The Docker factory had absorbed live container ops but left the
host-side prep (image-name resolution, container-name collision
retry, pipelock yaml generation, env_resolve writes, host
validation) in cli/start.py. That kept ~half the Docker-specific
logic outside the abstraction.
Split the factory into two phases:
prepare_docker_bottle(spec, stage_dir=...) -> DockerBottlePlan
Resolves names, validates skills/SSH, writes scratch files.
No Docker resources created yet.
launch_docker_bottle(plan) -> ContextManager[Bottle]
Builds image, creates networks, boots pipelock, runs the
agent container, provisions files. Teardown on exit.
DockerBottleSpec shrinks to intent-only inputs (manifest, agent
name, --cwd flag, user_cwd, forward_oauth_token). The CLI no longer
references docker_mod, pipelock, skills, ssh, or env_resolve.
get_bottle_factory becomes get_bottle_platform returning a
BottlePlatform with .prepare and .launch — one selectable thing per
platform.
The Bottle handle now remembers the in-container prompt path and
adds --append-system-prompt-file to claude's argv when present, so
the CLI no longer needs to know the path.
cmd_start: ~148 lines down from 229. Tests pass; dry-run output
byte-identical.
Introduce claude_bottle/bottles/ with a Bottle Protocol and a
get_bottle_factory() that dispatches on CLAUDE_BOTTLE_PLATFORM
(default "docker"). Move every Docker-specific subprocess.run call
from cli/start.py, plus the orchestration of build, networks, the
pipelock sidecar, container launch, and per-container provisioning
(prompt, skills, ssh, .git), into create_docker_bottle.
Drop bottles[].runtime from the manifest schema. Auto-detect whether
gVisor is registered with the daemon and pass --runtime=runsc when it
is; the preflight shows the resolved runtime so the choice is visible.
Manifests still carrying 'runtime' get a clear error pointing at the
auto-detect behavior, rather than silent ignore.
Out of scope: cli/cleanup.py and cli/list.py still call docker
directly. They enumerate active bottles across the host, which is a
separate concern from "create a bottle" and is left for a follow-up
that introduces a list_active/cleanup primitive on the factory.
The jq-style mapping (bool→"boolean", list→"array", None→"null", etc.)
existed only to match the original bash error wording. Not worth the
extra function; Python's native type names are clear enough.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- log.die() typed NoReturn so pyright knows it terminates control flow
(was returning the unreachable Die instance type).
- manifest.py: raw inputs typed object (not Any) and narrowed via a new
_as_json_object helper that validates str keys and returns
dict[str, object]. Eliminates the Unknown cascade through .get()
calls under strict.
- _from_dict classmethods renamed to from_dict so cross-class
construction (Bottle.from_dict from Manifest.from_json_obj, etc.)
doesn't trip reportPrivateUsage.
- _SUPPORTED_RUNTIMES typed tuple[Runtime, ...] so the membership
check narrows runtime_raw to Literal["runc", "runsc"] and the
# type: ignore[assignment] is no longer needed.
- Bottle.env uses a typed _empty_str_dict factory; bare dict resolves
to dict[Unknown, Unknown] under strict.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the TypedDict + 14 manifest_* free functions with frozen
dataclasses (SshEntry, BottleEgress, Bottle, Agent, Manifest) carrying
their own validators and constructors. Call sites import Manifest and
chain attribute access; the manifest_* helpers and manifest_validate
are gone.
Behavior changes worth flagging:
- Agent.bottle is now required (was optional with a "(none)" fallback).
Manifest.from_json_obj dies if any agent lacks a 'bottle' field or
references an undefined bottle, where previously start.py raised the
error lazily for the specific agent being launched.
- ssh.py now takes SshEntry instances; Host/IdentityFile shape checks
moved upstream into Manifest construction, leaving only the IdentityFile
filesystem-existence check in ssh_validate_entries.
- pipelock_bottle_allowlist's per-element string check is dropped — the
Manifest validator enforces it at load.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move schema checks out of per-access getters into a single
manifest_validate pass invoked by manifest_resolve. Getters can now
assume bottles/agents are well-typed dicts and every agent has a
defined bottle, so the .get(...) or {} chains collapse. Behavior
change: a bad runtime / shape error anywhere in the manifest now
fails at load instead of on the N-th read.
Intermediate step toward replacing TypedDict with a dataclass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the ranked list of changes that would move the project from
"works for me" toward the perceived maturity of comparable tools —
onboarding friction, error messages, distribution, versioning, schema
validation, starter library, docs site, cross-platform CI. Includes
effort estimates and an explicit "what polish is not" section so the
roadmap doesn't drift into feature work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lead with what the project does for the user — scoped Claude Code
agents on self-hosted infrastructure with per-agent secret and egress
limits — instead of the 2024-coded "isolated container" framing.
Tagline, "Why claude-bottle?" intro, and goals list now name secret
minimization, egress allowlisting, and self-hosted operation as the
load-bearing properties.
Also adds a sentence to the security model noting that DoH is
blocked structurally by the existing egress allowlist, since the
bottle has no L3 path off-box except through pipelock's hostname-
allowlisted CONNECT proxy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Soften the "container is the boundary against reaching the host"
framing in favor of what the design actually leans on: granting each
bottle only the secrets it needs, and constraining where those
secrets can travel via pipelock. The container is one layer rather
than the load-bearing one.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the surface area of the current Docker integration, how it
maps to Apple's `container` framework, the dominant networking risk
(pipelock multi-network attach), and the cost difference between a
faithful port and a simplified VM-firewall variant.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures the reasoning for staying on Python, the conditions under which
a Go rewrite would pay for itself, and why bash isn't viable at the
project's current size.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the canonical Apache 2.0 text at repo root and adds a License
section to the README with the copyright line. Apache 2.0 picked over
MIT for the patent grant and contributor language, both of which
matter for a security-adjacent tool aiming at corporate-evaluable
adoption. Also tidies the manifest example's GIT_AUTHOR_NAME to use
the project's online handle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the standalone Egress section with a Manifest section that
shows a complete bottle + agent example, with the egress and gVisor
explanations folded into JSONC comments above the relevant keys. The
gVisor paragraph in Security model is trimmed to a one-line pointer
at the manifest example.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A short apothecary-bottle SVG with a cream cartoon robot inside —
sized roughly to the robot so it works as a favicon-shaped icon.
README gains a centered logo above the title and a Trademarks
section disclaiming affiliation with Anthropic and framing the
"claude" in the project name as descriptive use.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Concrete worked example covering image strategy (with the bake-the-
claude-bottle-image-in optimization that elides 30-90s of in-VM
build), cold/warm/hot boot-to-prompt timing, standby vs ephemeral
cost breakdown, three workflow patterns, and Fly-specific gotchas
(DinD kernel requirements, the y/N preflight blocking automated
launch, pricing-may-have-moved hedge).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Argues that running claude-bottle unchanged on a remote Linux VM with
dockerd is the cheapest practical path to stronger isolation than
local Docker — preserves the v1 pipelock topology, requires zero code
changes, and shrinks the agent's blast radius from the developer
laptop to a disposable VM. Cross-references the existing
stronger-isolation-alternatives and local-vs-remote-agent-execution
notes so the research set composes cleanly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bottles can now set "runtime": "runsc" to launch the agent container
under gVisor instead of runc, adding a userspace syscall barrier
between the agent and the host kernel. Default is runc (Docker
default). Pipelock stays on the default runtime per the research doc's
minimum-diff prescription.
The launcher verifies runsc is registered with the daemon before
launch, surfaces the runtime in the preflight plan, and dies with an
install pointer (and a macOS-not-supported note) when runsc is
requested but unavailable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Frames the per-agent isolation story (each bottle gets only the env,
skills, ssh, and egress hosts its manifest grants) and is honest about
the limits of the container boundary, pointing at the new research doc
for the stronger-isolation v2 question.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Surveys gVisor, Kata, Firecracker, and Apple Container as replacements
or complements to Docker+runc, with concrete file-level migration notes
for this codebase and a recommended rung-by-rung path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cleans up references to the pre-refactor bash layout (cli.sh,
lib/*.sh, scripts/*.sh) across README, Dockerfile, the pipelock PRD,
and research notes. Refreshes line numbers in the oauth-token note
against the current cli/start.py.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cmd_build was ignoring its argv, so 'cli.py build --help' fell through
and started a docker build instead of printing the subcommand's
argparse help. Wire up an empty parser so --help and unknown args are
handled the same way the other subcommands handle them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
One file per subcommand under claude_bottle/cli/, with shared constants
and the tty helper in _common.py and dispatch in __init__.py. The
public import (from claude_bottle.cli import main) is unchanged, so
the root cli.py entrypoint and the test suite see no surface change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drop docs/JOURNAL.md and .claude/skills/init-entry/, and update
CLAUDE.md, docs/INDEX.md, and claude-bottle.example.json so nothing
points at them anymore.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Worked example covering two bottles (one minimal, one with all three
env-entry modes, an SSH entry, and a wider egress allowlist) and three
agents that share them. Named .example.json so manifest_resolve does
not auto-load it when running cli.py from the repo root.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Update the quickstart command to ./cli.py and drop a stale Dockerfile
comment that referenced scripts/lib/auth.sh, which no longer exists
after the bash->Python refactor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drop the Intended design section and PRD references; keep only
What this is, Goals, Non-goals, Repository layout, Conventions,
and When you're unsure.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces cli.sh + lib/*.sh with a claude_bottle/ Python package and a
cli.py entry point. No external dependencies — uses only Python's
stdlib (json, subprocess, getpass, tempfile, argparse, re, etc.).
- claude_bottle/{log,docker,manifest,env_resolve,network,pipelock,
skills,ssh,cli}.py mirror the previous lib/*.sh modules.
- Tests converted to unittest under tests/test_*.py with a stdlib
runner at tests/run_tests.py (unit | integration | path).
- .githooks/commit-msg ported to Python; same Conventional Commits rules.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously cmd_start unconditionally passed --remote-control to claude.
Make it a parsed flag so callers can choose. Behavior change: the
default is now disabled — pass --remote-control to opt in.
Surfaced in usage, the launch plan, and the assembled CLAUDE_ARGS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cmd_start declared SLUG as local, but cleanup_all (registered as the
EXIT/INT/TERM trap) calls pipelock_stop "$SLUG" after cmd_start has
returned and the local is out of scope. With set -u this aborted
shell teardown with "SLUG: unbound variable". Drop the local to
match the convention already used for MANIFEST_FILE, CONTAINER, and
STAGE_DIR.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Investigates whether the Gitea `tea` CLI can be authenticated via a
header-injecting proxy so the token never enters the container — even as
an env var. Parallels the OAuth-token research note. Recommends an
in-container root-owned reverse proxy as the lowest-friction shape, and
flags the unavoidable tradeoff that the agent retains the token's full
API scope (no exfil ≠ no harm).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Walks the current `docker run -e CLAUDE_CODE_OAUTH_TOKEN` flow, why claude
can read the token trivially via its Bash tool, why no Linux primitive
hides an env var from its own process, and why a root-owned localhost
auth-injecting reverse proxy (paired with an egress allowlist) is the
realistic mitigation. Documents `ANTHROPIC_BASE_URL` caveats (SSE,
header passthrough, issue #36998, out-of-band traffic).