Freezer._freeze only ever used bottle.name, which is always
f"bot-bottle-{agent.slug}". Remove the Bottle parameter from
commit() and _freeze(), derive the container name from agent.slug
directly in each subclass, and delete the _NamedBottle stub that
existed solely to paper over this.
Adds a Freezer ABC (backend/freeze.py) that encapsulates the
stop-commit-mark-preserved flow for all backends, following the same
pattern as BottleBackend. Each backend gets its own Freezer subclass:
DockerFreezer — docker commit
MacosContainerFreezer — container export + image rebuild; prompts
to stop if the container is running
SmolmachinesFreezer — smolvm pack create --from-vm
The base class owns write_committed_image, mark_preserved, and the
resume hint. Subclasses implement _freeze() and optionally override
_export_hint() for migration instructions.
Freezer.commit(agent, bottle) is the primary entry point for use
within a live launch context. Freezer.commit_slug(slug) is a
convenience wrapper for cmd_commit, which no longer branches on
backend names itself.
get_freezer(backend_name) is the factory, analogous to
get_bottle_backend(). CommitCancelled is raised by MacosContainerFreezer
when the user declines the stop prompt; cmd_commit catches it and
returns 0.
BottleSpec.manifest was ManifestIndex | Manifest — a union encoding
two lifecycle stages in one field. The union was unjustifiable:
it forced a type-narrowing workaround (loaded_manifest property)
on every consumer.
Clean split:
- BottleSpec.manifest: ManifestIndex (always; CLI-supplied intent)
- BottlePlan.manifest: Manifest (always; loaded by _validate())
_validate() returns the loaded Manifest directly. prepare() passes
it to _resolve_plan(), which stores it on the plan. All provisioner
code now reads plan.manifest.agent / plan.manifest.bottle — no
union, no asserts, no type: ignore.
BottleSpec.manifest is ManifestIndex | Manifest (pre/post _validate()).
Downstream code always runs post-validate so it needs Manifest, but
pyright flagged every .agent/.bottle access. The new loaded_manifest
property asserts isinstance and returns Manifest, giving pyright a
narrowed type without scattering type: ignore everywhere.
Also remove unused Manifest imports from test files and annotate the
_index() helper in test_manifest_agent_git_user.
Manifest now holds exactly one agent and one effective bottle (with
git_user overlay already applied). The old multi-agent/bottle
collection is renamed ManifestIndex. BottleSpec.manifest starts as
ManifestIndex from the CLI and becomes Manifest after _validate()
calls load_for_agent(); all provisioning code downstream reads
spec.manifest.agent / spec.manifest.bottle instead of indexing by name.
Manifest.resolve() now returns an empty-dict manifest with only directory
paths recorded (home_md, cwd_md). No content is read from any .md file
until load_for_agent() is called for a specific agent at preflight.
- Manifest.from_md_dirs: scan-only, no frontmatter parsing
- Manifest.load_for_agent: parses the selected agent file and its bottle
chain; works on eager (from_json_obj) manifests too by returning self
- Manifest.all_agent_names: scans filenames in lazy mode
- backend._validate: calls load_for_agent and propagates upgraded spec
- cli/info.py, cli/list.py, cli/start.py: use load_for_agent / all_agent_names
- manifest_extends.py: reverted to original (no partial-resolve helpers)
- manifest_loader.py: only scan_agent_names + load_bottle_chain_from_dir
- Tests updated to call load_for_agent before accessing agents/bottles;
test_md_agent_repos_deferred renamed to test_md_agent_repos_fails_at_preflight
The git-gate copies the identity file at start time and surfaces a
clear failure then; the pre-launch presence check was redundant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_validate_git_entries was written for static keys (PRD 0008) and ran
os.path.isfile() on every entry's IdentityFile. gitea-provider repos
(PRD 0047/0048) create their deploy key at provision time, so
IdentityFile is empty at parse — tripping the check with an empty path
("git upstream key file not found for '<name>': "). Gate the host-file
check on the static provider; gitea entries have nothing to verify here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the parallel fields passed through prepare() → _resolve_plan and
read everything from agent_provision instead. The provider plugin now
declares its own guest_home (so the backend stops hardcoding
"/home/node") and the wrapper that builds the provision plan accepts
instance_name and prompt_file, which providers store on the plan.
DockerBottlePlan and SmolmachinesBottlePlan expose container_name /
machine_name, image / agent_image, dockerfile_path /
agent_dockerfile_path, and prompt_file as properties that delegate to
agent_provision so existing call sites keep working unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BottleBackend.prepare was calling resolve_manifest_dockerfile("", spec)
for every bottle where the manifest did not set agent_provider.dockerfile.
That resolves an empty string against user_cwd, returning the cwd
itself — which docker then tried to read as a Dockerfile, giving
"is a directory" errors during image build.
When the manifest doesn't override, use the provider plugin's bundled
Dockerfile path (next to its agent_provider.py module) — mirroring
the pre-refactor behavior.
BottleBackend.prepare computed slug and resolved_env but never passed
them to _resolve_plan. The concrete docker/smolmachines _resolve_plan
methods still had the old (spec, *, stage_dir) signature too, so
prepare's kwargs blew up with "unexpected keyword argument
'instance_name'" the moment cli.py start was invoked.
Update the abstract _resolve_plan signature and both backend
implementations to accept the full kwarg set prepare passes, and
forward to resolve_plan.resolve_plan() with everything.
The recent refactor partially removed workspace planning and
capability-apply logic. This commit finishes the cleanup so the
test suite imports cleanly:
- Comment out workspace_plan field/property on BottlePlan and the
provision_workspace dispatch.
- Comment out workspace usages in docker.util (build_image_with_cwd),
smolmachines.provision.workspace, agent_provider.provision_git,
smolmachines.backend.
- Comment out capability_apply imports in cli.start and cli.supervise;
add a local CapabilityApplyError placeholder so the supervise CLI
module still imports.
- Break the bottle_state → backend.docker → backend circular import
by lazy-loading docker_mod inside bottle_identity, and by moving the
resolve_common import inside BottleBackend.prepare.
- Delete tests for workspace and capability_apply (unit + integration).
- Update test fixtures to drop removed kwargs (container_name_pinned,
derived_image, env_file, workspace_plan, agent_image_ref) from
DockerBottlePlan / SmolmachinesBottlePlan constructors.
- Delete the obsolete test_smolmachines_prepare.py (tested the old
resolve_plan signature; the shared prepare flow now lives in
BottleBackend.prepare).
- Adjust test_supervise.py for the new Supervise.prepare signature
(dockerfile_content arg removed).
925 → 897 tests, all passing.
guest_home is now a field on AgentProvisionPlan (set by each provider's
provision_plan() method). BottlePlan.guest_home becomes a read-only
property delegating to agent_provision.guest_home so existing callers
(provision_git, provision_skills, provision_prompt) are unchanged.
Both resolve_plan.py files drop guest_home from the plan constructor
call; the local variable still exists as an intermediary for the
workspace_plan call that precedes agent_provision_plan.
Chunk 1 (schema + storage): BottleSpec, ActiveAgent, and BottleMetadata
gain label and color fields. Both docker and smolmachines backends
persist them to metadata.json on prepare and surface them in
enumerate_active_agents(). AgentProvider.provision_plan() passes
label/color through to the Claude provider, which injects them into
claude.json so claude-code displays the session name and color in its
header. Codex provider accepts and ignores the knobs.
Chunk 2 (curses modal + display): cmd_start presents a two-step curses
modal — first edit the label (first keystroke replaces the pre-fill),
then optionally pick a color. cli list active renders label with ANSI
escape codes when the terminal supports it, falling back to agent_name
when no label is set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove unused Bottle import from docker/backend.py (pyright)
- Suppress wrong-import-position on circular-import-avoiding
deferred imports in backend/__init__.py (pylint C0413)
- Add encoding="utf-8" to read_text() in smolmachines provision
test (pylint W1514)
- Suppress consider-using-with on TemporaryDirectory setUp pattern
in both provision test files (pylint R1732)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add _load_user_plugin: loads AgentProvider subclass from
~/.bot-bottle/contrib/<name>/agent_provider.py; get_provider()
checks there first before falling back to built-ins
- Add Dockerfile cascade to docker prepare: per-bottle override →
manifest dockerfile → user plugin Dockerfile → provider default
- Move provision_ca and provision_git from backend-specific
provision/ modules to AgentProvider ABC as overridable defaults;
delete docker/provision/ca.py, docker/provision/git.py,
smolmachines/provision/ca.py, smolmachines/provision/git.py
- Add git_gate_insteadof_host/scheme properties to BottlePlan base;
SmolmachinesBottlePlan overrides them to return agent_git_gate_host
and "http" so provision_git works correctly on both backends
- Move SIGKILL retry from smolmachines provision/ca.py into
SmolmachinesBottle.exec via _exec_raw helper — all exec calls
on smolmachines now transparently retry once on exit 137
- Relax manifest_agent template validation to allow user-defined
template names; keep auth_token/forward_host_credentials guards
for built-in-only features
- Update tests: rewrite test_docker_provision_git_user and
test_smolmachines_provision to call provider methods directly;
add TestSmolmachinesBottleExec for SIGKILL retry coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Strip pipelock from all unit and integration test fixtures:
proxy_plan fields removed from DockerBottlePlan/SmolmachinesBottlePlan
constructors; pipelock-specific test classes deleted or renamed
- Update test_sidecar_init: remove test_pipelock_loses_egress_tokens,
rename "pipelock" daemon fixtures to "git-gate" throughout
- Remove test_pipelock_binary_present_and_versioned from integration test
- Remove test_pipelock_answers_on_bundle_ip from smolmachines launch test
- Update _SANDBOX_BLOCK_MARKERS: remove "pipelock" marker (egress blocks)
- Dockerfile.sidecars: remove pipelock build stage and COPY; update layout
comments and port table
- egress_entrypoint.sh: update comments now that egress is sole proxy
- Clean up pipelock references in comments/docstrings across backend,
network, manifest, supervise, git_gate, yaml_subset, agent_provider,
sidecar_bundle, sidecar_init, egress_addon_core modules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per PR review feedback (review #132): guest_home shouldn't be
buried inside workspace_plan / read from a hardcoded literal in
each provision module. It's a cross-cutting bottle property — the
backend's prepare step knows it, and every downstream consumer
(contrib providers, git provisioning, gitconfig path) should
read it from one place.
- Adds guest_home: str to BottlePlan base dataclass.
- Both backends' prepare steps populate plan.guest_home.
- contrib/{claude,codex}/agent_provider.py read plan.guest_home
(was plan.workspace_plan.guest_home).
- bot_bottle/backend/docker/provision/git.py reads plan.guest_home
for the gitconfig destination (was hardcoded "/home/node").
- bot_bottle/backend/smolmachines/provision/git.py drops the
_GUEST_HOME / _guest_home() helpers and reads plan.guest_home.
- Tests that construct BottlePlan subclasses directly pass
guest_home="/home/node" explicitly.
BottleBackend.provision now resolves the provider plugin from the
plan and dispatches prompt / skills / declarative-apply /
supervise-mcp through it. The four hooks the docker + smolmachines
backends used to override (provision_skills, provision_prompt,
provision_provider_auth, provision_supervise) are gone — the
duplicated 50-line implementations under
backend/{docker,smolmachines}/provision/{skills,prompt,
provider_auth,supervise}.py are deleted.
Each backend gains a small supervise_mcp_url(plan) override so the
provider plugin can run `claude mcp add` / `codex mcp add`
against the right URL: docker returns
http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/ on the compose
network alias; smolmachines returns plan.agent_supervise_url which
launch.py already pins to a host-loopback port.
Removes tests/unit/test_provision_supervise.py — the URL it
asserted on now lives on the backend, with no equivalent
standalone surface to test against (it's covered by the broader
plan / launch integration tests).
Closes#178.
The backend provision functions now receive a Bottle handle with
exec / cp_in methods instead of a raw target string. Provisioner
modules use bottle.exec and bottle.cp_in in place of inlined
subprocess.run(["docker", "exec"/"cp", ...]) and direct
_smolvm.machine_cp / machine_exec calls. This decouples the
provisioners from backend-specific runtime primitives so future
refactors (e.g. the supervise rework) can swap the bottle's exec
implementation without touching every provisioner.
Each launch.py constructs the Bottle handle before calling
provision so it can be passed in; provision_prompt's return value
is wired back onto the bottle's prompt path attribute after the
fact.
Move git_gate_plan, egress_plan, supervise_plan, and agent_provision
from DockerBottlePlan and SmolmachinesBottlePlan into BottlePlan.
Replace the abstract print method with a single concrete implementation
that renders git gate entries as "name → upstream_host:upstream_port"
and egress routes with conditional "[auth:scheme]" annotations.