Make it easier to add and stack docker stages to agent images #394

Open
opened 2026-07-16 23:20:44 -04:00 by didericis · 6 comments
Owner

Motivation

Bottle manifests should be able to add tools, users, packages, and other image customization on top of the image supplied by the agent provider without duplicating or replacing the provider Dockerfile.

The provider remains responsible for choosing the compatible base distro and establishing the prerequisites and runtime contract bot-bottle needs. A bottle may compose ordered customization layers between provider setup and final agent provisioning. Changing the base distro or bypassing provider assumptions remains the existing full-override agent_provider.dockerfile use case.

Proposed manifest shape

Use layers rather than stages: these entries are ordered image build fragments, not Docker multi-stage targets.

image:
  layers:
    - name: laser-tools
      dockerfile: |
        USER root
        RUN apt-get update \
            && apt-get install -y --no-install-recommends imagemagick \
            && rm -rf /var/lib/apt/lists/*
    - name: project-tools
      dockerfile: .bot-bottle/Dockerfile.tools

A layer's dockerfile may be an inline literal block or a path to a fragment in the repository. Names are used in diagnostics and should be unique within the resolved bottle.

Build order and contract

The effective image is built in this order:

  1. provider base image and root prerequisites;
  2. bottle-owned image.layers, in resolved manifest order;
  3. a generated bot-bottle finalization layer that restores and validates runtime invariants;
  4. runtime provisioning and agent launch.

Providers must expose a named customization boundary/build target. The builder builds that target, applies every bottle fragment on top of the previous result, and then applies the generated finalization layer.

The finalization layer must not assume fragments preserved provider state. It restores or validates the expected runtime user, home, workdir, certificates, filesystem permissions, entrypoint/command contract, and any other provider-declared invariants needed to launch the agent.

For extends:, parent layers are applied first and child layers are appended.

YAML-subset support

The hand-written YAML subset currently rejects multiline block scalars. Add narrowly scoped literal scalar support for inline Dockerfiles:

  • support | and |-, including their trailing-newline behavior;
  • preserve block content needed by Dockerfiles, including blank lines, # comments, indentation, colons, and backslash continuations;
  • return the parsed block as a normal string;
  • make serialize_yaml_subset emit multiline strings as literal blocks and round-trip them correctly;
  • keep folded >, anchors, aliases, tags, and unrelated full-YAML features unsupported.

Security and build context

  • A fragment must not contain FROM; changing or bypassing the provider base is not part of image.layers.
  • Builds use a deliberately constructed minimal context. A layer does not implicitly receive the entire checkout.
  • Dockerfile text and build arguments must never contain interpolated secrets. If build-time secrets are added later, they must use backend-native secret mounts.
  • File-backed fragment paths must be resolved safely within the allowed repository/context boundary.

Caching and diagnostics

Cache identity must include, as applicable:

  • provider Dockerfile/base target digest;
  • normalized inline fragment content;
  • referenced fragment and allowed build-context file contents;
  • resolved layer order;
  • target platform/backend-relevant inputs;
  • bot-bottle finalization version.

Build failures identify the bottle, layer name, and layer position. Each backend may use its native image/cache representation, but ordering and invalidation semantics must be consistent.

Acceptance criteria

  • The manifest model accepts an ordered image.layers list with unique named entries.
  • Each layer accepts either an inline literal Dockerfile fragment or a repository-relative fragment path, with clear validation for missing, conflicting, or invalid values.
  • The YAML-subset parser and serializer support and round-trip | and |- literal blocks without enabling folded scalars or other excluded YAML features.
  • Literal-block tests cover nesting under image.layers[], blank lines, Dockerfile comments, colons, indentation, backslash continuations, and trailing-newline behavior.
  • Layers execute after the provider customization boundary, in manifest order, and before bot-bottle finalization/provisioning.
  • extends: resolves parent layers first and appends child layers deterministically.
  • Fragments containing FROM are rejected with a useful error.
  • File-backed fragments cannot escape the allowed repository/build-context boundary.
  • The generated build context contains only declared/required inputs rather than the checkout implicitly.
  • Secrets are not interpolated into Dockerfile text, build args, logs, cache keys, or generated context.
  • Finalization restores and validates the provider runtime contract even when a fragment leaves hostile state such as USER root, a changed WORKDIR, or a custom ENTRYPOINT.
  • Cache keys change when the provider input, layer content/order, referenced file contents, platform inputs, or finalization version changes.
  • Build errors name the bottle and failing layer.
  • Docker, macOS-container, and Firecracker/Buildah tests cover supported-backend parity for ordering, composition, finalization, failures, and cache invalidation.
  • Existing agent_provider.dockerfile full-override behavior remains available for users who need to change the base distro or provider assumptions.
## Motivation Bottle manifests should be able to add tools, users, packages, and other image customization on top of the image supplied by the agent provider without duplicating or replacing the provider Dockerfile. The provider remains responsible for choosing the compatible base distro and establishing the prerequisites and runtime contract bot-bottle needs. A bottle may compose ordered customization layers between provider setup and final agent provisioning. Changing the base distro or bypassing provider assumptions remains the existing full-override `agent_provider.dockerfile` use case. ## Proposed manifest shape Use `layers` rather than `stages`: these entries are ordered image build fragments, not Docker multi-stage targets. ```yaml image: layers: - name: laser-tools dockerfile: | USER root RUN apt-get update \ && apt-get install -y --no-install-recommends imagemagick \ && rm -rf /var/lib/apt/lists/* - name: project-tools dockerfile: .bot-bottle/Dockerfile.tools ``` A layer's `dockerfile` may be an inline literal block or a path to a fragment in the repository. Names are used in diagnostics and should be unique within the resolved bottle. ## Build order and contract The effective image is built in this order: 1. provider base image and root prerequisites; 2. bottle-owned `image.layers`, in resolved manifest order; 3. a generated bot-bottle finalization layer that restores and validates runtime invariants; 4. runtime provisioning and agent launch. Providers must expose a named customization boundary/build target. The builder builds that target, applies every bottle fragment on top of the previous result, and then applies the generated finalization layer. The finalization layer must not assume fragments preserved provider state. It restores or validates the expected runtime user, home, workdir, certificates, filesystem permissions, entrypoint/command contract, and any other provider-declared invariants needed to launch the agent. For `extends:`, parent layers are applied first and child layers are appended. ## YAML-subset support The hand-written YAML subset currently rejects multiline block scalars. Add narrowly scoped literal scalar support for inline Dockerfiles: - support `|` and `|-`, including their trailing-newline behavior; - preserve block content needed by Dockerfiles, including blank lines, `#` comments, indentation, colons, and backslash continuations; - return the parsed block as a normal string; - make `serialize_yaml_subset` emit multiline strings as literal blocks and round-trip them correctly; - keep folded `>`, anchors, aliases, tags, and unrelated full-YAML features unsupported. ## Security and build context - A fragment must not contain `FROM`; changing or bypassing the provider base is not part of `image.layers`. - Builds use a deliberately constructed minimal context. A layer does not implicitly receive the entire checkout. - Dockerfile text and build arguments must never contain interpolated secrets. If build-time secrets are added later, they must use backend-native secret mounts. - File-backed fragment paths must be resolved safely within the allowed repository/context boundary. ## Caching and diagnostics Cache identity must include, as applicable: - provider Dockerfile/base target digest; - normalized inline fragment content; - referenced fragment and allowed build-context file contents; - resolved layer order; - target platform/backend-relevant inputs; - bot-bottle finalization version. Build failures identify the bottle, layer name, and layer position. Each backend may use its native image/cache representation, but ordering and invalidation semantics must be consistent. ## Acceptance criteria - [ ] The manifest model accepts an ordered `image.layers` list with unique named entries. - [ ] Each layer accepts either an inline literal Dockerfile fragment or a repository-relative fragment path, with clear validation for missing, conflicting, or invalid values. - [ ] The YAML-subset parser and serializer support and round-trip `|` and `|-` literal blocks without enabling folded scalars or other excluded YAML features. - [ ] Literal-block tests cover nesting under `image.layers[]`, blank lines, Dockerfile comments, colons, indentation, backslash continuations, and trailing-newline behavior. - [ ] Layers execute after the provider customization boundary, in manifest order, and before bot-bottle finalization/provisioning. - [ ] `extends:` resolves parent layers first and appends child layers deterministically. - [ ] Fragments containing `FROM` are rejected with a useful error. - [ ] File-backed fragments cannot escape the allowed repository/build-context boundary. - [ ] The generated build context contains only declared/required inputs rather than the checkout implicitly. - [ ] Secrets are not interpolated into Dockerfile text, build args, logs, cache keys, or generated context. - [ ] Finalization restores and validates the provider runtime contract even when a fragment leaves hostile state such as `USER root`, a changed `WORKDIR`, or a custom `ENTRYPOINT`. - [ ] Cache keys change when the provider input, layer content/order, referenced file contents, platform inputs, or finalization version changes. - [ ] Build errors name the bottle and failing layer. - [ ] Docker, macOS-container, and Firecracker/Buildah tests cover supported-backend parity for ordering, composition, finalization, failures, and cache invalidation. - [ ] Existing `agent_provider.dockerfile` full-override behavior remains available for users who need to change the base distro or provider assumptions.
didericis added the Kind/Feature
Priority
High
2
labels 2026-07-16 23:20:58 -04:00
didericis-claude was assigned by didericis 2026-07-16 23:33:36 -04:00
Author
Owner

@didericis-codex give me feedback on this issue/flesh it out more.

@didericis-codex give me feedback on this issue/flesh it out more.
Collaborator

I agree with the direction. I'd make this an ordered list of bottle-owned image layers while keeping the provider responsible for the base distro and final runtime contract:

  1. provider base image/root prerequisites;
  2. bottle-supplied build fragments, in manifest order;
  3. a bot-bottle finalization layer that restores and validates runtime invariants;
  4. runtime provisioning and agent launch.

The finalization step matters because a fragment can change USER, WORKDIR, ENTRYPOINT, certificates, or filesystem permissions. We should explicitly restore the provider's expected user/home/workdir and validate the resulting image rather than assume fragments preserve them.

A possible shape:

image:
  stages:
    - name: laser-tools
      dockerfile: |
        USER root
        RUN apt-get update \
            && apt-get install -y --no-install-recommends imagemagick \
            && rm -rf /var/lib/apt/lists/*
    - name: project-tools
      dockerfile: .bot-bottle/Dockerfile.tools

Ordered stages also give extends: straightforward semantics: parent stages first, then child stages. Each can become a cached intermediate image shared by the Docker, macOS-container, and Firecracker/Buildah backends.

The main implementation constraint is that Dockerfiles cannot literally splice content into another Dockerfile. Providers therefore need to expose a named customization boundary or build target. The builder can build that provider target, apply each bottle fragment as FROM <previous-stage>, then add the generated bot-bottle finalization layer. I'd keep agent_provider.dockerfile as the full-override escape hatch for changing distro or provider assumptions; stages should be the normal customization path.

Details worth including in the acceptance criteria:

  • reject FROM in fragments unless bypassing the provider base is explicitly supported;
  • use a minimal generated build context rather than exposing the entire checkout implicitly;
  • never interpolate secrets into Dockerfile text or build args; future secret support should use backend-native secret mounts;
  • include the provider Dockerfile digest, normalized fragments/referenced-file contents, platform, and finalization version in cache keys;
  • identify the bottle and named stage in build failures;
  • test backend parity, ordering, extends: composition, cache invalidation, and fragments that leave hostile state such as USER root or a custom ENTRYPOINT.

One naming thought: these are image layers/build fragments rather than Docker multi-stage targets in the usual sense, so image.layers or image.build may be less ambiguous than stages.

Overall: provider chooses the compatible base, the bottle composes custom layers, and bot-bottle reasserts the runtime contract. That gives the laser/tool-installation use case a concise path without making every bottle duplicate a provider Dockerfile.

I agree with the direction. I'd make this an ordered list of bottle-owned image layers while keeping the provider responsible for the base distro and final runtime contract: 1. provider base image/root prerequisites; 2. bottle-supplied build fragments, in manifest order; 3. a bot-bottle finalization layer that restores and validates runtime invariants; 4. runtime provisioning and agent launch. The finalization step matters because a fragment can change `USER`, `WORKDIR`, `ENTRYPOINT`, certificates, or filesystem permissions. We should explicitly restore the provider's expected user/home/workdir and validate the resulting image rather than assume fragments preserve them. A possible shape: ```yaml image: stages: - name: laser-tools dockerfile: | USER root RUN apt-get update \ && apt-get install -y --no-install-recommends imagemagick \ && rm -rf /var/lib/apt/lists/* - name: project-tools dockerfile: .bot-bottle/Dockerfile.tools ``` Ordered stages also give `extends:` straightforward semantics: parent stages first, then child stages. Each can become a cached intermediate image shared by the Docker, macOS-container, and Firecracker/Buildah backends. The main implementation constraint is that Dockerfiles cannot literally splice content into another Dockerfile. Providers therefore need to expose a named customization boundary or build target. The builder can build that provider target, apply each bottle fragment as `FROM <previous-stage>`, then add the generated bot-bottle finalization layer. I'd keep `agent_provider.dockerfile` as the full-override escape hatch for changing distro or provider assumptions; stages should be the normal customization path. Details worth including in the acceptance criteria: - reject `FROM` in fragments unless bypassing the provider base is explicitly supported; - use a minimal generated build context rather than exposing the entire checkout implicitly; - never interpolate secrets into Dockerfile text or build args; future secret support should use backend-native secret mounts; - include the provider Dockerfile digest, normalized fragments/referenced-file contents, platform, and finalization version in cache keys; - identify the bottle and named stage in build failures; - test backend parity, ordering, `extends:` composition, cache invalidation, and fragments that leave hostile state such as `USER root` or a custom `ENTRYPOINT`. One naming thought: these are image layers/build fragments rather than Docker multi-stage targets in the usual sense, so `image.layers` or `image.build` may be less ambiguous than `stages`. Overall: provider chooses the compatible base, the bottle composes custom layers, and bot-bottle reasserts the runtime contract. That gives the laser/tool-installation use case a concise path without making every bottle duplicate a provider Dockerfile.
Author
Owner

@didericis-codex looks good. Agree with changing it to layers. Also I think we'll need to modify the simple yaml subset we created/not sure we've made allowed for the syntax you used there yet. Evaluate how heavy a lift that is assuming I'm correct/we haven't added that yet, otherwise correct me.

@didericis-codex looks good. Agree with changing it to `layers`. Also I think we'll need to modify the simple `yaml` subset we created/not sure we've made allowed for the syntax you used there yet. Evaluate how heavy a lift that is assuming I'm correct/we haven't added that yet, otherwise correct me.
Collaborator

Confirmed: you're correct. bot_bottle/yaml_subset.py explicitly rejects | / > block scalars today, so the inline dockerfile: | example will not parse as-is.

I would call this a small-to-medium, localized lift rather than a parser rewrite. The existing nested list-of-mappings shape already works; the missing piece is the multiline scalar itself. The care point is that the tokenizer currently strips blank lines, trailing comments, and trailing whitespace before parsing. For Dockerfile content, lines such as # syntax=..., blank separators, continuations, and indentation need to be captured verbatim.

My recommendation is to add only literal block scalar support initially:

  • support | and probably |- for explicit trailing-newline behavior;
  • keep folded >, anchors, tags, and the rest of full YAML out of scope;
  • capture the indented block before normal comment/blank-line stripping;
  • return it as an ordinary str, so manifest/schema consumers need no special scalar type;
  • teach serialize_yaml_subset to emit multiline strings as literal blocks;
  • add round-trip tests covering a Dockerfile nested under image.layers[], including blank lines, # comments, backslash continuations, colons, and trailing-newline behavior.

That is likely one focused parser/serializer change plus tests. The main risk is regression in the shared YAML-subset parser (it is also used outside manifests), but literal blocks are syntactically explicit, so the new path can be isolated and existing inputs should remain unchanged.

Using only file-backed fragments would avoid this work, but since inline layers are the ergonomic core of the feature, I think the narrow | implementation is justified. Separately, adding image.layers to the manifest model/validation and implementing the image-building behavior is the larger part of issue #394.

Confirmed: you're correct. `bot_bottle/yaml_subset.py` explicitly rejects `|` / `>` block scalars today, so the inline `dockerfile: |` example will not parse as-is. I would call this a small-to-medium, localized lift rather than a parser rewrite. The existing nested list-of-mappings shape already works; the missing piece is the multiline scalar itself. The care point is that the tokenizer currently strips blank lines, trailing comments, and trailing whitespace before parsing. For Dockerfile content, lines such as `# syntax=...`, blank separators, continuations, and indentation need to be captured verbatim. My recommendation is to add only literal block scalar support initially: - support `|` and probably `|-` for explicit trailing-newline behavior; - keep folded `>`, anchors, tags, and the rest of full YAML out of scope; - capture the indented block before normal comment/blank-line stripping; - return it as an ordinary `str`, so manifest/schema consumers need no special scalar type; - teach `serialize_yaml_subset` to emit multiline strings as literal blocks; - add round-trip tests covering a Dockerfile nested under `image.layers[]`, including blank lines, `#` comments, backslash continuations, colons, and trailing-newline behavior. That is likely one focused parser/serializer change plus tests. The main risk is regression in the shared YAML-subset parser (it is also used outside manifests), but literal blocks are syntactically explicit, so the new path can be isolated and existing inputs should remain unchanged. Using only file-backed fragments would avoid this work, but since inline layers are the ergonomic core of the feature, I think the narrow `|` implementation is justified. Separately, adding `image.layers` to the manifest model/validation and implementing the image-building behavior is the larger part of issue #394.
Author
Owner

@didericis-codex RE #394 (comment)

Cool, update the issue/edit it and include the acceptance criteria you talked about.

@didericis-codex RE https://gitea.dideric.is/didericis/bot-bottle/issues/394#issuecomment-3943 Cool, update the issue/edit it and include the acceptance criteria you talked about.
Collaborator

Updated the issue body with the agreed image.layers design and the acceptance criteria, including the narrow | / |- YAML-subset work, ordering and extends: semantics, finalization invariants, build-context and secret constraints, caching, diagnostics, and backend-parity tests.

Updated the issue body with the agreed `image.layers` design and the acceptance criteria, including the narrow `|` / `|-` YAML-subset work, ordering and `extends:` semantics, finalization invariants, build-context and secret constraints, caching, diagnostics, and backend-parity tests.
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: didericis/bot-bottle#394