Initial commit
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
name: init-entry
|
||||||
|
description: Add a new timestamped entry to the project journal at ./docs/JOURNAL.md in the current working directory, creating the file (and `docs/`) if missing. Use when the user invokes /init-entry or asks to "log this in the journal", "add a journal entry", "record this", "write this down", or similar. Entries are stream-of-thought prose, not templates — newest on top, append-only.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add a journal entry
|
||||||
|
|
||||||
|
Adds a new entry to `./docs/JOURNAL.md` in the current working directory. The journal is an append-only log of unstructured stream-of-thought entries, newest on top. Each entry is a timestamp heading followed by freeform prose — whatever was on the mind worth capturing. No title, no template, no required sections. Do NOT under ANY CIRCUMSTANCES delete old entries when appending to the top.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Each entry looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
## YYYY-MM-DD HH:MM
|
||||||
|
|
||||||
|
[github](tag://github) [postgres](tag://postgres)
|
||||||
|
|
||||||
|
<freeform prose — stream of thought, not a template. Write what was just decided
|
||||||
|
or what's confusing or what's being tried. Multiple paragraphs are fine. No
|
||||||
|
section headings, no fill-in-the-blank prompts.>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tag line rules:**
|
||||||
|
|
||||||
|
- Tags appear on the line immediately under the timestamp heading (no blank line between header and tags), then a blank line before the body.
|
||||||
|
- Format is markdown links with the custom `tag://` URI scheme: `[name](tag://name)`. Multiple tags are space-separated on one line.
|
||||||
|
- The label and the URI value are usually the same word; differ only when there's a real reason (e.g. `[GitHub Actions](tag://github)`).
|
||||||
|
- **Tags are conditional.** Include them only when the entry has a coherent theme worth grouping across future entries. If nothing themes-up cleanly, omit the line entirely. Don't tag everything.
|
||||||
|
|
||||||
|
## Step 1 — Get the entry text from the invoker
|
||||||
|
|
||||||
|
The body of the entry must come from the invoker, verbatim. Your role is mechanical — timestamp, format, insert — not authorial. Do not draft, paraphrase, expand, summarize, or "improve" the prose. Do not draft from conversation context. Do not offer a draft for the invoker to react to. Do not propose what the entry "could be."
|
||||||
|
|
||||||
|
Get a timestamp by shelling out: `date '+%Y-%m-%d %H:%M'`. Use that exact value as the entry heading.
|
||||||
|
|
||||||
|
If the invoker passed content alongside the command (e.g. `/init-entry switched to postgres because sqlite locking was killing the worker pool`), use it as the body verbatim. If they passed nothing, ask what they want to capture and wait for their words.
|
||||||
|
|
||||||
|
Show the entry as it will be written (their timestamp + their prose) before writing to disk, so they can confirm formatting and insertion. Don't edit the content unprompted.
|
||||||
|
|
||||||
|
## Step 2 — Locate or create the journal
|
||||||
|
|
||||||
|
From the current working directory:
|
||||||
|
|
||||||
|
1. If `./docs/JOURNAL.md` exists → Step 3 (insert).
|
||||||
|
2. If `./docs/` exists but no `JOURNAL.md` → Step 4 (create).
|
||||||
|
3. If `./docs/` doesn't exist → create the directory, then Step 4.
|
||||||
|
|
||||||
|
## Step 3 — Insert into existing journal
|
||||||
|
|
||||||
|
Read `./docs/JOURNAL.md`. Find the insertion point:
|
||||||
|
|
||||||
|
- Scan for the first line matching `^## ` (an existing entry heading).
|
||||||
|
- Insert the new entry **before** that line, separated by one blank line above and below.
|
||||||
|
- If no `^## ` line exists, append the entry to the end of the file with one blank line separating it from prior content.
|
||||||
|
|
||||||
|
Never edit, reorder, or delete existing entries. The journal is append-only.
|
||||||
|
|
||||||
|
## Step 4 — Create a new journal file
|
||||||
|
|
||||||
|
Write `./docs/JOURNAL.md` with this skeleton followed by the new entry, so the format stays consistent regardless of which skill seeded the file:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Journal
|
||||||
|
|
||||||
|
Append-only stream of thought. Newest entries on top. Each entry is a timestamp
|
||||||
|
followed by freeform prose. Tag entries with `[name](tag://name)` links under
|
||||||
|
the header — only when a coherent theme emerges. Otherwise just write.
|
||||||
|
|
||||||
|
## YYYY-MM-DD HH:MM
|
||||||
|
|
||||||
|
<entry body>
|
||||||
|
```
|
||||||
|
|
||||||
|
Unix line endings, no trailing whitespace, no emojis.
|
||||||
|
|
||||||
|
## Step 5 — Commit and push
|
||||||
|
|
||||||
|
After the entry is written, commit and push it so the journal stays in sync with the remote. This step is best-effort: report failures inline, never retry, and never unwind the on-disk write.
|
||||||
|
|
||||||
|
1. If `git rev-parse --git-dir` fails (the cwd is not inside a git repo), skip this step entirely and note in the report that the entry was written but not committed.
|
||||||
|
2. Stage only the journal file:
|
||||||
|
```
|
||||||
|
git add docs/JOURNAL.md
|
||||||
|
```
|
||||||
|
Never use `git add -A` or `git add .` — unrelated working-tree changes are not the journal's concern and must not be swept into the commit.
|
||||||
|
3. Commit with a mechanical message derived from the timestamp (and tags, if any). Pass it via HEREDOC so quoting stays clean:
|
||||||
|
```
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
Journal: 2026-05-02 03:28
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
With tags, append them in parens after the timestamp: `Journal: 2026-05-02 03:28 (auth, postgres)`. The message must not paraphrase or summarize the entry body — the same no-synthesis rule that protects the body protects the commit log.
|
||||||
|
4. If the current branch has an upstream (`git rev-parse --abbrev-ref --symbolic-full-name @{u}` succeeds), run `git push`. If no upstream is configured or the push fails for any reason, report it and continue. Do not set an upstream, force-push, or otherwise paper over the failure.
|
||||||
|
|
||||||
|
If any of steps 2–4 fail, surface the failure in the report but do not amend, reset, or modify the on-disk state.
|
||||||
|
|
||||||
|
## Step 6 — Report
|
||||||
|
|
||||||
|
One short line: path to the file, "created" or "updated", and the timestamp. Append commit sha (or "not committed: <reason>") and push result (or "no upstream" / "push failed: <reason>"). Stop there.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- **Human-authored body.** Entry prose comes from the invoker, not from you. Don't draft, paraphrase, expand, or summarize. If invoked without content, ask. The journal is a record of the human's thinking — your synthesis doesn't belong in it.
|
||||||
|
- **Append-only.** Never edit or delete existing entries. To revise a past thought, write a new entry that references the prior timestamp.
|
||||||
|
- **No title in the heading.** Just the timestamp. Resisting the urge to title each entry is the point — entries are stream of thought, not curated essays.
|
||||||
|
- **No template inside the body.** Don't write `**What changed:**`, `**Considered:**`, `**Ruled out:**` as sub-headings. Plain prose.
|
||||||
|
- **Tags are conditional and live directly under the header.** Use `[name](tag://name)` markdown link format. Omit the tag line when no coherent theme exists. Don't invent themes.
|
||||||
|
- **Today's timestamp only.** Don't backdate.
|
||||||
|
- **No emojis.**
|
||||||
|
- **Don't volunteer to add anything else** (indexes, table of contents, README links, summaries). The journal stays text-only.
|
||||||
|
- **Mechanical commit message.** The commit message is derived from the timestamp and tags only. Never paraphrase the entry body into the subject line.
|
||||||
|
- **Scoped commit.** Only `docs/JOURNAL.md` is staged. Never sweep in unrelated working-tree changes, never amend a previous commit, never bypass hooks.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Keep the Docker build context lean. The Dockerfile doesn't COPY anything
|
||||||
|
# from the repo today, so this is mostly defensive — but it also means
|
||||||
|
# `docker build` doesn't ship gigabytes-of-history (.git) to the daemon as
|
||||||
|
# the repo grows.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
.claude
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# mcp-server (TypeScript)
|
||||||
|
mcp-server/node_modules/
|
||||||
|
mcp-server/dist/
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# claude-bottle
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
claude-bottle spins up an isolated container for running Claude Code with a
|
||||||
|
curated set of skills and env vars. The point is to run Claude with broad
|
||||||
|
permissions inside a sandbox, so a misbehaving agent cannot reach the host.
|
||||||
|
Bash scripts orchestrate the container lifecycle and the copying of skills
|
||||||
|
and env vars into it.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Minimize risk of running claude with full permissions
|
||||||
|
- Allow me to easily spin up agent tasks in parallel
|
||||||
|
- Create isolated, well defined, easily updated, shareable agents
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Communicating between agents directly
|
||||||
|
- Self hosted VMs (v1 uses local Docker containers, not VMs)
|
||||||
|
- Advanced agent auditing (lean on git history for auditing)
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- `README.md` — short public-facing description.
|
||||||
|
- `CLAUDE.md` — this file, orientation for future Claude sessions.
|
||||||
|
- `.gitignore` — OS junk.
|
||||||
|
- `claude-bottle.json` — manifest of named agents (env / skills / prompt
|
||||||
|
per agent), consumed by `cli.sh`. See "Manifest" under
|
||||||
|
"Intended design".
|
||||||
|
- `docs/INDEX.md` — pointer to the journal and research notes.
|
||||||
|
- `docs/JOURNAL.md` — append-only log of decisions and state changes.
|
||||||
|
- `docs/prds/` — product requirement docs.
|
||||||
|
- `docs/research/` — research notes (empty for now, kept tracked via `.gitkeep`).
|
||||||
|
- `.claude/skills/init-entry/` — project-local Claude Code skill providing `/init-entry` for adding journal entries. Snapshotted from `~/.claude/skills/init-entry/` at scaffold time; refresh deliberately if it drifts.
|
||||||
|
|
||||||
|
The container launcher scripts (`Dockerfile`, `cli.sh`,
|
||||||
|
`lib/*.sh`) landed in PRD 0001 and were
|
||||||
|
extended in PRD 0002 with `lib/manifest.sh`,
|
||||||
|
`lib/env_resolve.sh`, and `lib/skills.sh`. Note: any
|
||||||
|
future repo-root `skills/<name>/` directory (skills sent into the
|
||||||
|
container) is a distinct concept from `.claude/skills/<name>/` (Claude
|
||||||
|
Code skills used while working in this repo) — don't conflate them.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Text-driven content. `docs/JOURNAL.md` is an append-only stream of thought,
|
||||||
|
newest first. Entries are timestamps followed by freeform prose — no
|
||||||
|
templates, no required sections. Add entries with `/init-entry`.
|
||||||
|
- Product requirement docs live in `docs/prds/`.
|
||||||
|
- Research notes live in `docs/research/`.
|
||||||
|
- Low dependencies by default. The project is bash-first; ask before adding new
|
||||||
|
tools, runtimes, or package managers.
|
||||||
|
|
||||||
|
## Intended design
|
||||||
|
|
||||||
|
PRD 0002 lands the manifest-driven agent flow described below. The
|
||||||
|
`defaults/` directory and the repo-side `skills/` snapshot/diff loop
|
||||||
|
sketched at scaffold time are deferred — see "Deferred from the
|
||||||
|
scaffold sketch" at the end of this section.
|
||||||
|
|
||||||
|
### Manifest
|
||||||
|
|
||||||
|
Per-agent configuration lives in `claude-bottle.json` under an `"agents"` key.
|
||||||
|
`cli.sh` looks for this file in two locations and merges them:
|
||||||
|
|
||||||
|
1. **Current working directory** (`$PWD/claude-bottle.json`) — project-local agents.
|
||||||
|
2. **Home directory** (`$HOME/claude-bottle.json`) — personal global agents.
|
||||||
|
|
||||||
|
If both exist, the two `agents` objects are merged (home is the base, cwd
|
||||||
|
entries win on a same-agent-name conflict).
|
||||||
|
If neither file exists, `cli.sh` dies with a clear message.
|
||||||
|
|
||||||
|
Each agent has three attributes:
|
||||||
|
|
||||||
|
- `env` — hash of env vars. Each value is a JSON string whose mode
|
||||||
|
is selected by sentinel prefix:
|
||||||
|
- `"?<message>"` — value is prompted at runtime from `/dev/tty`
|
||||||
|
(silent), exported into the launcher process, and forwarded to
|
||||||
|
the container via `docker run -e NAME` (no `=value`). Never
|
||||||
|
written to disk, never on argv. The launcher always asks, even
|
||||||
|
if a same-named var is already in the parent shell. `<message>`
|
||||||
|
is rendered verbatim as the prompt body; the launcher appends
|
||||||
|
` (input hidden): `. Bare `"?"` is allowed and falls back to a
|
||||||
|
default `claude-bottle: secret value for NAME` prompt.
|
||||||
|
- `"${HOST_VAR}"` — exact `${IDENT}` form, where `IDENT` matches
|
||||||
|
`[A-Za-z_][A-Za-z0-9_]*`. Value is read from `$HOST_VAR` in the
|
||||||
|
host process env at launch time. Treated the same as a secret on
|
||||||
|
the wire: copied into this process under the target name,
|
||||||
|
forwarded as `-e NAME` (no `=value`), never written to disk.
|
||||||
|
- any other string — literal value, hardcoded in the manifest.
|
||||||
|
Written to a mode-600 env-file under `mktemp -d` and passed to
|
||||||
|
docker via `--env-file`. Newlines are rejected up front because
|
||||||
|
docker `--env-file` cannot represent them. A literal whose text
|
||||||
|
starts with `?` or matches `${IDENT}` is not representable in
|
||||||
|
v1 — pick a different value or revisit the convention.
|
||||||
|
- `skills` — list of skill names. Each is `docker cp`'d from
|
||||||
|
`~/.claude/skills/<name>/` into the running container's
|
||||||
|
`~/.claude/skills/<name>/`, preserving per-skill directory structure
|
||||||
|
(no flattening, no archives). If a referenced skill is missing on
|
||||||
|
the host, `cli.sh` fails with a clear message naming the skill
|
||||||
|
and the path checked. The host→repo fallback and host↔repo diff
|
||||||
|
prompt described in the original sketch are deferred.
|
||||||
|
- `prompt` — string prepended to the chat when the container session
|
||||||
|
boots. Delivered by writing the string to a file inside the
|
||||||
|
container via `docker cp` (so the prompt content does not land on
|
||||||
|
`docker exec` argv) and passing it to
|
||||||
|
`claude --append-system-prompt-file <path>`. Note: as of the
|
||||||
|
claude-code version pinned in the Dockerfile, this flag is real but
|
||||||
|
is not surfaced in the alphabetized `claude --help` output (only
|
||||||
|
mentioned obliquely under `--bare`); a future rename or removal will
|
||||||
|
break the launcher with a clear error from claude itself. Bare
|
||||||
|
`start` (no `<name>`) is intentionally not supported — `<name>`
|
||||||
|
remains required.
|
||||||
|
- `ssh` — optional array of SSH host entries. Each entry is an object
|
||||||
|
with five required keys:
|
||||||
|
- `Host` — the `Host` alias written to `~/.ssh/config` in the
|
||||||
|
container (also the name you use as the ssh destination).
|
||||||
|
- `IdentityFile` — absolute path to the private key file on the host
|
||||||
|
(leading `~` is expanded). At launch the key is `docker cp`'d into
|
||||||
|
`/root/.claude-bottle-keys/` (mode 700, root-owned), loaded into a
|
||||||
|
root-owned `ssh-agent` listening on `/run/claude-bottle-agent.sock`,
|
||||||
|
and the key file is then deleted. The agent socket is `chmod 666`
|
||||||
|
so the `node` user can connect; the agent protocol only exposes
|
||||||
|
signing operations, never the key bytes. Keys must be
|
||||||
|
passphrase-less (no TTY for `ssh-add` to prompt against).
|
||||||
|
- `Hostname` — the actual hostname or IP for `HostName`.
|
||||||
|
- `User` — the SSH username for `User`.
|
||||||
|
- `Port` — the SSH port number for `Port`.
|
||||||
|
- `KnownHostKey` — (optional) the host's public key, written to
|
||||||
|
`~/.ssh/known_hosts` under both the `Host` alias and the
|
||||||
|
`Hostname` (so the lookup succeeds whether the connection uses
|
||||||
|
the alias or the raw IP/host, e.g. a git remote URL with the
|
||||||
|
bare IP). Eliminates the interactive host-verification prompt on
|
||||||
|
first connect.
|
||||||
|
|
||||||
|
Per-Host blocks in `~/.ssh/config` use `IdentityAgent
|
||||||
|
/run/claude-bottle-agent-public.sock` rather than `IdentityFile`, so SSH
|
||||||
|
always reaches the agent regardless of `SSH_AUTH_SOCK`. The public
|
||||||
|
socket is served by a root-owned `socat` forwarder, not by the agent
|
||||||
|
itself: OpenSSH's `ssh-agent` enforces a `SO_PEERCRED`-based UID-match
|
||||||
|
check on every connection (only accepts peers with euid 0 or matching
|
||||||
|
the agent's own uid), so non-root callers like `node` are rejected
|
||||||
|
even when the socket is mode 666. `socat` runs as root, accepts node's
|
||||||
|
connections on the public socket, and proxies to the real agent socket
|
||||||
|
at `/run/claude-bottle-agent.sock`; from the agent's perspective the peer
|
||||||
|
is uid 0 and passes the check.
|
||||||
|
|
||||||
|
Why an in-container agent (not a bind-mounted host agent): Docker
|
||||||
|
Desktop on macOS does not forward Unix-domain socket `connect()`
|
||||||
|
across the macOS↔Linux VM boundary (returns `ENOTSUP`). Running the
|
||||||
|
agent inside the container sidesteps that while preserving the
|
||||||
|
isolation property we want (node can use the key for SSH but cannot
|
||||||
|
read the bytes — root-owned agent and forwarder, no `CAP_SYS_PTRACE`).
|
||||||
|
|
||||||
|
`cli.sh start` validates that every key file exists on the host
|
||||||
|
before the y/N prompt, then after the container is running it spawns
|
||||||
|
the in-container `ssh-agent`, loads the keys, deletes the key files,
|
||||||
|
and writes `~/.ssh/config` (mode 600) with one `Host` block per
|
||||||
|
entry.
|
||||||
|
|
||||||
|
Agent keys (the top-level keys of `claude-bottle.json`) should already be
|
||||||
|
slug-friendly (lowercase, alphanumeric + hyphens). The container name
|
||||||
|
is `claude-bottle-<slug>`, with a numeric suffix appended on conflict —
|
||||||
|
so two parallel starts of the same agent get distinct containers
|
||||||
|
(`claude-bottle-journal`, `claude-bottle-journal-2`, ...) instead of the
|
||||||
|
second failing. Two distinct agent keys that slug to the same value
|
||||||
|
(e.g. `"Review PR"` and `"review-pr"`) will both work but become hard
|
||||||
|
to tell apart in `docker ps`; pick keys that are already slugs to
|
||||||
|
avoid that ambiguity. `CLAUDE_BOTTLE_CONTAINER` still pins an exact name
|
||||||
|
and keeps the strict-conflict failure if it's already taken.
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
Before launching a container, `cli.sh` shows the resolved plan and
|
||||||
|
waits for a single `y/N`:
|
||||||
|
|
||||||
|
- agent name, image, container name
|
||||||
|
- env var names (never values; secrets are also never identified
|
||||||
|
separately, since the name itself plus the manifest is the source
|
||||||
|
of truth)
|
||||||
|
- skill names being sent
|
||||||
|
- prompt length and first line only
|
||||||
|
|
||||||
|
Pass `--dry-run` (or set `CLAUDE_BOTTLE_DRY_RUN=1`) to print the plan
|
||||||
|
and exit before any `docker run` / `docker cp` / `docker exec`.
|
||||||
|
|
||||||
|
### Deferred from the scaffold sketch
|
||||||
|
|
||||||
|
The pre-PRD-0002 sketch described a repo-root `skills/<name>/`
|
||||||
|
snapshot, a `defaults/secrets.json` + `defaults/config.env`, and a
|
||||||
|
host↔repo skill diff loop. None of that is implemented; v1 reads the
|
||||||
|
manifest, prompts secrets, forwards literals via `--env-file`, and
|
||||||
|
copies host skills directly into the container. Reopen the question
|
||||||
|
in the journal if and when the snapshot story matters.
|
||||||
|
|
||||||
|
## When you're unsure
|
||||||
|
|
||||||
|
Ask. Default to drafting in chat over editing files when the request is ambiguous.
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# claude-bottle container image.
|
||||||
|
#
|
||||||
|
# Goal: a small, cache-friendly base that ships claude-code (the
|
||||||
|
# `@anthropic-ai/claude-code` npm package, CLI name `claude`) ready to run
|
||||||
|
# interactively. The container is ephemeral; per PRD 0001 v1 the host
|
||||||
|
# filesystem is not mounted in.
|
||||||
|
#
|
||||||
|
# Layer ordering is deliberate: the npm install lives in its own layer so
|
||||||
|
# changes to the rest of the repo (or to the CMD) don't bust it.
|
||||||
|
|
||||||
|
# Current Node LTS; slim variant keeps the image small while still
|
||||||
|
# providing apt-get for any future additions.
|
||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
# Install runtime system deps. claude-code shells out to git for several
|
||||||
|
# features (status checks, commits, PR creation) — without git in the
|
||||||
|
# image, those features fail in surprising ways once the user does any
|
||||||
|
# real work. ca-certificates is already in the slim base; listed for
|
||||||
|
# clarity in case the base ever drops it. socat is the privileged
|
||||||
|
# forwarder for the in-container ssh-agent (see lib/ssh.sh): the agent
|
||||||
|
# runs as root and rejects non-root connections, so socat sits between
|
||||||
|
# node and the agent socket.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install claude-code globally. Pinned to the version verified in the v1
|
||||||
|
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
||||||
|
# rolling forward; an unpinned install would mean rebuilds silently pick
|
||||||
|
# up new behavior.
|
||||||
|
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.126 \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
# Run as a non-root user. The node image already provides a `node` user
|
||||||
|
# (uid 1000) with a home directory, which is where claude-code will write
|
||||||
|
# its session state.
|
||||||
|
USER node
|
||||||
|
WORKDIR /home/node
|
||||||
|
|
||||||
|
# Pre-create the skills directory so PRD 0002's host->container skill
|
||||||
|
# copier (scripts/lib/skills.sh) drops files into a path owned by the
|
||||||
|
# `node` user. `skills_copy_into` also `mkdir -p`s defensively, but
|
||||||
|
# baking it into the image avoids a permission-confusion footgun if a
|
||||||
|
# future change to the launcher copies in as a different user.
|
||||||
|
RUN mkdir -p /home/node/.claude/skills
|
||||||
|
|
||||||
|
# Pre-populate ~/.claude.json so claude skips the first-run onboarding
|
||||||
|
# screens on every fresh container launch: the theme picker
|
||||||
|
# (`hasCompletedOnboarding`), the "trust this folder" dialog
|
||||||
|
# (`projects.<cwd>.hasTrustDialogAccepted`), the implicit theme
|
||||||
|
# default, and the bypass-permissions-mode warning that fires the
|
||||||
|
# first time `--dangerously-skip-permissions` is used in a profile
|
||||||
|
# (`bypassPermissionsModeAccepted`). Without these, an ephemeral
|
||||||
|
# container shows all four on every start. The fifth screen —
|
||||||
|
# "Detected a custom API key in your environment" — only fires on the
|
||||||
|
# ANTHROPIC_API_KEY fallback path; under the primary OAuth-token flow
|
||||||
|
# (CLAUDE_CODE_OAUTH_TOKEN) it does not appear. When it does fire it
|
||||||
|
# is handled at launch time by scripts/lib/auth.sh, which computes
|
||||||
|
# the key suffix inside the container so the value never crosses
|
||||||
|
# host argv.
|
||||||
|
#
|
||||||
|
# Heredoc delimiter is unquoted so $HOME expands; no other `$` appears
|
||||||
|
# in the body, so this is safe under dash (Docker's default RUN shell).
|
||||||
|
RUN cat > "$HOME/.claude.json" <<JSON
|
||||||
|
{
|
||||||
|
"hasCompletedOnboarding": true,
|
||||||
|
"theme": "dark",
|
||||||
|
"bypassPermissionsModeAccepted": true,
|
||||||
|
"projects": {
|
||||||
|
"$HOME": { "hasTrustDialogAccepted": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# Default to an interactive claude session. In the v1 launcher,
|
||||||
|
# `scripts/start.sh` runs the container detached and uses `docker exec`
|
||||||
|
# to attach a TTY, but this CMD makes `docker run -it claude-bottle` also
|
||||||
|
# do something useful for ad-hoc debugging.
|
||||||
|
CMD ["claude"]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# claude-bottle
|
||||||
|
|
||||||
|
Spins up an isolated container for running Claude Code with a curated set of skills and env vars.
|
||||||
|
|
||||||
|
## Why "claude-bottle"?
|
||||||
|
|
||||||
|
Each container is a bottle; Claude is the genie inside. The genie has
|
||||||
|
broad powers within the bottle — read, write, run anything — but it
|
||||||
|
cannot escape to the host. You uncork one bottle per agent
|
||||||
|
(`./cli.sh start <agent>`), many bottles run in parallel, and each
|
||||||
|
one's powers are scoped to what the manifest grants it: a curated set
|
||||||
|
of skills, env vars, and a starting prompt. When the session ends the
|
||||||
|
bottle is destroyed and the genie does not persist.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Minimize risk of running claude with full permissions
|
||||||
|
- Allow me to easily spin up agent tasks in parallel
|
||||||
|
- Create isolated, well defined, easily updated, shareable agents
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Communicating between agents directly
|
||||||
|
- Self hosted VMs (v1 uses local Docker containers, not VMs)
|
||||||
|
- Advanced agent auditing (lean on git history for auditing)
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Requires Docker on the host and a long-lived Claude Code OAuth token in
|
||||||
|
your shell env.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cli.sh start <agent> # builds the image on first run, drops you into claude
|
||||||
|
```
|
||||||
|
|
||||||
|
The container is removed automatically when the session ends. If the script
|
||||||
|
is killed with SIGKILL the exit trap won't fire and the container may be
|
||||||
|
left running; remove it with `docker rm -f <container-name>`.
|
||||||
|
|
||||||
|
## Auth: OAuth token, not API key
|
||||||
|
|
||||||
|
claude-bottle authenticates `claude` inside the container with the same
|
||||||
|
Pro/Max subscription you already use on the host, via a long-lived OAuth
|
||||||
|
token. No `ANTHROPIC_API_KEY` is needed.
|
||||||
|
|
||||||
|
**Why a token instead of mounting `~/.claude.json`:** on macOS, Claude
|
||||||
|
Code stores OAuth credentials in the encrypted Keychain, not in
|
||||||
|
`~/.claude.json`. Mounting that file into a Linux container does not
|
||||||
|
carry the credentials with it. Linux hosts keep credentials in
|
||||||
|
`~/.claude/.credentials.json`, but to keep the launcher portable
|
||||||
|
claude-bottle uses the env-var path on every host.
|
||||||
|
|
||||||
|
**One-time setup on the host:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude setup-token # browser login, prints a ~1-year OAuth token
|
||||||
|
```
|
||||||
|
|
||||||
|
Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager)
|
||||||
|
as `CLAUDE_BOTTLE_OAUTH_TOKEN`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`cli.sh` automatically forwards it to every container as
|
||||||
|
`CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e` — no manifest wiring
|
||||||
|
required, and the value is never written to disk or placed on argv.
|
||||||
|
|
||||||
|
Inside the container, `claude` picks up `CLAUDE_CODE_OAUTH_TOKEN` and
|
||||||
|
authenticates against your subscription. Caveats: the token is bound
|
||||||
|
to your subscription tier (Pro/Max/Team/Enterprise), it does not work
|
||||||
|
with `claude --bare` (which only reads `ANTHROPIC_API_KEY`), and if it
|
||||||
|
leaks, regenerate via `claude setup-token` again. Reference:
|
||||||
|
<https://code.claude.com/docs/en/authentication>.
|
||||||
@@ -0,0 +1,966 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# cli.sh — manage claude-bottle containers.
|
||||||
|
#
|
||||||
|
# usage: cli.sh <command> [args...]
|
||||||
|
#
|
||||||
|
# Commands:
|
||||||
|
# build build (or rebuild) the claude-bottle Docker image.
|
||||||
|
# cleanup stop and remove all active claude-bottle containers.
|
||||||
|
# info print env, skills, and prompt details for a named agent.
|
||||||
|
# list list available agents or active containers.
|
||||||
|
# start boot a sandboxed container for a named agent and attach an
|
||||||
|
# interactive claude-code session. The container is torn down
|
||||||
|
# when the session ends.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Capture the user's cwd before anything else touches it.
|
||||||
|
USER_CWD="${PWD}"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
REPO_DIR="$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# shellcheck source=lib/log.sh
|
||||||
|
. "${SCRIPT_DIR}/lib/log.sh"
|
||||||
|
# shellcheck source=lib/docker.sh
|
||||||
|
. "${SCRIPT_DIR}/lib/docker.sh"
|
||||||
|
# shellcheck source=lib/env.sh
|
||||||
|
. "${SCRIPT_DIR}/lib/env.sh"
|
||||||
|
# shellcheck source=lib/manifest.sh
|
||||||
|
. "${SCRIPT_DIR}/lib/manifest.sh"
|
||||||
|
# shellcheck source=lib/env_resolve.sh
|
||||||
|
. "${SCRIPT_DIR}/lib/env_resolve.sh"
|
||||||
|
# shellcheck source=lib/skills.sh
|
||||||
|
. "${SCRIPT_DIR}/lib/skills.sh"
|
||||||
|
# shellcheck source=lib/ssh.sh
|
||||||
|
. "${SCRIPT_DIR}/lib/ssh.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf 'usage: %s <command> [args...]\n' "$(basename "$0")" >&2
|
||||||
|
printf '\n' >&2
|
||||||
|
printf 'Commands:\n' >&2
|
||||||
|
printf ' build build (or rebuild) the claude-bottle Docker image\n' >&2
|
||||||
|
printf ' cleanup stop and remove all active claude-bottle containers\n' >&2
|
||||||
|
printf ' edit open an agent in vim for editing\n' >&2
|
||||||
|
printf ' info print env, skills, and prompt details for a named agent\n' >&2
|
||||||
|
printf ' init interactively create a new agent and add it to claude-bottle.json\n' >&2
|
||||||
|
printf ' list list available agents or active containers\n' >&2
|
||||||
|
printf ' start boot a container for a named agent and attach an interactive session\n' >&2
|
||||||
|
printf '\n' >&2
|
||||||
|
printf "Run '%s <command> --help' for command-specific usage.\n" "$(basename "$0")" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_build() {
|
||||||
|
require_docker
|
||||||
|
build_image "${CLAUDE_BOTTLE_IMAGE:-claude-bottle:latest}" "$REPO_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_info() {
|
||||||
|
usage_info() {
|
||||||
|
printf 'usage: %s info <name>\n' "$(basename "$0")" >&2
|
||||||
|
printf ' <name> must be defined in claude-bottle.json at the repo root.\n' >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -lt 1 ]; then
|
||||||
|
usage_info
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage_info; exit 0 ;;
|
||||||
|
-*) usage_info; die "unknown flag: $1" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local NAME="$1"
|
||||||
|
require_jq
|
||||||
|
local MANIFEST_FILE
|
||||||
|
MANIFEST_FILE="$(mktemp -t claude-bottle-manifest.XXXXXX.json)"
|
||||||
|
trap 'rm -f "${MANIFEST_FILE:-}"' EXIT
|
||||||
|
manifest_resolve "$USER_CWD" > "$MANIFEST_FILE"
|
||||||
|
manifest_require_agent "$MANIFEST_FILE" "$NAME"
|
||||||
|
|
||||||
|
local env_names="" _en
|
||||||
|
while IFS= read -r _en; do
|
||||||
|
[ -z "$_en" ] && continue
|
||||||
|
env_names="${env_names:+${env_names}, }${_en}"
|
||||||
|
done < <(manifest_env_names "$MANIFEST_FILE" "$NAME")
|
||||||
|
|
||||||
|
local skill_names=() _sk
|
||||||
|
while IFS= read -r _sk; do
|
||||||
|
[ -z "$_sk" ] && continue
|
||||||
|
skill_names+=("$_sk")
|
||||||
|
done < <(manifest_skills "$MANIFEST_FILE" "$NAME")
|
||||||
|
|
||||||
|
local prompt_content prompt_len prompt_first_line
|
||||||
|
prompt_content="$(manifest_prompt "$MANIFEST_FILE" "$NAME")"
|
||||||
|
prompt_len="${#prompt_content}"
|
||||||
|
prompt_first_line="$(printf '%s' "$prompt_content" | awk 'NR==1{print; exit}')"
|
||||||
|
|
||||||
|
local box_name
|
||||||
|
box_name="$(manifest_agent_box "$MANIFEST_FILE" "$NAME")"
|
||||||
|
|
||||||
|
local ssh_entries=() _se
|
||||||
|
while IFS= read -r _se; do
|
||||||
|
[ -z "$_se" ] && continue
|
||||||
|
ssh_entries+=("$_se")
|
||||||
|
done < <(manifest_ssh "$MANIFEST_FILE" "$NAME")
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
info "agent : ${NAME}"
|
||||||
|
info "env (names only): ${env_names:-(none)}"
|
||||||
|
info "skills : ${skill_names[*]:-(none)}"
|
||||||
|
info "prompt : ${prompt_len} chars; first line: ${prompt_first_line:-(empty)}"
|
||||||
|
if [ -n "$box_name" ]; then
|
||||||
|
info "box : ${box_name}"
|
||||||
|
if [ "${#ssh_entries[@]}" -gt 0 ]; then
|
||||||
|
local _n _h _u _p _k _khk
|
||||||
|
for _se in "${ssh_entries[@]}"; do
|
||||||
|
_n="$(printf '%s' "$_se" | jq -r '.Host')"
|
||||||
|
_h="$(printf '%s' "$_se" | jq -r '.Hostname')"
|
||||||
|
_u="$(printf '%s' "$_se" | jq -r '.User')"
|
||||||
|
_p="$(printf '%s' "$_se" | jq -r '.Port')"
|
||||||
|
_k="$(printf '%s' "$_se" | jq -r '.IdentityFile')"
|
||||||
|
_khk="$(printf '%s' "$_se" | jq -r '.KnownHostKey // empty')"
|
||||||
|
info " ssh host : ${_n} (Hostname=${_h}, User=${_u}, Port=${_p}, IdentityFile=${_k})"
|
||||||
|
[ -n "$_khk" ] && info " KnownHostKey: ${_khk}"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
info " ssh hosts : (none)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "box : (none)"
|
||||||
|
fi
|
||||||
|
printf '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
usage_list() {
|
||||||
|
printf 'usage: %s list <available|active>\n' "$(basename "$0")" >&2
|
||||||
|
printf ' available list agent names defined in claude-bottle.json\n' >&2
|
||||||
|
printf ' active list running claude-bottle containers\n' >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -lt 1 ]; then
|
||||||
|
usage_list
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
available)
|
||||||
|
require_jq
|
||||||
|
local MANIFEST_FILE
|
||||||
|
MANIFEST_FILE="$(mktemp -t claude-bottle-manifest.XXXXXX.json)"
|
||||||
|
trap 'rm -f "${MANIFEST_FILE:-}"' EXIT
|
||||||
|
manifest_resolve "$USER_CWD" > "$MANIFEST_FILE"
|
||||||
|
jq -r '.agents | keys_unsorted[]' "$MANIFEST_FILE"
|
||||||
|
;;
|
||||||
|
active)
|
||||||
|
require_docker
|
||||||
|
local containers
|
||||||
|
containers="$(docker ps --filter 'name=^claude-bottle-' --format '{{.Names}}{{"\t"}}{{.Status}}' 2>/dev/null || true)"
|
||||||
|
if [ -z "$containers" ]; then
|
||||||
|
info "no active claude-bottle containers"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '\n'
|
||||||
|
local name status
|
||||||
|
while IFS=$'\t' read -r name status; do
|
||||||
|
info "container: ${name} status: ${status}"
|
||||||
|
done <<< "$containers"
|
||||||
|
printf '\n'
|
||||||
|
;;
|
||||||
|
-h|--help) usage_list; exit 0 ;;
|
||||||
|
*) usage_list; die "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_cleanup() {
|
||||||
|
require_docker
|
||||||
|
local containers
|
||||||
|
containers="$(docker ps --filter 'name=^claude-bottle-' --format '{{.Names}}' 2>/dev/null || true)"
|
||||||
|
if [ -z "$containers" ]; then
|
||||||
|
info "no active claude-bottle containers"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '\n' >&2
|
||||||
|
local name
|
||||||
|
while IFS= read -r name; do
|
||||||
|
info "found: ${name}"
|
||||||
|
done <<< "$containers"
|
||||||
|
printf '\n' >&2
|
||||||
|
printf 'claude-bottle: remove all of the above? [y/N] ' >&2
|
||||||
|
local REPLY
|
||||||
|
IFS= read -r REPLY </dev/tty
|
||||||
|
case "$REPLY" in
|
||||||
|
y|Y|yes|YES) ;;
|
||||||
|
*) info "aborted"; return 0 ;;
|
||||||
|
esac
|
||||||
|
while IFS= read -r name; do
|
||||||
|
info "removing ${name}"
|
||||||
|
docker rm -f "$name" >/dev/null
|
||||||
|
done <<< "$containers"
|
||||||
|
info "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# cmd_start — bring up an ephemeral claude-bottle container configured for a
|
||||||
|
# named agent from the repo-root claude-bottle.json manifest, and drop the
|
||||||
|
# user into an interactive claude-code session inside it.
|
||||||
|
#
|
||||||
|
# Lifecycle (per PRD 0001 "ephemeral" requirement): the container is
|
||||||
|
# removed automatically when the interactive session ends. We use
|
||||||
|
# `docker run --rm -d` plus a trap that forces removal on exit, so
|
||||||
|
# signals like Ctrl-C also clean up.
|
||||||
|
#
|
||||||
|
# ASSUMPTION: the container is started detached (`-d`) running `sleep
|
||||||
|
# infinity` so that skills and config can be copied in via `docker cp`
|
||||||
|
# before `docker exec` attaches the claude session. The container therefore
|
||||||
|
# stays alive in the background between launch and attach — the EXIT/INT/TERM
|
||||||
|
# trap is what guarantees teardown on normal exit. SIGKILL bypasses the
|
||||||
|
# trap; if this process is killed that way the container will be left
|
||||||
|
# running and must be removed manually with `docker rm -f <name>`.
|
||||||
|
#
|
||||||
|
# Per-agent configuration (PRD 0002):
|
||||||
|
# - env vars in three modes (secret-prompted, literal, interpolated
|
||||||
|
# from the host process env). Resolved by lib/env_resolve.sh.
|
||||||
|
# * secret → prompted from /dev/tty, exported, forwarded via
|
||||||
|
# `docker run -e NAME` (no `=value`).
|
||||||
|
# * interpolated→ copied from a host var into this process under
|
||||||
|
# the target name, forwarded the same way as a
|
||||||
|
# secret (off argv, off disk).
|
||||||
|
# * literal → written to a mode-600 env-file under mktemp -d
|
||||||
|
# and forwarded with `--env-file <path>`.
|
||||||
|
# - skills: host directories under ~/.claude/skills/<name>/ are
|
||||||
|
# `docker cp`'d into the running container's
|
||||||
|
# ~/.claude/skills/<name>/ by lib/skills.sh.
|
||||||
|
# - prompt: written to a host-side mode-600 file, then `docker cp`'d
|
||||||
|
# into the container (so the prompt content never lands on
|
||||||
|
# `docker exec` argv) and passed to
|
||||||
|
# `claude --append-system-prompt-file <path>`.
|
||||||
|
#
|
||||||
|
# Confirmation: the resolved plan (skill names, env var names — never
|
||||||
|
# values, prompt length and first line) is shown before launch and
|
||||||
|
# gated on a single y/N.
|
||||||
|
#
|
||||||
|
# Dry-run: pass --dry-run (or set CLAUDE_BOTTLE_DRY_RUN=1) to print the
|
||||||
|
# resolved plan and exit BEFORE docker run / cp / exec. Used for
|
||||||
|
# verifying the manifest wiring without booting Claude.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cmd_start() {
|
||||||
|
usage_start() {
|
||||||
|
printf 'usage: %s start [--dry-run] [--cwd] <name>\n' "$(basename "$0")" >&2
|
||||||
|
printf ' <name> must be defined in claude-bottle.json at the repo root.\n' >&2
|
||||||
|
printf ' --cwd copy the current working directory into a derived image at\n' >&2
|
||||||
|
printf ' /home/node/workspace and start claude there.\n' >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
local DRY_RUN="${CLAUDE_BOTTLE_DRY_RUN:-0}"
|
||||||
|
local COPY_CWD=0
|
||||||
|
local NAME=""
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
--cwd) COPY_CWD=1; shift ;;
|
||||||
|
-h|--help) usage_start; exit 0 ;;
|
||||||
|
--) shift; break ;;
|
||||||
|
-*) usage_start; die "unknown flag: $1" ;;
|
||||||
|
*)
|
||||||
|
if [ -z "$NAME" ]; then
|
||||||
|
NAME="$1"
|
||||||
|
else
|
||||||
|
usage_start; die "unexpected extra argument: $1"
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "${NAME:-}" ]; then
|
||||||
|
usage_start
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
local SLUG
|
||||||
|
SLUG="$(slugify "$NAME")"
|
||||||
|
|
||||||
|
local IMAGE="${CLAUDE_BOTTLE_IMAGE:-claude-bottle:latest}"
|
||||||
|
# Default container name is claude-bottle-<slug>. If the user pinned a
|
||||||
|
# specific name via CLAUDE_BOTTLE_CONTAINER we honor it as-is below.
|
||||||
|
# Otherwise we auto-suffix on conflict so concurrent starts of the
|
||||||
|
# same agent get distinct containers (claude-bottle-journal,
|
||||||
|
# claude-bottle-journal-2, ...). Final resolution happens just below,
|
||||||
|
# after require_docker, since container_exists needs docker reachable.
|
||||||
|
local DEFAULT_CONTAINER="claude-bottle-${SLUG}"
|
||||||
|
local PINNED_CONTAINER="${CLAUDE_BOTTLE_CONTAINER:-}"
|
||||||
|
|
||||||
|
# When --cwd is on, runtime image is a thin derived image FROM $IMAGE
|
||||||
|
# with the user's cwd COPY'd in. Tag it per-agent so the layer cache
|
||||||
|
# stays effective across repeated launches of the same agent.
|
||||||
|
local RUNTIME_IMAGE="$IMAGE"
|
||||||
|
local DERIVED_IMAGE=""
|
||||||
|
if [ "$COPY_CWD" = "1" ]; then
|
||||||
|
DERIVED_IMAGE="${CLAUDE_BOTTLE_DERIVED_IMAGE:-claude-bottle:cwd-${SLUG}}"
|
||||||
|
RUNTIME_IMAGE="$DERIVED_IMAGE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_docker
|
||||||
|
require_jq
|
||||||
|
|
||||||
|
# Resolve the manifest (merge USER_CWD and HOME configs) into a temp file
|
||||||
|
# early so it is available for all subsequent manifest calls.
|
||||||
|
# Not declared local: the EXIT trap fires after cmd_start returns, so local
|
||||||
|
# variables would already be out of scope when cleanup_all runs.
|
||||||
|
MANIFEST_FILE="$(mktemp -t claude-bottle-manifest.XXXXXX.json)"
|
||||||
|
trap 'rm -f "${MANIFEST_FILE:-}"' EXIT
|
||||||
|
manifest_resolve "$USER_CWD" > "$MANIFEST_FILE"
|
||||||
|
|
||||||
|
manifest_require_agent "$MANIFEST_FILE" "$NAME"
|
||||||
|
|
||||||
|
# Not declared local: needed by cleanup_all after cmd_start returns (see MANIFEST_FILE note above).
|
||||||
|
CONTAINER=""
|
||||||
|
local _suffix=2
|
||||||
|
if [ -n "$PINNED_CONTAINER" ]; then
|
||||||
|
CONTAINER="$PINNED_CONTAINER"
|
||||||
|
if container_exists "$CONTAINER"; then
|
||||||
|
die "container '${CONTAINER}' already exists (pinned via CLAUDE_BOTTLE_CONTAINER). Remove it with 'docker rm -f ${CONTAINER}' or unset the override."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
CONTAINER="$DEFAULT_CONTAINER"
|
||||||
|
while container_exists "$CONTAINER"; do
|
||||||
|
CONTAINER="${DEFAULT_CONTAINER}-${_suffix}"
|
||||||
|
_suffix=$((_suffix + 1))
|
||||||
|
if [ "$_suffix" -gt 100 ]; then
|
||||||
|
die "could not find a free container name after ${DEFAULT_CONTAINER}-99; clean up old containers with 'docker rm -f <name>'"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Plan resolution (host-only, no container yet) ---
|
||||||
|
|
||||||
|
# Collect the env names (for display) and the skill names (for both
|
||||||
|
# display and validation).
|
||||||
|
local ENV_NAMES_LIST=""
|
||||||
|
local _en
|
||||||
|
while IFS= read -r _en; do
|
||||||
|
[ -z "$_en" ] && continue
|
||||||
|
if [ -z "$ENV_NAMES_LIST" ]; then
|
||||||
|
ENV_NAMES_LIST="$_en"
|
||||||
|
else
|
||||||
|
ENV_NAMES_LIST="${ENV_NAMES_LIST}, ${_en}"
|
||||||
|
fi
|
||||||
|
done < <(manifest_env_names "$MANIFEST_FILE" "$NAME")
|
||||||
|
|
||||||
|
# CLAUDE_BOTTLE_OAUTH_TOKEN → CLAUDE_CODE_OAUTH_TOKEN forwarding.
|
||||||
|
# When the host has the token set, it is always forwarded regardless of the
|
||||||
|
# manifest so that every container can authenticate without wiring the token
|
||||||
|
# into each agent definition.
|
||||||
|
local FORWARD_OAUTH_TOKEN=0
|
||||||
|
if [ -n "${CLAUDE_BOTTLE_OAUTH_TOKEN:-}" ]; then
|
||||||
|
FORWARD_OAUTH_TOKEN=1
|
||||||
|
if [ -z "$ENV_NAMES_LIST" ]; then
|
||||||
|
ENV_NAMES_LIST="CLAUDE_CODE_OAUTH_TOKEN"
|
||||||
|
else
|
||||||
|
ENV_NAMES_LIST="${ENV_NAMES_LIST}, CLAUDE_CODE_OAUTH_TOKEN"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skills as an array.
|
||||||
|
local SKILL_NAMES=()
|
||||||
|
local _sk
|
||||||
|
while IFS= read -r _sk; do
|
||||||
|
[ -z "$_sk" ] && continue
|
||||||
|
SKILL_NAMES+=("$_sk")
|
||||||
|
done < <(manifest_skills "$MANIFEST_FILE" "$NAME")
|
||||||
|
|
||||||
|
# Validate every requested skill exists on the host BEFORE the y/N.
|
||||||
|
if [ "${#SKILL_NAMES[@]}" -gt 0 ]; then
|
||||||
|
skills_validate_all "${SKILL_NAMES[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve the box referenced by this agent and validate it exists.
|
||||||
|
# A box is required — agents without one are rejected before launch.
|
||||||
|
local BOX_NAME
|
||||||
|
BOX_NAME="$(manifest_agent_box "$MANIFEST_FILE" "$NAME")"
|
||||||
|
if [ -z "$BOX_NAME" ]; then
|
||||||
|
die "agent '${NAME}' has no 'box' field. Add a box association to this agent in claude-bottle.json."
|
||||||
|
fi
|
||||||
|
manifest_require_box "$MANIFEST_FILE" "$BOX_NAME"
|
||||||
|
|
||||||
|
# SSH entries come from the agent's box (empty if no box set).
|
||||||
|
local SSH_ENTRIES=()
|
||||||
|
local _se
|
||||||
|
while IFS= read -r _se; do
|
||||||
|
[ -z "$_se" ] && continue
|
||||||
|
SSH_ENTRIES+=("$_se")
|
||||||
|
done < <(manifest_ssh "$MANIFEST_FILE" "$NAME")
|
||||||
|
|
||||||
|
# Validate key files exist on the host BEFORE the y/N.
|
||||||
|
if [ "${#SSH_ENTRIES[@]}" -gt 0 ]; then
|
||||||
|
ssh_validate_entries "${SSH_ENTRIES[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stage env-file + args-file under a mktemp dir; clean up on exit.
|
||||||
|
# Not declared local: needed by cleanup_stage after cmd_start returns (see MANIFEST_FILE note above).
|
||||||
|
STAGE_DIR="$(mktemp -d -t claude-bottle-stage.XXXXXX)"
|
||||||
|
local ENV_FILE="${STAGE_DIR}/agent.env"
|
||||||
|
local ARGS_FILE="${STAGE_DIR}/docker-args"
|
||||||
|
local PROMPT_FILE="${STAGE_DIR}/prompt.txt"
|
||||||
|
: > "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
: > "$ARGS_FILE"
|
||||||
|
: > "$PROMPT_FILE"
|
||||||
|
chmod 600 "$PROMPT_FILE"
|
||||||
|
|
||||||
|
cleanup_stage() {
|
||||||
|
if [ -n "${STAGE_DIR:-}" ] && [ -d "$STAGE_DIR" ]; then
|
||||||
|
rm -rf "$STAGE_DIR"
|
||||||
|
fi
|
||||||
|
rm -f "${MANIFEST_FILE:-}"
|
||||||
|
}
|
||||||
|
trap cleanup_stage EXIT
|
||||||
|
|
||||||
|
# Resolve env entries: prompts secrets (silent /dev/tty), copies
|
||||||
|
# interpolated host vars into this process, writes literal pairs to
|
||||||
|
# ENV_FILE.
|
||||||
|
env_resolve "$MANIFEST_FILE" "$NAME" "$ENV_FILE" "$ARGS_FILE"
|
||||||
|
|
||||||
|
# Read the prompt and write it to PROMPT_FILE. Inside the container the
|
||||||
|
# prompt will be passed via `--append-system-prompt-file <path>`, so
|
||||||
|
# the content does NOT land on `docker exec` argv even if it grows
|
||||||
|
# arbitrarily large.
|
||||||
|
local PROMPT_CONTENT
|
||||||
|
PROMPT_CONTENT="$(manifest_prompt "$MANIFEST_FILE" "$NAME")"
|
||||||
|
printf '%s' "$PROMPT_CONTENT" > "$PROMPT_FILE"
|
||||||
|
|
||||||
|
local PROMPT_LEN="${#PROMPT_CONTENT}"
|
||||||
|
local PROMPT_FIRST_LINE
|
||||||
|
PROMPT_FIRST_LINE="$(printf '%s' "$PROMPT_CONTENT" | awk 'NR==1{print; exit}')"
|
||||||
|
|
||||||
|
# --- Show plan + confirm ---
|
||||||
|
|
||||||
|
printf '\n' >&2
|
||||||
|
info "agent : ${NAME}"
|
||||||
|
info "image : ${IMAGE}"
|
||||||
|
if [ -n "$DERIVED_IMAGE" ]; then
|
||||||
|
info "cwd : ${USER_CWD} -> /home/node/workspace (derived: ${DERIVED_IMAGE})"
|
||||||
|
fi
|
||||||
|
info "container : ${CONTAINER}"
|
||||||
|
info "stage dir : ${STAGE_DIR}"
|
||||||
|
if [ -n "$ENV_NAMES_LIST" ]; then
|
||||||
|
info "env (names only): ${ENV_NAMES_LIST}"
|
||||||
|
else
|
||||||
|
info "env (names only): (none)"
|
||||||
|
fi
|
||||||
|
if [ "${#SKILL_NAMES[@]}" -gt 0 ]; then
|
||||||
|
info "skills : ${SKILL_NAMES[*]}"
|
||||||
|
else
|
||||||
|
info "skills : (none)"
|
||||||
|
fi
|
||||||
|
if [ -n "$BOX_NAME" ]; then
|
||||||
|
info "box : ${BOX_NAME}"
|
||||||
|
if [ "${#SSH_ENTRIES[@]}" -gt 0 ]; then
|
||||||
|
local _ssh_names="" _se
|
||||||
|
for _se in "${SSH_ENTRIES[@]}"; do
|
||||||
|
local _n
|
||||||
|
_n="$(printf '%s' "$_se" | jq -r '.Host')"
|
||||||
|
_ssh_names="${_ssh_names:+${_ssh_names}, }${_n}"
|
||||||
|
done
|
||||||
|
info " ssh hosts : ${_ssh_names}"
|
||||||
|
else
|
||||||
|
info " ssh hosts : (none)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "box : (none)"
|
||||||
|
fi
|
||||||
|
info "prompt : ${PROMPT_LEN} chars; first line: ${PROMPT_FIRST_LINE:-(empty)}"
|
||||||
|
printf '\n' >&2
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
|
info "dry-run requested; not starting container."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'claude-bottle: launch this agent? [y/N] ' >&2
|
||||||
|
local REPLY
|
||||||
|
IFS= read -r REPLY </dev/tty
|
||||||
|
case "$REPLY" in
|
||||||
|
y|Y|yes|YES) ;;
|
||||||
|
*) info "aborted by user"; exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Build & launch ---
|
||||||
|
|
||||||
|
build_image "$IMAGE" "$REPO_DIR"
|
||||||
|
if [ -n "$DERIVED_IMAGE" ]; then
|
||||||
|
build_image_with_cwd "$DERIVED_IMAGE" "$IMAGE" "$USER_CWD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup container on exit too. Compose with stage cleanup.
|
||||||
|
cleanup_all() {
|
||||||
|
if container_exists "$CONTAINER"; then
|
||||||
|
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
cleanup_stage
|
||||||
|
}
|
||||||
|
# Replaces the cleanup_stage EXIT trap above; cleanup_all calls cleanup_stage internally.
|
||||||
|
trap cleanup_all EXIT INT TERM
|
||||||
|
|
||||||
|
# Assemble docker run argv:
|
||||||
|
# - --rm -d --name CONTAINER
|
||||||
|
# - --env-file ENV_FILE (only if it has any entries)
|
||||||
|
# - one `-e NAME` pair per line in ARGS_FILE (secret + interpolated)
|
||||||
|
# - IMAGE
|
||||||
|
# - sleep infinity (so we can `docker exec` an interactive session)
|
||||||
|
local DOCKER_ARGS=(--rm -d --name "$CONTAINER")
|
||||||
|
if [ -s "$ENV_FILE" ]; then
|
||||||
|
DOCKER_ARGS+=(--env-file "$ENV_FILE")
|
||||||
|
fi
|
||||||
|
# Read pairs of (-e, NAME) lines from ARGS_FILE.
|
||||||
|
local flag vname
|
||||||
|
while IFS= read -r flag; do
|
||||||
|
[ -z "$flag" ] && continue
|
||||||
|
IFS= read -r vname || break
|
||||||
|
DOCKER_ARGS+=("$flag" "$vname")
|
||||||
|
done <"$ARGS_FILE"
|
||||||
|
if [ "$FORWARD_OAUTH_TOKEN" = "1" ]; then
|
||||||
|
export CLAUDE_CODE_OAUTH_TOKEN="$CLAUDE_BOTTLE_OAUTH_TOKEN"
|
||||||
|
DOCKER_ARGS+=(-e CLAUDE_CODE_OAUTH_TOKEN)
|
||||||
|
fi
|
||||||
|
DOCKER_ARGS+=("$RUNTIME_IMAGE" sleep infinity)
|
||||||
|
|
||||||
|
info "starting container ${CONTAINER} from ${RUNTIME_IMAGE}"
|
||||||
|
# The pre-check loop above is best-effort: two parallel starts can both
|
||||||
|
# observe the same bare name as free, so we also retry here when docker
|
||||||
|
# rejects the run with a name conflict. Pinned names skip the retry —
|
||||||
|
# user-chosen, user-owned.
|
||||||
|
local RUN_ERR_FILE="${STAGE_DIR}/docker-run.err"
|
||||||
|
local RUN_ERR_TEXT
|
||||||
|
while :; do
|
||||||
|
: > "$RUN_ERR_FILE"
|
||||||
|
if docker run "${DOCKER_ARGS[@]}" >/dev/null 2>"$RUN_ERR_FILE"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
RUN_ERR_TEXT="$(cat "$RUN_ERR_FILE")"
|
||||||
|
if [ -n "$PINNED_CONTAINER" ] || ! printf '%s' "$RUN_ERR_TEXT" | grep -q "is already in use"; then
|
||||||
|
printf '%s\n' "$RUN_ERR_TEXT" >&2
|
||||||
|
die "docker run failed for container '${CONTAINER}'"
|
||||||
|
fi
|
||||||
|
if [ "$_suffix" -gt 100 ]; then
|
||||||
|
die "could not find a free container name after ${DEFAULT_CONTAINER}-99 retries; clean up old containers with 'docker rm -f <name>'"
|
||||||
|
fi
|
||||||
|
CONTAINER="${DEFAULT_CONTAINER}-${_suffix}"
|
||||||
|
_suffix=$((_suffix + 1))
|
||||||
|
DOCKER_ARGS[3]="$CONTAINER"
|
||||||
|
info "name conflict; retrying as ${CONTAINER}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Copy prompt file into the container WITHOUT putting its contents on
|
||||||
|
# argv. `docker cp` reads the file from disk and streams it in.
|
||||||
|
local CONTAINER_PROMPT_PATH="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}/.claude-bottle-prompt.txt"
|
||||||
|
docker cp "$PROMPT_FILE" "${CONTAINER}:${CONTAINER_PROMPT_PATH}" >/dev/null
|
||||||
|
# `docker cp` preserves the host file's numeric UID, which on hosts where
|
||||||
|
# the user is not uid 1000 (e.g. macOS uid 501) leaves the in-container
|
||||||
|
# file unreadable by the `node` user. Re-own and re-mode as root inside
|
||||||
|
# the container so `node` can read its own mode-600 prompt regardless of
|
||||||
|
# host UID.
|
||||||
|
docker exec -u 0 "$CONTAINER" chown node:node "$CONTAINER_PROMPT_PATH" >/dev/null
|
||||||
|
docker exec -u 0 "$CONTAINER" chmod 600 "$CONTAINER_PROMPT_PATH" >/dev/null
|
||||||
|
|
||||||
|
# Copy each requested skill.
|
||||||
|
if [ "${#SKILL_NAMES[@]}" -gt 0 ]; then
|
||||||
|
skills_copy_into "$CONTAINER" "${SKILL_NAMES[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set up SSH keys and config.
|
||||||
|
if [ "${#SSH_ENTRIES[@]}" -gt 0 ]; then
|
||||||
|
ssh_setup "$CONTAINER" "$STAGE_DIR" "${SSH_ENTRIES[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# When --cwd is on, ship the host repo's .git directory in via `docker cp`
|
||||||
|
# rather than the build-time COPY. Two reasons: (1) build-time COPY honors
|
||||||
|
# the host project's .dockerignore, which often excludes .git/ (e.g. the
|
||||||
|
# openemr fork) — without .git the agent inside has no branch, no remotes,
|
||||||
|
# and can't commit; (2) keeping .git out of the cached image layer avoids
|
||||||
|
# bloating the layer (a real repo's .git can be several GB) and avoids
|
||||||
|
# baking a stale snapshot of refs/index into the image. The cp at run
|
||||||
|
# time means the agent always sees the host's current refs.
|
||||||
|
if [ "$COPY_CWD" = "1" ] && [ -d "$USER_CWD/.git" ]; then
|
||||||
|
info "copying ${USER_CWD}/.git -> ${CONTAINER}:/home/node/workspace/.git"
|
||||||
|
docker cp "$USER_CWD/.git" "${CONTAINER}:/home/node/workspace/.git" >/dev/null
|
||||||
|
docker exec -u 0 "$CONTAINER" chown -R node:node /home/node/workspace/.git >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "attaching interactive claude session (Ctrl-D or 'exit' to leave; container will be removed)"
|
||||||
|
# --remote-control: enable Remote Control (hidden flag; see --remote-control-session-name-prefix
|
||||||
|
# in `claude --help` — the prefix flag is the only surfaced piece, the toggle itself is hidden,
|
||||||
|
# same pattern as --append-system-prompt-file).
|
||||||
|
# --dangerously-skip-permissions: bypass permission prompts. Safe here because the whole point of
|
||||||
|
# claude-bottle is sandboxing claude inside a container (see CLAUDE.md "What this is").
|
||||||
|
local CLAUDE_ARGS=(--remote-control --dangerously-skip-permissions)
|
||||||
|
# `|| true` so a non-zero exit from the REPL doesn't skip the trap output.
|
||||||
|
if [ -n "$PROMPT_CONTENT" ]; then
|
||||||
|
docker exec -it "$CONTAINER" claude "${CLAUDE_ARGS[@]}" --append-system-prompt-file "$CONTAINER_PROMPT_PATH" || true
|
||||||
|
else
|
||||||
|
docker exec -it "$CONTAINER" claude "${CLAUDE_ARGS[@]}" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "session ended; container ${CONTAINER} will be removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# cmd_init — interactively populate a new agent and write it to either
|
||||||
|
# ~/claude-bottle.json (user) or ./claude-bottle.json (project).
|
||||||
|
#
|
||||||
|
# Prompts for:
|
||||||
|
# - agent name (required)
|
||||||
|
# - env vars: name + mode (secret / interpolated / literal)
|
||||||
|
# - skills (space-separated)
|
||||||
|
# - system prompt (multi-line, terminated by a lone ".")
|
||||||
|
# - SSH host entries (optional)
|
||||||
|
#
|
||||||
|
# Merges the new agent into the target file if it already exists
|
||||||
|
# (existing agents are preserved; name conflicts prompt for confirmation).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cmd_init() {
|
||||||
|
usage_init() {
|
||||||
|
printf 'usage: %s init <user|project>\n' "$(basename "$0")" >&2
|
||||||
|
printf ' user add the agent to ~/claude-bottle.json\n' >&2
|
||||||
|
printf ' project add the agent to ./claude-bottle.json in the current directory\n' >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -lt 1 ]; then
|
||||||
|
usage_init
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
local SCOPE TARGET_FILE
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage_init; exit 0 ;;
|
||||||
|
user) SCOPE="user"; TARGET_FILE="${HOME}/claude-bottle.json" ;;
|
||||||
|
project) SCOPE="project"; TARGET_FILE="${USER_CWD}/claude-bottle.json" ;;
|
||||||
|
*) usage_init; die "expected 'user' or 'project', got: $1" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
require_jq
|
||||||
|
|
||||||
|
printf '\n' >&2
|
||||||
|
info "claude-bottle init — adding a new agent to ${TARGET_FILE}"
|
||||||
|
printf '\n' >&2
|
||||||
|
|
||||||
|
# --- Agent name ---
|
||||||
|
local AGENT_NAME=""
|
||||||
|
while [ -z "$AGENT_NAME" ]; do
|
||||||
|
printf 'Agent name: ' >&2
|
||||||
|
IFS= read -r AGENT_NAME </dev/tty
|
||||||
|
# Normalise spaces to hyphens so the name is slug-friendly.
|
||||||
|
AGENT_NAME="$(printf '%s' "$AGENT_NAME" | tr ' ' '-')"
|
||||||
|
if [ -z "$AGENT_NAME" ]; then
|
||||||
|
warn "agent name cannot be empty"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Warn (but do not block) if the name is not already a clean slug.
|
||||||
|
if ! printf '%s' "$AGENT_NAME" | grep -qE '^[a-z0-9][a-z0-9-]*$'; then
|
||||||
|
warn "agent name '${AGENT_NAME}' contains non-slug characters; it will still work but may cause container naming issues"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for an existing agent with the same name.
|
||||||
|
if [ -f "$TARGET_FILE" ] && jq -e --arg n "$AGENT_NAME" '.agents | has($n)' "$TARGET_FILE" >/dev/null 2>&1; then
|
||||||
|
printf 'claude-bottle: agent "%s" already exists in %s. Overwrite? [y/N] ' "$AGENT_NAME" "$TARGET_FILE" >&2
|
||||||
|
local _ow
|
||||||
|
IFS= read -r _ow </dev/tty
|
||||||
|
case "$_ow" in
|
||||||
|
y|Y|yes|YES) ;;
|
||||||
|
*) info "aborted"; return 0 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Skills ---
|
||||||
|
printf '\n' >&2
|
||||||
|
printf 'Skills (space or comma separated, or Enter for none): ' >&2
|
||||||
|
local _skills_input=""
|
||||||
|
IFS= read -r _skills_input </dev/tty
|
||||||
|
|
||||||
|
local SKILLS_JSON='[]'
|
||||||
|
if [ -n "$_skills_input" ]; then
|
||||||
|
local _skill_arr=()
|
||||||
|
local _cleaned
|
||||||
|
_cleaned="$(printf '%s' "$_skills_input" | tr ',' ' ')"
|
||||||
|
read -ra _skill_arr <<< "$_cleaned"
|
||||||
|
if [ "${#_skill_arr[@]}" -gt 0 ]; then
|
||||||
|
SKILLS_JSON="$(printf '%s\n' "${_skill_arr[@]}" | jq -R . | jq -s .)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- System prompt ---
|
||||||
|
printf '\n' >&2
|
||||||
|
info "System prompt — enter text, then a lone '.' on its own line to finish (just '.' to leave empty):"
|
||||||
|
local PROMPT_CONTENT="" _pline _pfirst=1
|
||||||
|
while :; do
|
||||||
|
IFS= read -r _pline </dev/tty
|
||||||
|
[ "$_pline" = "." ] && break
|
||||||
|
if [ "$_pfirst" = "1" ]; then
|
||||||
|
PROMPT_CONTENT="$_pline"
|
||||||
|
_pfirst=0
|
||||||
|
else
|
||||||
|
PROMPT_CONTENT="${PROMPT_CONTENT}"$'\n'"${_pline}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Box association ---
|
||||||
|
printf '\n' >&2
|
||||||
|
printf 'Associate this agent with a box? [y/N] ' >&2
|
||||||
|
local _box_yn=""
|
||||||
|
IFS= read -r _box_yn </dev/tty
|
||||||
|
local BOX_NAME=""
|
||||||
|
local BOX_SSH_JSON='[]'
|
||||||
|
local BOX_ENV_JSON='{}'
|
||||||
|
local _box_exists=0
|
||||||
|
case "$_box_yn" in
|
||||||
|
y|Y|yes|YES)
|
||||||
|
while [ -z "$BOX_NAME" ]; do
|
||||||
|
printf ' Box name: ' >&2
|
||||||
|
IFS= read -r BOX_NAME </dev/tty
|
||||||
|
BOX_NAME="$(printf '%s' "$BOX_NAME" | tr ' ' '-')"
|
||||||
|
if [ -z "$BOX_NAME" ]; then
|
||||||
|
warn "box name cannot be empty"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check whether the box already exists in the target file.
|
||||||
|
if [ -f "$TARGET_FILE" ] && jq -e --arg b "$BOX_NAME" '.boxes | has($b)' "$TARGET_FILE" >/dev/null 2>&1; then
|
||||||
|
_box_exists=1
|
||||||
|
info "Box '${BOX_NAME}' already exists in ${TARGET_FILE}; agent will reference it."
|
||||||
|
else
|
||||||
|
info "Creating new box '${BOX_NAME}'."
|
||||||
|
|
||||||
|
# --- Env vars (stored on the box) ---
|
||||||
|
printf '\n' >&2
|
||||||
|
info "Env vars — enter each var name then its mode. Press Enter with no name to finish."
|
||||||
|
info " Modes: secret (prompt at runtime) | interpolated (read from host env) | literal (hardcoded value)"
|
||||||
|
while :; do
|
||||||
|
printf '\n Var name (or Enter to finish): ' >&2
|
||||||
|
local _vname=""
|
||||||
|
IFS= read -r _vname </dev/tty
|
||||||
|
[ -z "$_vname" ] && break
|
||||||
|
|
||||||
|
printf ' Mode [secret/interpolated/literal] (default: secret): ' >&2
|
||||||
|
local _vmode=""
|
||||||
|
IFS= read -r _vmode </dev/tty
|
||||||
|
_vmode="${_vmode:-secret}"
|
||||||
|
|
||||||
|
local _vval=""
|
||||||
|
case "$_vmode" in
|
||||||
|
secret)
|
||||||
|
printf ' Prompt message shown to user (default: "enter %s"): ' "$_vname" >&2
|
||||||
|
local _smsg=""
|
||||||
|
IFS= read -r _smsg </dev/tty
|
||||||
|
if [ -n "$_smsg" ]; then
|
||||||
|
_vval="?${_smsg}"
|
||||||
|
else
|
||||||
|
_vval="?"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
interpolated)
|
||||||
|
printf ' Host env var to read from (default: %s): ' "$_vname" >&2
|
||||||
|
local _hvar=""
|
||||||
|
IFS= read -r _hvar </dev/tty
|
||||||
|
_hvar="${_hvar:-${_vname}}"
|
||||||
|
_vval="\${${_hvar}}"
|
||||||
|
;;
|
||||||
|
literal)
|
||||||
|
printf ' Value: ' >&2
|
||||||
|
IFS= read -r _vval </dev/tty
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warn "unknown mode '${_vmode}'; using secret"
|
||||||
|
_vval="?"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
BOX_ENV_JSON="$(printf '%s' "$BOX_ENV_JSON" | jq --arg k "$_vname" --arg v "$_vval" '.[$k] = $v')"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf ' Add SSH host entries to this box? [y/N] ' >&2
|
||||||
|
local _ssh_yn=""
|
||||||
|
IFS= read -r _ssh_yn </dev/tty
|
||||||
|
case "$_ssh_yn" in
|
||||||
|
y|Y|yes|YES)
|
||||||
|
while :; do
|
||||||
|
printf '\n SSH Host alias (or Enter to finish): ' >&2
|
||||||
|
local _shost=""
|
||||||
|
IFS= read -r _shost </dev/tty
|
||||||
|
[ -z "$_shost" ] && break
|
||||||
|
|
||||||
|
printf ' Hostname (actual hostname or IP): ' >&2
|
||||||
|
local _shostname=""
|
||||||
|
IFS= read -r _shostname </dev/tty
|
||||||
|
|
||||||
|
printf ' User: ' >&2
|
||||||
|
local _suser=""
|
||||||
|
IFS= read -r _suser </dev/tty
|
||||||
|
|
||||||
|
printf ' Port (default: 22): ' >&2
|
||||||
|
local _sport=""
|
||||||
|
IFS= read -r _sport </dev/tty
|
||||||
|
_sport="${_sport:-22}"
|
||||||
|
if ! printf '%s' "$_sport" | grep -qE '^[0-9]+$'; then
|
||||||
|
warn "port must be a number; defaulting to 22"
|
||||||
|
_sport="22"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf ' IdentityFile (path to private key on host): ' >&2
|
||||||
|
local _sidentity=""
|
||||||
|
IFS= read -r _sidentity </dev/tty
|
||||||
|
|
||||||
|
printf ' KnownHostKey (optional, Enter to skip): ' >&2
|
||||||
|
local _skhk=""
|
||||||
|
IFS= read -r _skhk </dev/tty
|
||||||
|
|
||||||
|
local _entry
|
||||||
|
_entry="$(jq -n \
|
||||||
|
--arg host "$_shost" \
|
||||||
|
--arg hostname "$_shostname" \
|
||||||
|
--arg user "$_suser" \
|
||||||
|
--argjson port "$_sport" \
|
||||||
|
--arg identity "$_sidentity" \
|
||||||
|
'{Host: $host, Hostname: $hostname, User: $user, Port: $port, IdentityFile: $identity}')"
|
||||||
|
if [ -n "$_skhk" ]; then
|
||||||
|
_entry="$(printf '%s' "$_entry" | jq --arg khk "$_skhk" '. + {KnownHostKey: $khk}')"
|
||||||
|
fi
|
||||||
|
BOX_SSH_JSON="$(printf '%s' "$BOX_SSH_JSON" | jq --argjson e "$_entry" '. + [$e]')"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Build agent JSON ---
|
||||||
|
local AGENT_JSON
|
||||||
|
AGENT_JSON="$(jq -n \
|
||||||
|
--argjson skills "$SKILLS_JSON" \
|
||||||
|
--arg prompt "$PROMPT_CONTENT" \
|
||||||
|
'{skills: $skills, prompt: $prompt}')"
|
||||||
|
|
||||||
|
if [ -n "$BOX_NAME" ]; then
|
||||||
|
AGENT_JSON="$(printf '%s' "$AGENT_JSON" | jq --arg b "$BOX_NAME" '. + {box: $b}')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the new entry. When creating a brand-new box, include it so the
|
||||||
|
# merge below writes both boxes and agents in one step. env lives on the box.
|
||||||
|
local NEW_ENTRY
|
||||||
|
if [ -n "$BOX_NAME" ] && [ "$_box_exists" = "0" ]; then
|
||||||
|
NEW_ENTRY="$(jq -n \
|
||||||
|
--arg box_name "$BOX_NAME" \
|
||||||
|
--argjson box_env "$BOX_ENV_JSON" \
|
||||||
|
--argjson box_ssh "$BOX_SSH_JSON" \
|
||||||
|
--arg agent_name "$AGENT_NAME" \
|
||||||
|
--argjson agent "$AGENT_JSON" \
|
||||||
|
'{"boxes": {($box_name): {env: $box_env, ssh: $box_ssh}}, "agents": {($agent_name): $agent}}')"
|
||||||
|
else
|
||||||
|
NEW_ENTRY="$(jq -n \
|
||||||
|
--arg name "$AGENT_NAME" \
|
||||||
|
--argjson agent "$AGENT_JSON" \
|
||||||
|
'{"agents": {($name): $agent}}')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Write / merge into target file ---
|
||||||
|
printf '\n' >&2
|
||||||
|
|
||||||
|
local TMP_FILE
|
||||||
|
TMP_FILE="$(mktemp -t claude-bottle-init.XXXXXX.json)"
|
||||||
|
|
||||||
|
if [ -f "$TARGET_FILE" ]; then
|
||||||
|
if ! jq -e . "$TARGET_FILE" >/dev/null 2>&1; then
|
||||||
|
rm -f "$TMP_FILE"
|
||||||
|
die "${TARGET_FILE} exists but is not valid JSON; fix or remove it first"
|
||||||
|
fi
|
||||||
|
if ! printf '%s' "$NEW_ENTRY" | jq -s '{
|
||||||
|
"boxes": ((.[0].boxes // {}) * (.[1].boxes // {})),
|
||||||
|
"agents": ((.[0].agents // {}) * (.[1].agents // {}))
|
||||||
|
}' "$TARGET_FILE" - > "$TMP_FILE"; then
|
||||||
|
rm -f "$TMP_FILE"
|
||||||
|
die "failed to merge agent into ${TARGET_FILE}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! printf '%s\n' "$NEW_ENTRY" > "$TMP_FILE"; then
|
||||||
|
rm -f "$TMP_FILE"
|
||||||
|
die "failed to write ${TARGET_FILE}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$TMP_FILE" "$TARGET_FILE"
|
||||||
|
|
||||||
|
info "Agent '${AGENT_NAME}' written to ${TARGET_FILE}."
|
||||||
|
info "Run '$(basename "$0") info ${AGENT_NAME}' to verify."
|
||||||
|
printf '\n' >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# cmd_edit — open an agent entry in vim at the line where its key appears.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cmd_edit() {
|
||||||
|
usage_edit() {
|
||||||
|
printf 'usage: %s edit <user|project> <name>\n' "$(basename "$0")" >&2
|
||||||
|
printf ' user edit an agent in ~/claude-bottle.json\n' >&2
|
||||||
|
printf ' project edit an agent in ./claude-bottle.json in the current directory\n' >&2
|
||||||
|
printf ' <name> name of the agent to jump to\n' >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -lt 2 ]; then
|
||||||
|
usage_edit
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
local TARGET_FILE
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage_edit; exit 0 ;;
|
||||||
|
user) TARGET_FILE="${HOME}/claude-bottle.json" ;;
|
||||||
|
project) TARGET_FILE="${USER_CWD}/claude-bottle.json" ;;
|
||||||
|
*) usage_edit; die "expected 'user' or 'project', got: $1" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local NAME="$2"
|
||||||
|
|
||||||
|
require_jq
|
||||||
|
|
||||||
|
if [ ! -f "$TARGET_FILE" ]; then
|
||||||
|
die "${TARGET_FILE} does not exist"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! jq -e --arg n "$NAME" '.agents | has($n)' "$TARGET_FILE" >/dev/null 2>&1; then
|
||||||
|
die "agent '${NAME}' not found in ${TARGET_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local LINE
|
||||||
|
LINE="$(grep -Fn "\"${NAME}\"" "$TARGET_FILE" | head -1 | cut -d: -f1)"
|
||||||
|
LINE="${LINE:-1}"
|
||||||
|
|
||||||
|
exec vim +"${LINE}" "$TARGET_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if [ "$#" -lt 1 ]; then
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMAND="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
case "$COMMAND" in
|
||||||
|
build) cmd_build ;;
|
||||||
|
cleanup) cmd_cleanup ;;
|
||||||
|
edit) cmd_edit "$@" ;;
|
||||||
|
info) cmd_info "$@" ;;
|
||||||
|
init) cmd_init "$@" ;;
|
||||||
|
list) cmd_list "$@" ;;
|
||||||
|
start) cmd_start "$@" ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) usage; die "unknown command: ${COMMAND}" ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Decisions and state changes are logged in `JOURNAL.md`. Research notes live in `research/`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Journal
|
||||||
|
|
||||||
|
Append-only stream of thought. Newest entries on top. Each entry is a timestamp
|
||||||
|
followed by freeform prose. Tag entries with `[name](tag://name)` links under
|
||||||
|
the header — only when a coherent theme emerges. Otherwise just write.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Host Dispatch to Container Agents
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Can host Claude decide which claude-bottle container to spin up for a task, while guaranteeing the work executes in the container and not on the host?
|
||||||
|
|
||||||
|
## Claude Code Agent Mechanisms
|
||||||
|
|
||||||
|
Claude Code provides two mechanisms for defining reusable agent behavior:
|
||||||
|
|
||||||
|
**Skills** (`.claude/skills/<name>/SKILL.md`) run inline in the main conversation context. They're reusable workflows invoked via `/skill-name`, with optional tool pre-approval.
|
||||||
|
|
||||||
|
**Subagents** (`.claude/agents/<name>.md`) run in an isolated context window with a custom system prompt and a declared tool allowlist. They're invoked by natural language, `@agent-name`, or `claude --agent`. The `tools:` frontmatter is enforced — the subagent cannot call tools not in the list. (See [Claude Code subagents docs](https://code.claude.com/docs/en/sub-agents.md), "Choose the subagent scope" and "Write subagent files" sections.)
|
||||||
|
|
||||||
|
"Isolated context window" means only conversational isolation (fresh LLM state, summarized output). It is not process, filesystem, or network isolation. Subagents still run on the host with full user permissions.
|
||||||
|
|
||||||
|
## The Reliability Problem
|
||||||
|
|
||||||
|
The previous approach used an MCP server to bridge host Claude and claude-bottle containers. It failed because host Claude had both work-capable tools (Edit, Write, Bash) and MCP dispatch tools. Claude could choose to do the work itself rather than dispatch, with no enforcement mechanism to prevent it.
|
||||||
|
|
||||||
|
## Why Tool Restriction Solves It
|
||||||
|
|
||||||
|
Claude Code's subagent `tools:` allowlist is architecturally enforced — not a prompt-level suggestion. If the host subagent is defined with only container-dispatch tools and no Edit/Write/Bash, it is incapable of doing implementation work. Dispatch becomes the only available path.
|
||||||
|
|
||||||
|
## Reliable Dispatch Architecture
|
||||||
|
|
||||||
|
Three pieces in combination give a 100% guarantee:
|
||||||
|
|
||||||
|
1. **Restricted host subagent** — a `.claude/agents/claude-bottle-dispatch.md` with `tools:` limited to MCP container tools and git-read operations. No Edit, Write, or arbitrary Bash.
|
||||||
|
|
||||||
|
2. **MCP server** — exposes tools the restricted host can call:
|
||||||
|
- `list_agents()` — available agents from the manifest (host Claude decides which to use)
|
||||||
|
- `run_agent(agent_name, task)` — starts a container non-interactively, returns a job ID
|
||||||
|
- `get_status(job_id)` — check running/done
|
||||||
|
- `get_output(job_id)` — read results
|
||||||
|
|
||||||
|
3. **Non-interactive container run mode** — `cli.sh run <agent> "<task>"` passes the task to `claude --print` inside the container and captures output. Currently `cli.sh start` is interactive only; this mode does not yet exist.
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
Build host-dispatch-to-container in two deliverables:
|
||||||
|
|
||||||
|
**Deliverable 1: Non-interactive run mode for claude-bottle**
|
||||||
|
|
||||||
|
Extend `cli.sh` with a `run <agent> <task>` subcommand. Starts the container, writes the task prompt to a file inside it (same `docker cp` pattern used for `--append-system-prompt-file`), invokes `claude --print` with the prompt, streams stdout back to the host, and exits when Claude finishes. Results committed and pushed from inside the container as usual.
|
||||||
|
|
||||||
|
**Deliverable 2: MCP server wrapping claude-bottle**
|
||||||
|
|
||||||
|
A minimal MCP server (bash or node) exposing `list_agents`, `run_agent`, `get_status`, `get_output`. Registered in the host Claude Code settings so a restricted dispatch subagent can call it.
|
||||||
|
|
||||||
|
The combination enforces the container boundary at the tool layer, not the prompt layer — making it structurally impossible for host Claude to do implementation work itself.
|
||||||
|
|
||||||
|
**Critical:** the tool restriction only applies within the dispatch agent's context. A normal Claude session has its full toolset and may never invoke the dispatch agent regardless of its description. The dispatch agent must be the *entry point* for the session, not an optional subagent a full-tool host might call. Two ways to enforce this:
|
||||||
|
|
||||||
|
- Launch with `claude --agent claude-bottle-dispatch` — makes the dispatch agent the primary agent for the session.
|
||||||
|
- Set `agent: claude-bottle-dispatch` in the project `.claude/settings.json` — same effect automatically for any `claude` invocation in that directory.
|
||||||
|
|
||||||
|
Without one of these, the guarantee does not hold.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Landscape: containerized Claude Code agent tools
|
||||||
|
|
||||||
|
Research into whether claude-bottle is redundant with existing projects, and
|
||||||
|
whether it's worth publishing.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The "Claude Code in Docker" space is active but not saturated. claude-bottle
|
||||||
|
occupies a distinct position: no surveyed project combines all five of its
|
||||||
|
defining features. Publishing is likely worthwhile, with the main risk being
|
||||||
|
claudebox expanding to absorb the same niche.
|
||||||
|
|
||||||
|
## Closest competitor: claudebox
|
||||||
|
|
||||||
|
[RchGrav/claudebox](https://github.com/RchGrav/claudebox) is the most
|
||||||
|
feature-complete analog. It runs Claude Code in Docker with per-project
|
||||||
|
isolated images, 15+ pre-configured dev-language profiles, and per-project
|
||||||
|
network firewall allowlists. Actively maintained with multiple forks.
|
||||||
|
|
||||||
|
What it lacks: manifest-driven named agents, per-agent env resolution modes
|
||||||
|
(prompt / host-forward / literal), skill directory injection, per-agent system
|
||||||
|
prompts, SSH-agent forwarding without copying private keys, home+project
|
||||||
|
manifest merge.
|
||||||
|
|
||||||
|
## Other surveyed projects
|
||||||
|
|
||||||
|
- **textcortex/claude-code-sandbox → spritz** — evolved toward
|
||||||
|
Kubernetes-native multi-agent infra; not bash-first or local-Docker.
|
||||||
|
Original sandbox repo is archived.
|
||||||
|
- **trailofbits/claude-code-devcontainer** — devcontainer config for security
|
||||||
|
audits; not a general agent launcher.
|
||||||
|
- **Several small solo repos** (arezi/claude-sandbox, nkrefman/claude-sandbox,
|
||||||
|
VishalJ99/claude-docker) — lightweight Docker wrappers with no multi-agent
|
||||||
|
config layer.
|
||||||
|
- **Docker's official sandbox templates** — launch-and-run Dockerfiles plus an
|
||||||
|
npm-based runtime; not a manifest-driven fleet manager.
|
||||||
|
|
||||||
|
## Adjacent (different model)
|
||||||
|
|
||||||
|
- **dagger/container-use** (mid-2025) — exposes an MCP server so the *agent*
|
||||||
|
spins up its own containers with Git worktrees. Inverted model vs. claude-bottle
|
||||||
|
(agent controls container rather than being launched into one by a manifest).
|
||||||
|
Still marked early-development.
|
||||||
|
- **E2B, Northflank, Cloudflare Sandbox SDK** — cloud-hosted SaaS sandbox
|
||||||
|
runtimes; fundamentally different architecture.
|
||||||
|
|
||||||
|
## What no found project does
|
||||||
|
|
||||||
|
None combine:
|
||||||
|
1. Named-agent JSON manifest with per-agent env resolution (prompt / host-forward / literal)
|
||||||
|
2. Claude Code skills directory injection
|
||||||
|
3. Per-agent system prompts
|
||||||
|
4. SSH-agent key forwarding without copying private keys into the container
|
||||||
|
5. Home + project manifest merge
|
||||||
|
|
||||||
|
## Publishing verdict
|
||||||
|
|
||||||
|
Worth publishing. Differentiators that matter to the target audience (power
|
||||||
|
users running parallel Claude Code sessions with distinct personas/tooling):
|
||||||
|
|
||||||
|
- The bash-first, low-dependency design — competitors are npm-based or
|
||||||
|
Kubernetes-native.
|
||||||
|
- Named agents with distinct skills and system prompts, not just language profiles.
|
||||||
|
- SSH forwarding without key copying.
|
||||||
|
|
||||||
|
Main risk: claudebox adds manifest/agent config. The space is moving fast
|
||||||
|
enough that publishing sooner is better if establishing prior art matters.
|
||||||
|
|
||||||
|
Discovery will be slow without active promotion; an Anthropic Discord post or
|
||||||
|
HN "Show HN" would do most of the work.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- GitHub search cannot surface private or very new repos comprehensively.
|
||||||
|
- Counts (stars, forks) were not confirmed for every project.
|
||||||
|
- Research conducted 2026-05-07; the space moves fast.
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# Local vs. Remote Agent Execution: Security & Privacy Tradeoffs
|
||||||
|
|
||||||
|
Research notes on when to run containerized Claude Code agents on a remote machine
|
||||||
|
outside the local network versus inside it, focusing on security and privacy concerns.
|
||||||
|
Relevant to a potential claude-bottle extension for remote agent execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The core mental model
|
||||||
|
|
||||||
|
The topology decision isn't "local = safe, remote = dangerous." The real variables are:
|
||||||
|
**what can the agent reach if compromised**, **what's on the host if the container
|
||||||
|
escapes**, and **whether credentials are short-lived and scoped**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat landscape by topology
|
||||||
|
|
||||||
|
### Local (current claude-bottle model)
|
||||||
|
|
||||||
|
- Container escape → developer laptop → `~/.ssh`, `~/.aws`, browser cookies, Keychain, everything
|
||||||
|
- Outbound: Docker containers have full internet access by default; no egress monitoring on most home networks
|
||||||
|
- Lateral movement: compromised container can reach the LAN — NAS, other machines, internal services
|
||||||
|
- Notable: CVE-2025-59536 (CVSS 8.7, Feb 2026) — a poisoned `.claude/settings.json` in a repo gives RCE when Claude Code opens it. `--dangerously-skip-permissions` removes the last gate.
|
||||||
|
- Supply chain: MCP servers, skills, and npm packages pulled during agent execution. ~20% of ClawHub skills were found malicious in early 2026.
|
||||||
|
|
||||||
|
**What local topology protects:**
|
||||||
|
- No inbound attack surface — nothing listening on a public port
|
||||||
|
- Secrets stay physically on your hardware; no transit risk
|
||||||
|
- Network egress bounded by the host router
|
||||||
|
|
||||||
|
### Remote machine
|
||||||
|
|
||||||
|
- New inbound attack surface (SSH/API port must be open; CISA notes exploitation of remote access vulns within 9–13 days of disclosure)
|
||||||
|
- Secrets must travel from local to remote — each transit is a new exposure class
|
||||||
|
- If the VM has a cloud IAM role attached → blast radius includes cloud APIs (S3, RDS, IAM, etc.)
|
||||||
|
- Compromised remote host can read env vars injected into containers, intercept docker exec sessions, exfiltrate skills and prompts
|
||||||
|
- **Worst case:** a remote VM connected back to the LAN via VPN is the worst of both worlds — internet-facing attack surface + full LAN access. This pattern should never be built.
|
||||||
|
- Multiple agents sharing the same remote host creates cross-tenant bleed risk.
|
||||||
|
|
||||||
|
**What remote topology can offer:**
|
||||||
|
- Better isolation from the developer laptop — a compromised container doesn't reach `~/.ssh` or local credentials unless explicitly forwarded
|
||||||
|
- Ephemeral compute — a cloud VM torn down after each session leaves no persistent attack surface
|
||||||
|
- Cloud-native network controls (Security Groups, VPC firewall, PrivateLink) can be more granular than home router rules
|
||||||
|
- VPC flow logs + CloudTrail give an audit trail that a home network doesn't
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data sensitivity — when "on-prem" matters
|
||||||
|
|
||||||
|
Data that should not leave the local network absent extraordinary controls:
|
||||||
|
|
||||||
|
| Data type | Why |
|
||||||
|
|---|---|
|
||||||
|
| Private SSH keys | If copied to remote, stored outside your control; lateral movement via key reuse |
|
||||||
|
| Secrets forwarded into containers (API tokens, OAuth tokens) | In-transit exposure; remote machine persistence risk |
|
||||||
|
| Source code under NDA or with unreleased IP | Contractual and competitive risk |
|
||||||
|
| PHI (HIPAA) | Requires BAA with every system that touches it; standard cloud VMs don't qualify |
|
||||||
|
| PII of EU residents (GDPR) | Cannot legally transit US infrastructure without SCCs |
|
||||||
|
| Internal API credentials for LAN systems | Sending to a remote agent that can reach back via VPN creates a remote-controlled pivot |
|
||||||
|
|
||||||
|
Data that can go remote with proper controls:
|
||||||
|
- Public open-source code
|
||||||
|
- Non-sensitive project scaffolding
|
||||||
|
- Prompts that don't contain embedded secrets
|
||||||
|
- Derived outputs (build artifacts, test results) that don't contain source data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blast radius comparison
|
||||||
|
|
||||||
|
### Worst case: local container compromise
|
||||||
|
|
||||||
|
1. Container escape via kernel exploit → developer laptop
|
||||||
|
2. From laptop: `~/.ssh/id_rsa`, `~/.aws/credentials`, browser session cookies, macOS Keychain, `~/.claude`
|
||||||
|
3. Lateral movement to LAN — internal services, NAS, other dev machines
|
||||||
|
4. Outbound: anything the home/office network allows
|
||||||
|
|
||||||
|
### Worst case: remote container compromise
|
||||||
|
|
||||||
|
1. Container escape → remote host
|
||||||
|
2. From remote host: host environment credentials, any attached IAM role, secrets in the host filesystem
|
||||||
|
3. If a VPN or SSH tunnel links remote machine to local network → full lateral movement back through that tunnel
|
||||||
|
4. Cloud API access if the VM has IAM permissions → S3, RDS, EC2, Secrets Manager, etc.
|
||||||
|
|
||||||
|
**Remote blast radius is potentially larger than local if:**
|
||||||
|
- The remote VM has cloud IAM permissions broader than the local laptop environment
|
||||||
|
- The remote machine is connected back to the local network via VPN (creating a pivot)
|
||||||
|
- Multiple agents share the same remote host
|
||||||
|
|
||||||
|
**Remote blast radius is smaller if:**
|
||||||
|
- The remote VM is strictly isolated (no VPN back to LAN)
|
||||||
|
- IAM role is locked to minimum necessary permissions
|
||||||
|
- Remote host is ephemeral and torn down after each session
|
||||||
|
|
||||||
|
Key insight: once a container is compromised via prompt injection, the blast radius is dominated by what the agent *can reach*, not by where it physically runs. Studies have measured an 82.4% inter-agent compromise rate in multi-agent systems.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credentials and secrets
|
||||||
|
|
||||||
|
### Local topology (current claude-bottle)
|
||||||
|
|
||||||
|
- Secrets live in the host environment or are prompted from `/dev/tty`
|
||||||
|
- Forwarded to containers via `-e NAME` (not `=value`), never on argv, never in env-files for secrets
|
||||||
|
- On container teardown, secrets are gone from that process space
|
||||||
|
- Risk: container escape to host reaches the host env where the parent process ran
|
||||||
|
|
||||||
|
### Remote topology
|
||||||
|
|
||||||
|
Secrets must travel from their source to the remote machine. Mechanisms in increasing security order:
|
||||||
|
|
||||||
|
1. **SSH env forwarding (`AcceptEnv`/`SendEnv`)** — secrets in plaintext in the SSH session; logged by some SSH daemon configurations
|
||||||
|
2. **Encrypted orchestration channel** — secrets encrypted in transit, but remote machine must decrypt and expose them in memory
|
||||||
|
3. **Vault/cloud secrets manager + dynamic credentials** — remote machine fetches its own short-lived secrets; local machine never sends secrets at all (best option)
|
||||||
|
|
||||||
|
An 8,640x reduction in abuse window comes from switching from 90-day keys to 15-minute tokens. Static long-lived tokens (`CLAUDE_CODE_OAUTH_TOKEN`, `GITLAB_TOKEN`) are the biggest risk in a remote topology.
|
||||||
|
|
||||||
|
**Proxy-inject pattern:** agent makes unauthenticated requests; a proxy outside the container injects credentials per-request. The container never sees the raw token. Anthropic's secure deployment docs recommend this pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Egress and exfiltration risk
|
||||||
|
|
||||||
|
### Local topology
|
||||||
|
|
||||||
|
- Monitoring: whatever the home/office router supports — usually minimal
|
||||||
|
- Containment: `--network none` + a proxy socket provides the strongest containment; claude-bottle does not currently do this
|
||||||
|
- DLP: essentially none unless specifically deployed on the LAN
|
||||||
|
- Domain fronting risk: even allowlisted-domain proxies can be bypassed via domain fronting — an agent that can reach `api.anthropic.com` could relay data to an attacker-controlled backend through that domain
|
||||||
|
- **claude-bottle today: containers have full outbound internet access. No egress restrictions.**
|
||||||
|
|
||||||
|
### Remote topology (cloud VM)
|
||||||
|
|
||||||
|
- VPC flow logs capture every connection attempt by IP/port — better than most home networks if configured
|
||||||
|
- Security Groups can allowlist specific endpoints; a NAT gateway can be locked down
|
||||||
|
- DLP tooling is more mature in cloud environments (AWS Macie, GCP DLP API, Cloudflare AI Gateway)
|
||||||
|
- But only if configured. A raw cloud VM with default settings has worse egress monitoring than a corporate network.
|
||||||
|
|
||||||
|
Strongest exfiltration controls for either topology:
|
||||||
|
1. `--network none` in Docker + a unix socket proxy that enforces domain allowlists
|
||||||
|
2. TLS inspection at the proxy to defeat domain fronting
|
||||||
|
3. Audit log all outbound traffic at the proxy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compliance
|
||||||
|
|
||||||
|
### HIPAA
|
||||||
|
|
||||||
|
- PHI must stay within HIPAA-covered infrastructure. Standard commercial cloud VMs require a signed BAA with the provider. AWS Bedrock, Azure OpenAI, and Google Cloud can be configured with BAA coverage; a generic EC2 cannot.
|
||||||
|
- If the agent processes any PHI, the remote machine must be in a HIPAA-qualified environment.
|
||||||
|
- "Minimum necessary" rule: an agent with broad filesystem access to a repo containing PHI violates this unless carefully scoped.
|
||||||
|
|
||||||
|
### GDPR
|
||||||
|
|
||||||
|
- EU personal data cannot legally be sent to US infrastructure without Standard Contractual Clauses or adequacy decisions.
|
||||||
|
- Local execution (if the developer is EU-based) sidesteps this. Remote execution on a US cloud VM requires SCCs.
|
||||||
|
|
||||||
|
### SOC2
|
||||||
|
|
||||||
|
- No specific data residency mandate, but all infrastructure that touches in-scope data must implement the trust criteria. A remote VM needs audit logs, access controls, and continuous monitoring — more operational overhead than local execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision heuristics
|
||||||
|
|
||||||
|
| Scenario | Recommendation |
|
||||||
|
|---|---|
|
||||||
|
| Solo developer, personal projects, no regulated data | Local is fine; add egress proxy for defense-in-depth |
|
||||||
|
| Regulated data (HIPAA, GDPR, SOC2) | Local unless the remote environment is fully qualified |
|
||||||
|
| Long-running automated tasks, no secrets in content | Remote VM acceptable with egress controls and ephemeral lifecycle |
|
||||||
|
| Parallel agent fleet | Remote VM cluster with per-agent IAM roles, strict egress, centralized secret management |
|
||||||
|
| Agent receives credentials that give network access to LAN | Keep local; never build a VPN-connected remote agent that can pivot back to the LAN |
|
||||||
|
| Source code with embedded secrets (`.env`, API keys in config) | Local only, or sanitize before sending to remote |
|
||||||
|
| Lack infrastructure maturity for proper remote config | Local — a poorly configured VM is strictly worse than a well-configured local container |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concrete recommendations if extending claude-bottle for remote
|
||||||
|
|
||||||
|
1. **Never build the VPN-pivot pattern.** A remote agent connected back to the LAN via VPN is the worst of both worlds. If a remote agent needs LAN resources, expose those through a narrow API, not a VPN.
|
||||||
|
|
||||||
|
2. **Add `--network none` + a proxy socket, even locally first.** The single highest-leverage change available right now. Bounds egress to allowlisted domains, prevents arbitrary exfiltration regardless of topology. Anthropic's secure deployment docs show exactly how to do this.
|
||||||
|
|
||||||
|
3. **Use dynamic, short-lived credentials for the remote context.** Replace static tokens with per-task dynamically issued credentials (Vault with approle auth, or cloud workload identity federation). This eliminates the "credential concentration on remote host" problem.
|
||||||
|
|
||||||
|
4. **Proxy-inject credentials; don't send them to the container.** The proxy runs on the remote host (not in the container); the credential lives only in the proxy process.
|
||||||
|
|
||||||
|
5. **Scope IAM/cloud permissions to the minimum.** No `*:*` policies. No admin roles on the VM.
|
||||||
|
|
||||||
|
6. **Make the remote VM ephemeral.** Spin it up for the session, tear it down when done. No persistent credentials or data between sessions.
|
||||||
|
|
||||||
|
7. **Enable VPC flow logs and forward them somewhere.** Going remote buys you egress monitoring you don't have locally — but only if configured.
|
||||||
|
|
||||||
|
8. **Validate hooks before execution.** `init-work` / `init-free-agent` copies a project into the container. That project may contain poisoned `.claude/settings.json` hooks (CVE-2025-59536 vector). `--dangerously-skip-permissions` removes the last gate; consider validating hooks before execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
For the current claude-bottle use case (developer feature implementation, no regulated data,
|
||||||
|
single developer), local execution is the right default. The biggest unaddressed risk
|
||||||
|
right now isn't topology — it's that containers have unrestricted outbound internet access.
|
||||||
|
Adding `--network none` + a proxy socket would be higher-leverage than any topology change.
|
||||||
|
|
||||||
|
Remote execution becomes worth the complexity for parallelism at scale, long-running
|
||||||
|
unattended tasks, or strict separation of agent compute from developer hardware — but
|
||||||
|
only with egress controls, ephemeral lifecycle, and dynamic credential management in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Penligent AI — AI Agents Hacking in 2026: Defending the New Execution Boundary
|
||||||
|
- Repello AI — The Agentic AI security threat landscape in 2026
|
||||||
|
- arxiv 2601.17548 — Prompt Injection Attacks on Agentic Coding Assistants
|
||||||
|
- Unit42 (Palo Alto) — Navigating Security Tradeoffs of AI Agents
|
||||||
|
- Trend Micro — Unveiling AI Agent Vulnerabilities Part III: Data Exfiltration
|
||||||
|
- Help Net Security — 29 million leaked secrets in 2025: AI agents credentials are out of control
|
||||||
|
- GitGuardian — Short-Lived Credentials in Agentic Systems: A Practical Trade-off Guide
|
||||||
|
- Aembit — Securing AI Agents Without Static Credentials
|
||||||
|
- Anthropic Docs — Securely Deploying AI Agents
|
||||||
|
- Anthropic Engineering — Claude Code Sandboxing
|
||||||
|
- TrueFoundry — Claude Code Sandboxing: Network Isolation, File System Controls
|
||||||
|
- TrueFoundry — LLM Deployment in Regulated Industries: HIPAA, SOC2 & GDPR Playbook
|
||||||
|
- MindStudio — AI Agent Compliance: GDPR SOC 2 and Beyond
|
||||||
|
- OX Security — The Mother of All AI Supply Chains: Critical MCP Vulnerability
|
||||||
|
- The Hacker News — Anthropic MCP Design Vulnerability Enables RCE
|
||||||
|
- Straiker — Agent Hijacking: How Prompt Injection Leads to Full AI System Compromise
|
||||||
|
- Seraphic Security — Secure Remote Access Best Practices 2025
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Docker helpers. Build/inspect primitives shared by cli.sh
|
||||||
|
# (and reusable by future skill-sync / secret-injection scripts).
|
||||||
|
# Idempotent: safe to source multiple times.
|
||||||
|
|
||||||
|
if [ -n "${CLAUDE_BOTTLE_LIB_DOCKER_SOURCED:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
CLAUDE_BOTTLE_LIB_DOCKER_SOURCED=1
|
||||||
|
|
||||||
|
_iso_lib_docker_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./log.sh
|
||||||
|
. "${_iso_lib_docker_dir}/log.sh"
|
||||||
|
|
||||||
|
# require_docker — fails with an install pointer if `docker` is not on PATH.
|
||||||
|
require_docker() {
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
info "Docker is required but was not found on PATH."
|
||||||
|
info "macOS: install Docker Desktop https://docs.docker.com/desktop/install/mac-install/"
|
||||||
|
info "Linux: install Docker Engine https://docs.docker.com/engine/install/"
|
||||||
|
die "docker not found"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# image_exists <ref> — returns 0 if the named local image exists, else 1.
|
||||||
|
image_exists() {
|
||||||
|
local ref="${1:?image_exists: missing image reference}"
|
||||||
|
docker image inspect "$ref" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# container_exists <name> — returns 0 if a container (running or stopped)
|
||||||
|
# with the given name exists, else 1.
|
||||||
|
container_exists() {
|
||||||
|
local name="${1:?container_exists: missing container name}"
|
||||||
|
# `docker ps -a -q -f name=^<name>$` prints the container id if it exists.
|
||||||
|
local id
|
||||||
|
id="$(docker ps -a -q -f "name=^${name}$" 2>/dev/null || true)"
|
||||||
|
[ -n "$id" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# slugify <name> — prints a DNS-safe slug (lowercase, non-alnum runs → '-',
|
||||||
|
# trimmed) on stdout. Exits non-zero if the result is empty.
|
||||||
|
slugify() {
|
||||||
|
local input="${1:?slugify: missing name}"
|
||||||
|
local slug
|
||||||
|
slug="$(printf '%s' "$input" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
|
||||||
|
if [ -z "$slug" ]; then
|
||||||
|
die "name '${input}' produced an empty slug; use alphanumeric characters"
|
||||||
|
fi
|
||||||
|
printf '%s' "$slug"
|
||||||
|
}
|
||||||
|
|
||||||
|
# build_image <ref> <context_dir> — invokes `docker build` every call. The
|
||||||
|
# layer cache makes no-change rebuilds cheap (typically <1s); always running
|
||||||
|
# the build means edits to the Dockerfile (or anything COPY'd in) take
|
||||||
|
# effect on the next cli.sh without the user having to manually `docker
|
||||||
|
# rmi` first.
|
||||||
|
build_image() {
|
||||||
|
local ref="${1:?build_image: missing image reference}"
|
||||||
|
local context="${2:?build_image: missing build context directory}"
|
||||||
|
|
||||||
|
info "building image ${ref} from ${context} (layer cache keeps repeat builds fast)"
|
||||||
|
docker build -t "$ref" "$context"
|
||||||
|
}
|
||||||
|
|
||||||
|
# build_image_with_cwd <derived_ref> <base_ref> <cwd>
|
||||||
|
#
|
||||||
|
# Builds a thin derived image that copies the contents of <cwd> into
|
||||||
|
# /home/node/workspace (owned by node:node) and sets WORKDIR there, so
|
||||||
|
# the launched claude session starts inside the user's project.
|
||||||
|
#
|
||||||
|
# The Dockerfile is piped via stdin (`-f -`) so no file is written into
|
||||||
|
# <cwd> — only the build context is read from there. Any .dockerignore
|
||||||
|
# already in <cwd> is honored automatically by docker build.
|
||||||
|
#
|
||||||
|
# A trust-dialog entry for /home/node/workspace is added to
|
||||||
|
# ~/.claude.json during the build, because the baked-in entry in the
|
||||||
|
# base image only covers /home/node and claude's "trust this folder"
|
||||||
|
# prompt is keyed on cwd.
|
||||||
|
build_image_with_cwd() {
|
||||||
|
local derived="${1:?build_image_with_cwd: missing derived ref}"
|
||||||
|
local base="${2:?build_image_with_cwd: missing base ref}"
|
||||||
|
local cwd="${3:?build_image_with_cwd: missing cwd}"
|
||||||
|
|
||||||
|
if [ ! -d "$cwd" ]; then
|
||||||
|
die "cwd not found at ${cwd}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "building image ${derived} from ${base} with ${cwd} -> /home/node/workspace"
|
||||||
|
docker build -t "$derived" -f - "$cwd" <<DOCKERFILE
|
||||||
|
FROM ${base}
|
||||||
|
COPY --chown=node:node . /home/node/workspace
|
||||||
|
RUN node -e 'const fs=require("fs"),p=process.env.HOME+"/.claude.json",c=JSON.parse(fs.readFileSync(p,"utf8"));c.projects=c.projects||{};c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};fs.writeFileSync(p,JSON.stringify(c,null,2));'
|
||||||
|
WORKDIR /home/node/workspace
|
||||||
|
DOCKERFILE
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Env-var helpers. Set/unset checks only — never echo a secret value.
|
||||||
|
# See CLAUDE.md "Checking env vars safely" for the rule this enforces.
|
||||||
|
# Idempotent: safe to source multiple times.
|
||||||
|
|
||||||
|
if [ -n "${CLAUDE_BOTTLE_LIB_ENV_SOURCED:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
CLAUDE_BOTTLE_LIB_ENV_SOURCED=1
|
||||||
|
|
||||||
|
# Resolve sibling helpers regardless of caller's cwd.
|
||||||
|
_iso_lib_env_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./log.sh
|
||||||
|
. "${_iso_lib_env_dir}/log.sh"
|
||||||
|
|
||||||
|
# require_env <NAME> — fails with a clear message if the named env var is
|
||||||
|
# unset or empty. Crucially does NOT print the value, the length, or any
|
||||||
|
# substring; only the variable name is echoed.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# require_env ANTHROPIC_API_KEY
|
||||||
|
require_env() {
|
||||||
|
local name="${1:-}"
|
||||||
|
if [ -z "$name" ]; then
|
||||||
|
die "require_env: missing variable name argument"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Indirect expansion to read the named variable without naming it twice.
|
||||||
|
local value="${!name-}"
|
||||||
|
if [ -z "$value" ]; then
|
||||||
|
die "required env var ${name} is not set. Export it in your shell and re-run."
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Env resolver. Walks the env entries for one agent in claude-bottle.json
|
||||||
|
# and produces:
|
||||||
|
# 1. The list of `docker run` arg fragments needed to forward each var.
|
||||||
|
# Both `secret` and `interpolated` entries become `-e NAME` (no
|
||||||
|
# `=value`) so Docker inherits the value from this process env
|
||||||
|
# without rendering it on argv or persisting it to disk.
|
||||||
|
# Only `literal` entries are written to a host-disk env-file and
|
||||||
|
# forwarded with `--env-file <path>`.
|
||||||
|
# 2. The export side-effect of populating this process's env with
|
||||||
|
# secret values prompted from the user, and with interpolated
|
||||||
|
# values copied from the matching host var, so `-e NAME` actually
|
||||||
|
# has something to inherit.
|
||||||
|
#
|
||||||
|
# Each env entry is a JSON string. Mode is selected by sentinel prefix:
|
||||||
|
# "?" → secret (prompt at runtime). Bare "?" uses a default
|
||||||
|
# prompt; "?<message>" uses <message> as the prompt body.
|
||||||
|
# "${HOST_VAR}" → interpolated from $HOST_VAR in the host process env
|
||||||
|
# any other str → literal (the JSON string is the value verbatim)
|
||||||
|
# A literal whose text starts with "?" or matches "${IDENT}" is not
|
||||||
|
# representable in v1 — pick a different value or change the convention.
|
||||||
|
#
|
||||||
|
# Critical rules (re-read CLAUDE.md "Checking env vars safely"):
|
||||||
|
# - NEVER echo, log, or interpolate the value of a secret or
|
||||||
|
# interpolated env var. Both modes are treated as potentially
|
||||||
|
# sensitive: nothing about their value (other than presence /
|
||||||
|
# length) ever lands on disk, in a log line, or on argv.
|
||||||
|
# - The env-file written for literal values lives under `mktemp -d`
|
||||||
|
# with mode 600 and is removed on script exit by the caller's trap.
|
||||||
|
# Secrets and interpolated values never go to this file.
|
||||||
|
# - Errors mention only the variable NAME, never any portion of the value.
|
||||||
|
#
|
||||||
|
# Idempotent: safe to source multiple times.
|
||||||
|
|
||||||
|
if [ -n "${CLAUDE_BOTTLE_LIB_ENV_RESOLVE_SOURCED:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
CLAUDE_BOTTLE_LIB_ENV_RESOLVE_SOURCED=1
|
||||||
|
|
||||||
|
_iso_lib_env_resolve_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./log.sh
|
||||||
|
. "${_iso_lib_env_resolve_dir}/log.sh"
|
||||||
|
# shellcheck source=./manifest.sh
|
||||||
|
. "${_iso_lib_env_resolve_dir}/manifest.sh"
|
||||||
|
|
||||||
|
# env_entry_kind <raw-string> — prints "secret", "interpolated", or
|
||||||
|
# "literal" based on the sentinel form of the entry. Never echoes the
|
||||||
|
# value of an interpolated entry — only its host-var NAME via the
|
||||||
|
# captured submatch. Secret-mode prompt text (everything after the
|
||||||
|
# leading "?") is extracted by env_entry_secret_prompt, not here.
|
||||||
|
env_entry_kind() {
|
||||||
|
local raw="${1-}"
|
||||||
|
case "$raw" in
|
||||||
|
\?*)
|
||||||
|
printf 'secret'
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$raw" =~ ^\$\{[A-Za-z_][A-Za-z0-9_]*\}$ ]]; then
|
||||||
|
printf 'interpolated'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'literal'
|
||||||
|
}
|
||||||
|
|
||||||
|
# env_entry_secret_prompt <raw-string> — for a secret entry (one whose
|
||||||
|
# raw value starts with "?"), prints the prompt body (everything after
|
||||||
|
# the leading "?"). Empty for a bare "?", which signals "use default
|
||||||
|
# prompt." Caller is responsible for falling back to a default.
|
||||||
|
env_entry_secret_prompt() {
|
||||||
|
local raw="${1-}"
|
||||||
|
printf '%s' "${raw#\?}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# env_entry_interpolated_from <raw-string> — for an interpolated entry,
|
||||||
|
# prints the host var name (the identifier between `${` and `}`).
|
||||||
|
env_entry_interpolated_from() {
|
||||||
|
local raw="${1-}"
|
||||||
|
local inner="${raw#\$\{}"
|
||||||
|
inner="${inner%\}}"
|
||||||
|
printf '%s' "$inner"
|
||||||
|
}
|
||||||
|
|
||||||
|
# _read_secret_silent <NAME> [<prompt-body>] — prompt the user for a
|
||||||
|
# secret value on the tty without echoing the keystrokes. Stores the
|
||||||
|
# value in the global variable named by $1 via printf -v. Stdin
|
||||||
|
# redirection from /dev/tty so this still works under `<(...)` and
|
||||||
|
# other non-tty stdin situations.
|
||||||
|
#
|
||||||
|
# If <prompt-body> is provided and non-empty, the prompt rendered to
|
||||||
|
# the tty is "<prompt-body> (input hidden): "; otherwise it falls back
|
||||||
|
# to "claude-bottle: secret value for <NAME> (input hidden): ". The "(input
|
||||||
|
# hidden): " tail is always appended by this function — manifest
|
||||||
|
# authors write the message text only.
|
||||||
|
#
|
||||||
|
# We never `echo "$VALUE"` or interpolate it elsewhere; the only consumer
|
||||||
|
# is `export "$NAME=$VALUE"` immediately below.
|
||||||
|
_read_secret_silent() {
|
||||||
|
local target="${1:?_read_secret_silent: missing target var name}"
|
||||||
|
local prompt_body="${2-}"
|
||||||
|
local value=""
|
||||||
|
# Use the controlling tty for both the prompt and the read so this is
|
||||||
|
# robust even if stdin is a pipe.
|
||||||
|
if [ ! -t 0 ] && [ ! -t 2 ]; then
|
||||||
|
die "cannot prompt for secret '${target}': no tty available. Run from an interactive shell."
|
||||||
|
fi
|
||||||
|
# `printf` to /dev/tty for the prompt, `read -s` from /dev/tty for the value.
|
||||||
|
if [ -n "$prompt_body" ]; then
|
||||||
|
printf '%s (input hidden): ' "$prompt_body" >/dev/tty
|
||||||
|
else
|
||||||
|
printf 'claude-bottle: secret value for %s (input hidden): ' "$target" >/dev/tty
|
||||||
|
fi
|
||||||
|
# IFS= read -rs to read one line, raw, silent.
|
||||||
|
IFS= read -rs value </dev/tty
|
||||||
|
printf '\n' >/dev/tty
|
||||||
|
if [ -z "$value" ]; then
|
||||||
|
die "empty value provided for secret '${target}'. Re-run and supply a value."
|
||||||
|
fi
|
||||||
|
# Indirect assignment — never expose value via expansion in a string we
|
||||||
|
# log or pass anywhere else.
|
||||||
|
printf -v "$target" '%s' "$value"
|
||||||
|
# Scrub our local copy.
|
||||||
|
value=""
|
||||||
|
}
|
||||||
|
|
||||||
|
# env_resolve <manifest_file> <agent_name> <env_file_path> <out_args_path>
|
||||||
|
#
|
||||||
|
# Iterates the agent's env entries. For each entry:
|
||||||
|
# - secret → ALWAYS prompt for the value (even if already set in
|
||||||
|
# this process env), export it into this process, and
|
||||||
|
# append `-e NAME` to <out_args_path> (one arg per
|
||||||
|
# line; a NAME with no `=value`).
|
||||||
|
# - interpolated→ read the host process env value of the named host var;
|
||||||
|
# if unset, die with the host-var name. Copy into this
|
||||||
|
# process under the target name and append `-e NAME` to
|
||||||
|
# <out_args_path>. Never written to disk.
|
||||||
|
# - literal → append `NAME=VALUE` to <env_file_path>; the resolver
|
||||||
|
# does NOT add anything to <out_args_path> for this entry
|
||||||
|
# (the caller adds a single `--env-file <env_file_path>`
|
||||||
|
# if the file is non-empty).
|
||||||
|
#
|
||||||
|
# The caller is responsible for:
|
||||||
|
# - creating <env_file_path> as an empty file with mode 600 under a
|
||||||
|
# mktemp dir,
|
||||||
|
# - creating <out_args_path> as an empty file,
|
||||||
|
# - cleaning both up on exit (trap),
|
||||||
|
# - reading <out_args_path> line-by-line into the docker-run argv.
|
||||||
|
#
|
||||||
|
# Returns 0 on success, dies on any error.
|
||||||
|
env_resolve() {
|
||||||
|
local manifest_file="${1:?env_resolve: missing manifest file}"
|
||||||
|
local agent="${2:?env_resolve: missing agent name}"
|
||||||
|
local env_file="${3:?env_resolve: missing env_file path}"
|
||||||
|
local out_args="${4:?env_resolve: missing out_args path}"
|
||||||
|
|
||||||
|
local name raw kind from prompt_body
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[ -z "$name" ] && continue
|
||||||
|
raw="$(manifest_env_entry "$manifest_file" "$agent" "$name")"
|
||||||
|
kind="$(env_entry_kind "$raw")"
|
||||||
|
case "$kind" in
|
||||||
|
secret)
|
||||||
|
# Always prompt — never trust an already-exported host value.
|
||||||
|
# A "?"-prefixed entry in the manifest is the user's signal
|
||||||
|
# that this variable must be supplied interactively at launch
|
||||||
|
# time, even if a same-named var is already in the parent shell.
|
||||||
|
prompt_body="$(env_entry_secret_prompt "$raw")"
|
||||||
|
_read_secret_silent "$name" "$prompt_body"
|
||||||
|
# Export so child processes (docker run) inherit. `-e NAME` (no
|
||||||
|
# value) on docker run picks up from the parent process env.
|
||||||
|
export "${name?}"
|
||||||
|
printf -- '-e\n%s\n' "$name" >>"$out_args"
|
||||||
|
;;
|
||||||
|
interpolated)
|
||||||
|
from="$(env_entry_interpolated_from "$raw")"
|
||||||
|
# Treat interpolated values as potentially sensitive: never write
|
||||||
|
# them to disk and never put them on argv. Instead, copy the host
|
||||||
|
# var into THIS process under the target name (so Docker can
|
||||||
|
# inherit it via `-e NAME`), and emit `-e NAME` in the args file.
|
||||||
|
# The check below uses indirect expansion only to determine
|
||||||
|
# presence — no expansion of the value lands in any output.
|
||||||
|
if [ -z "${!from-}" ]; then
|
||||||
|
die "env entry ${name} is interpolated from \$${from}, but \$${from} is unset or empty in the host environment."
|
||||||
|
fi
|
||||||
|
# Copy via printf -v + indirect read. We use a brief local then
|
||||||
|
# immediately export under $name and scrub the local.
|
||||||
|
local _interp_val
|
||||||
|
_interp_val="${!from}"
|
||||||
|
printf -v "${name?}" '%s' "$_interp_val"
|
||||||
|
_interp_val=""
|
||||||
|
export "${name?}"
|
||||||
|
printf -- '-e\n%s\n' "$name" >>"$out_args"
|
||||||
|
;;
|
||||||
|
literal)
|
||||||
|
# Multi-line literal values are not supported by docker --env-file,
|
||||||
|
# so reject them up front rather than letting docker fail with a
|
||||||
|
# confusing message.
|
||||||
|
case "$raw" in
|
||||||
|
*$'\n'*) die "env entry ${name} (literal) contains a newline; docker --env-file cannot represent multi-line values." ;;
|
||||||
|
esac
|
||||||
|
printf '%s=%s\n' "$name" "$raw" >>"$env_file"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(manifest_env_names "$manifest_file" "$agent")
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tiny logging wrappers. Sourced by entry-point scripts.
|
||||||
|
# Idempotent: safe to source multiple times.
|
||||||
|
|
||||||
|
if [ -n "${CLAUDE_BOTTLE_LIB_LOG_SOURCED:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
CLAUDE_BOTTLE_LIB_LOG_SOURCED=1
|
||||||
|
|
||||||
|
# info <msg...> — informational message to stderr.
|
||||||
|
info() {
|
||||||
|
printf 'claude-bottle: %s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# warn <msg...> — warning to stderr.
|
||||||
|
warn() {
|
||||||
|
printf 'claude-bottle: warning: %s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# die <msg...> — error to stderr, exit 1.
|
||||||
|
die() {
|
||||||
|
printf 'claude-bottle: error: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Manifest helpers. Read claude-bottle.json and pull the definition for a named
|
||||||
|
# agent.
|
||||||
|
#
|
||||||
|
# The manifest schema is documented in CLAUDE.md "Intended design". In
|
||||||
|
# short:
|
||||||
|
# {
|
||||||
|
# "boxes": {
|
||||||
|
# "<box-name>": {
|
||||||
|
# "env": { "<NAME>": <env-entry>, ... },
|
||||||
|
# "ssh": [ <ssh-entry>, ... ]
|
||||||
|
# },
|
||||||
|
# ...
|
||||||
|
# },
|
||||||
|
# "agents": {
|
||||||
|
# "<agent-name>": {
|
||||||
|
# "skills": [ "<skill-name>", ... ],
|
||||||
|
# "prompt": "<string>",
|
||||||
|
# "box": "<box-name>"
|
||||||
|
# },
|
||||||
|
# ...
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# A box groups shared infrastructure (SSH keys, known hosts) that multiple
|
||||||
|
# agents can reference by name. The "box" field is required on every agent;
|
||||||
|
# cli.sh start rejects agents that omit it.
|
||||||
|
#
|
||||||
|
# An <env-entry> is a JSON string. Mode is selected by sentinel prefix:
|
||||||
|
# "?<message>" → prompt for the value at runtime, displaying <message>
|
||||||
|
# (bare "?" is allowed; uses a default prompt)
|
||||||
|
# "${HOST_VAR}" → interpolate from $HOST_VAR in the host process env
|
||||||
|
# any other str → literal (the JSON string is the value verbatim)
|
||||||
|
# The classification lives in env_resolve.sh (env_entry_kind); this
|
||||||
|
# module only fetches the raw string and validates that it is a string.
|
||||||
|
#
|
||||||
|
# Manifest parsing happens on the host with `jq`, never inside the
|
||||||
|
# container. We never echo env *values* here — only names. For literal
|
||||||
|
# entries the "name" and the value happen to be the same shape (both
|
||||||
|
# are JSON strings), so callers must take care not to log the result of
|
||||||
|
# manifest_env_entry.
|
||||||
|
#
|
||||||
|
# All functions (except manifest_resolve) take a manifest_file argument —
|
||||||
|
# the path to a resolved JSON file, typically produced by manifest_resolve.
|
||||||
|
#
|
||||||
|
# Idempotent: safe to source multiple times.
|
||||||
|
|
||||||
|
if [ -n "${CLAUDE_BOTTLE_LIB_MANIFEST_SOURCED:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
CLAUDE_BOTTLE_LIB_MANIFEST_SOURCED=1
|
||||||
|
|
||||||
|
_iso_lib_manifest_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./log.sh
|
||||||
|
. "${_iso_lib_manifest_dir}/log.sh"
|
||||||
|
|
||||||
|
# require_jq — fails with an install pointer if `jq` is not on PATH.
|
||||||
|
require_jq() {
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
info "jq is required on the host for claude-bottle manifest parsing."
|
||||||
|
info "macOS: brew install jq"
|
||||||
|
info "Linux: apt-get install jq (or your distro equivalent)"
|
||||||
|
die "jq not found"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_resolve <cwd> — looks for claude-bottle.json in <cwd> and in $HOME,
|
||||||
|
# merges the two (cwd entries override home entries for the same agent name),
|
||||||
|
# and prints the merged JSON to stdout. Dies if neither file is found or if
|
||||||
|
# either found file is not valid JSON.
|
||||||
|
manifest_resolve() {
|
||||||
|
local cwd="${1:?manifest_resolve: missing cwd}"
|
||||||
|
local cwd_file="${cwd}/claude-bottle.json"
|
||||||
|
local home_file="${HOME}/claude-bottle.json"
|
||||||
|
|
||||||
|
local has_cwd=0 has_home=0
|
||||||
|
|
||||||
|
if [ -f "$cwd_file" ]; then
|
||||||
|
if ! jq -e . "$cwd_file" >/dev/null 2>&1; then
|
||||||
|
die "claude-bottle.json at ${cwd_file} is not valid JSON"
|
||||||
|
fi
|
||||||
|
has_cwd=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$home_file" ]; then
|
||||||
|
if ! jq -e . "$home_file" >/dev/null 2>&1; then
|
||||||
|
die "claude-bottle.json at ${home_file} is not valid JSON"
|
||||||
|
fi
|
||||||
|
has_home=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$has_cwd" = "0" ] && [ "$has_home" = "0" ]; then
|
||||||
|
die "no claude-bottle.json found in ${cwd} or ${HOME}"
|
||||||
|
elif [ "$has_cwd" = "1" ] && [ "$has_home" = "0" ]; then
|
||||||
|
cat "$cwd_file"
|
||||||
|
elif [ "$has_cwd" = "0" ] && [ "$has_home" = "1" ]; then
|
||||||
|
cat "$home_file"
|
||||||
|
else
|
||||||
|
# Merge: home is the base, cwd overrides on name conflict for both boxes and agents.
|
||||||
|
jq -s '{
|
||||||
|
"boxes": ((.[0].boxes // {}) * (.[1].boxes // {})),
|
||||||
|
"agents": ((.[0].agents // {}) * (.[1].agents // {}))
|
||||||
|
}' "$home_file" "$cwd_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_has_agent <manifest_file> <name> — returns 0 if the agent key
|
||||||
|
# exists in the manifest, else 1.
|
||||||
|
manifest_has_agent() {
|
||||||
|
local manifest_file="${1:?manifest_has_agent: missing manifest file}"
|
||||||
|
local name="${2:?manifest_has_agent: missing agent name}"
|
||||||
|
jq -e --arg n "$name" '.agents | has($n)' "$manifest_file" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_require_agent <manifest_file> <name> — like manifest_has_agent but
|
||||||
|
# dies with a useful message (and prints the available agent names) if the
|
||||||
|
# named agent is not defined.
|
||||||
|
manifest_require_agent() {
|
||||||
|
local manifest_file="${1:?manifest_require_agent: missing manifest file}"
|
||||||
|
local name="${2:?manifest_require_agent: missing agent name}"
|
||||||
|
if ! manifest_has_agent "$manifest_file" "$name"; then
|
||||||
|
local available
|
||||||
|
available="$(jq -r '.agents | keys_unsorted | join(", ")' "$manifest_file" 2>/dev/null || echo "")"
|
||||||
|
if [ -n "$available" ]; then
|
||||||
|
die "agent '${name}' not defined in claude-bottle.json. Available: ${available}"
|
||||||
|
else
|
||||||
|
die "agent '${name}' not defined in claude-bottle.json (manifest is empty)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_env_names <manifest_file> <name> — prints one env-var name per line
|
||||||
|
# on stdout (the keys of boxes[agent.box].env, in declaration order). No values.
|
||||||
|
# Prints nothing if the agent has no box or the box has no env.
|
||||||
|
manifest_env_names() {
|
||||||
|
local manifest_file="${1:?manifest_env_names: missing manifest file}"
|
||||||
|
local name="${2:?manifest_env_names: missing agent name}"
|
||||||
|
jq -r --arg n "$name" '
|
||||||
|
.agents[$n].box as $box |
|
||||||
|
if ($box == null or $box == "") then empty
|
||||||
|
else (.boxes[$box].env // {} | keys_unsorted[])
|
||||||
|
end
|
||||||
|
' "$manifest_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_env_entry <manifest_file> <agent> <env_name> — prints the raw
|
||||||
|
# string value of a single env entry on stdout (no quoting, no JSON
|
||||||
|
# encoding). Env entries live on the agent's box (boxes[agent.box].env).
|
||||||
|
# Used by env_resolve.sh, which classifies the result by sentinel. Dies
|
||||||
|
# if the agent has no box, or the entry is not a JSON string; the
|
||||||
|
# prompt-at-runtime form is "?<message>", not JSON null.
|
||||||
|
manifest_env_entry() {
|
||||||
|
local manifest_file="${1:?manifest_env_entry: missing manifest file}"
|
||||||
|
local agent="${2:?manifest_env_entry: missing agent name}"
|
||||||
|
local var="${3:?manifest_env_entry: missing env var name}"
|
||||||
|
local box
|
||||||
|
box="$(jq -r --arg a "$agent" '.agents[$a].box // ""' "$manifest_file")"
|
||||||
|
if [ -z "$box" ]; then
|
||||||
|
die "env entry ${var} for agent ${agent}: agent has no 'box' field"
|
||||||
|
fi
|
||||||
|
local entry_type
|
||||||
|
entry_type="$(jq -r --arg b "$box" --arg v "$var" '.boxes[$b].env[$v] | type' "$manifest_file")"
|
||||||
|
if [ "$entry_type" != "string" ]; then
|
||||||
|
die "env entry ${var} for agent ${agent} must be a JSON string (was ${entry_type}). Use \"?<message>\" for prompt-at-runtime."
|
||||||
|
fi
|
||||||
|
jq -r --arg b "$box" --arg v "$var" '.boxes[$b].env[$v]' "$manifest_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_skills <manifest_file> <name> — prints one skill name per line on
|
||||||
|
# stdout (the elements of agent.skills, in order).
|
||||||
|
manifest_skills() {
|
||||||
|
local manifest_file="${1:?manifest_skills: missing manifest file}"
|
||||||
|
local name="${2:?manifest_skills: missing agent name}"
|
||||||
|
jq -r --arg n "$name" '.agents[$n].skills // [] | .[]' "$manifest_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_prompt <manifest_file> <name> — prints the prompt string on stdout
|
||||||
|
# (no trailing newline manipulation; the raw value goes out). Empty string
|
||||||
|
# if not set.
|
||||||
|
manifest_prompt() {
|
||||||
|
local manifest_file="${1:?manifest_prompt: missing manifest file}"
|
||||||
|
local name="${2:?manifest_prompt: missing agent name}"
|
||||||
|
jq -r --arg n "$name" '.agents[$n].prompt // ""' "$manifest_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_agent_box <manifest_file> <name> — prints the box name referenced
|
||||||
|
# by the agent on stdout, or an empty string if the agent has no "box" field.
|
||||||
|
manifest_agent_box() {
|
||||||
|
local manifest_file="${1:?manifest_agent_box: missing manifest file}"
|
||||||
|
local name="${2:?manifest_agent_box: missing agent name}"
|
||||||
|
jq -r --arg n "$name" '.agents[$n].box // ""' "$manifest_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_has_box <manifest_file> <box_name> — returns 0 if the named box
|
||||||
|
# exists in the manifest, else 1.
|
||||||
|
manifest_has_box() {
|
||||||
|
local manifest_file="${1:?manifest_has_box: missing manifest file}"
|
||||||
|
local box_name="${2:?manifest_has_box: missing box name}"
|
||||||
|
jq -e --arg b "$box_name" '.boxes | has($b)' "$manifest_file" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_require_box <manifest_file> <box_name> — like manifest_has_box but
|
||||||
|
# dies with a useful message (and prints available box names) if the box is
|
||||||
|
# not defined.
|
||||||
|
manifest_require_box() {
|
||||||
|
local manifest_file="${1:?manifest_require_box: missing manifest file}"
|
||||||
|
local box_name="${2:?manifest_require_box: missing box name}"
|
||||||
|
if ! manifest_has_box "$manifest_file" "$box_name"; then
|
||||||
|
local available
|
||||||
|
available="$(jq -r '.boxes // {} | keys_unsorted | join(", ")' "$manifest_file" 2>/dev/null || echo "")"
|
||||||
|
if [ -n "$available" ]; then
|
||||||
|
die "box '${box_name}' not defined in claude-bottle.json. Available boxes: ${available}"
|
||||||
|
else
|
||||||
|
die "box '${box_name}' not defined in claude-bottle.json (no boxes defined)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_box_ssh <manifest_file> <box_name> — prints one compact JSON object
|
||||||
|
# per line for each ssh entry in boxes[box_name].ssh. Prints nothing if the
|
||||||
|
# box has no ssh array or it is empty.
|
||||||
|
manifest_box_ssh() {
|
||||||
|
local manifest_file="${1:?manifest_box_ssh: missing manifest file}"
|
||||||
|
local box_name="${2:?manifest_box_ssh: missing box name}"
|
||||||
|
jq -c --arg b "$box_name" '.boxes[$b].ssh // [] | .[]' "$manifest_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_ssh <manifest_file> <name> — prints one compact JSON object per line
|
||||||
|
# for each ssh entry associated with the agent. SSH entries are resolved via
|
||||||
|
# the agent's "box" field: if set, entries come from boxes[box].ssh; if the
|
||||||
|
# agent has no "box" field, prints nothing.
|
||||||
|
# Each object has: Host, IdentityFile, Hostname, User, Port (required);
|
||||||
|
# KnownHostKey (optional).
|
||||||
|
manifest_ssh() {
|
||||||
|
local manifest_file="${1:?manifest_ssh: missing manifest file}"
|
||||||
|
local name="${2:?manifest_ssh: missing agent name}"
|
||||||
|
local box
|
||||||
|
box="$(jq -r --arg n "$name" '.agents[$n].box // ""' "$manifest_file")"
|
||||||
|
if [ -z "$box" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
jq -c --arg b "$box" '.boxes[$b].ssh // [] | .[]' "$manifest_file"
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Skill copier. Copies named skills from the host's ~/.claude/skills/<name>/
|
||||||
|
# into the running container's ~/.claude/skills/<name>/, preserving
|
||||||
|
# directory structure (no flattening, no archives), per CLAUDE.md
|
||||||
|
# "Intended design".
|
||||||
|
#
|
||||||
|
# Scope of THIS file (matches PRD 0002 "Open question 3" resolution):
|
||||||
|
# - host → container only.
|
||||||
|
# - if a referenced skill is missing on the host, fail with a clear
|
||||||
|
# message naming the skill. No silent skipping. The repo-side
|
||||||
|
# `skills/<name>/` snapshot and host↔repo diff prompt described in
|
||||||
|
# CLAUDE.md "Intended design" are deferred.
|
||||||
|
#
|
||||||
|
# Idempotent: safe to source multiple times.
|
||||||
|
|
||||||
|
if [ -n "${CLAUDE_BOTTLE_LIB_SKILLS_SOURCED:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
CLAUDE_BOTTLE_LIB_SKILLS_SOURCED=1
|
||||||
|
|
||||||
|
_iso_lib_skills_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./log.sh
|
||||||
|
. "${_iso_lib_skills_dir}/log.sh"
|
||||||
|
|
||||||
|
# Container-side home/skills paths. The Dockerfile sets the user to `node`
|
||||||
|
# (uid 1000) with home /home/node, so this is where claude-code looks.
|
||||||
|
CLAUDE_BOTTLE_CONTAINER_HOME="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}"
|
||||||
|
CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR="${CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR:-${CLAUDE_BOTTLE_CONTAINER_HOME}/.claude/skills}"
|
||||||
|
|
||||||
|
# host_skill_dir <name> — prints the absolute host path for a skill.
|
||||||
|
host_skill_dir() {
|
||||||
|
local name="${1:?host_skill_dir: missing skill name}"
|
||||||
|
printf '%s/.claude/skills/%s' "${HOME:?HOME not set}" "$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# host_skill_exists <name> — returns 0 if the host has a skill directory
|
||||||
|
# at ~/.claude/skills/<name>/, else 1.
|
||||||
|
host_skill_exists() {
|
||||||
|
local name="${1:?host_skill_exists: missing skill name}"
|
||||||
|
[ -d "$(host_skill_dir "$name")" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# require_host_skill <name> — dies with a clear message if the named
|
||||||
|
# skill is missing on the host. The error names the skill and the path
|
||||||
|
# checked.
|
||||||
|
require_host_skill() {
|
||||||
|
local name="${1:?require_host_skill: missing skill name}"
|
||||||
|
if ! host_skill_exists "$name"; then
|
||||||
|
die "skill '${name}' not found on host at $(host_skill_dir "$name"). Create it under ~/.claude/skills/, then re-run."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# skills_validate_all <name1> [<name2> ...] — checks every named skill
|
||||||
|
# exists on the host, dies on the first one that does not. No copy yet.
|
||||||
|
# Use this BEFORE the confirmation prompt so the user does not get
|
||||||
|
# asked y/N for a plan that's already known to fail.
|
||||||
|
skills_validate_all() {
|
||||||
|
local n
|
||||||
|
for n in "$@"; do
|
||||||
|
require_host_skill "$n"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# skills_copy_into <container> <name1> [<name2> ...]
|
||||||
|
#
|
||||||
|
# For each named skill:
|
||||||
|
# 1. ensure ~/.claude/skills/ exists in the container (mkdir -p)
|
||||||
|
# 2. `docker cp <host_skill_dir>/. <container>:<container_skills>/<name>/`
|
||||||
|
# — the trailing `/.` on the source preserves directory structure
|
||||||
|
# and copies the contents into a freshly-created destination dir,
|
||||||
|
# avoiding the docker-cp quirk where copying `dir` (no slash) into
|
||||||
|
# an existing `dest/` would nest as `dest/dir/`.
|
||||||
|
#
|
||||||
|
# The destination directory is removed first if it already exists, so
|
||||||
|
# repeated calls produce a deterministic state.
|
||||||
|
skills_copy_into() {
|
||||||
|
local container="${1:?skills_copy_into: missing container name}"
|
||||||
|
shift
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure the target parent dir exists in the container. This is a
|
||||||
|
# no-op if the Dockerfile already created it, but cheap and defensive.
|
||||||
|
docker exec "$container" mkdir -p "${CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR}" >/dev/null
|
||||||
|
|
||||||
|
local n src dst
|
||||||
|
for n in "$@"; do
|
||||||
|
src="$(host_skill_dir "$n")"
|
||||||
|
if [ ! -d "$src" ]; then
|
||||||
|
die "skill '${n}' disappeared from host between validation and copy at ${src}."
|
||||||
|
fi
|
||||||
|
dst="${CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR}/${n}"
|
||||||
|
info "copying skill ${n} into ${container}:${dst}"
|
||||||
|
# Wipe any prior copy so we're deterministic, then create empty dst
|
||||||
|
# and copy contents-of-src into it via the `/.` source-suffix trick.
|
||||||
|
docker exec "$container" rm -rf "$dst" >/dev/null
|
||||||
|
docker exec "$container" mkdir -p "$dst" >/dev/null
|
||||||
|
docker cp "${src}/." "${container}:${dst}/" >/dev/null
|
||||||
|
done
|
||||||
|
}
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# SSH helpers. Validates ssh entries from claude-bottle.json, then sets up SSH
|
||||||
|
# inside the container via a root-owned ssh-agent so the `node` user (Claude)
|
||||||
|
# can use the keys for SSH operations but cannot read the key bytes.
|
||||||
|
#
|
||||||
|
# Why an in-container agent (not bind-mounted from host): Docker Desktop on
|
||||||
|
# macOS does not forward Unix-domain socket connect() across the macOS↔Linux
|
||||||
|
# VM boundary — connect() returns ENOTSUP. Running ssh-agent inside the
|
||||||
|
# container sidesteps that entirely and keeps the same isolation guarantee.
|
||||||
|
#
|
||||||
|
# How the isolation works:
|
||||||
|
# - Keys are docker cp'd to /root/.claude-bottle-keys/ (mode 700, root-owned).
|
||||||
|
# /root itself is mode 700 in the node:22-slim base image, so node (uid
|
||||||
|
# 1000) cannot even traverse into it.
|
||||||
|
# - ssh-agent runs as root, listening on /run/claude-bottle-agent.sock. Each
|
||||||
|
# key is loaded with ssh-add, then the key file is deleted. The bytes
|
||||||
|
# now live only in the agent process's memory.
|
||||||
|
# - The agent socket stays root-only. OpenSSH's ssh-agent enforces a
|
||||||
|
# SO_PEERCRED-based UID match: it rejects every connection whose peer
|
||||||
|
# euid is neither 0 nor the agent's own uid. chmod'ing the socket open
|
||||||
|
# does *not* defeat this — the kernel-level check still rejects node.
|
||||||
|
# - To bridge that, a root-owned socat forwarder listens on
|
||||||
|
# /run/claude-bottle-agent-public.sock (mode 666) and proxies bytes to the
|
||||||
|
# real agent socket. From the agent's view, socat (uid 0) is the peer
|
||||||
|
# and passes the UID check. From node's view, the public socket is the
|
||||||
|
# accessible endpoint.
|
||||||
|
# - node cannot ptrace the root-owned agent or socat (no CAP_SYS_PTRACE in
|
||||||
|
# a default container), so /proc/<pid>/mem is off-limits and the key
|
||||||
|
# bytes never leave root-owned memory.
|
||||||
|
# - ~/.ssh/config in node's home points each Host at the public socket via
|
||||||
|
# IdentityAgent, so SSH always reaches the forwarder regardless of
|
||||||
|
# SSH_AUTH_SOCK.
|
||||||
|
#
|
||||||
|
# Limitation: keys must be passphrase-less. ssh-add prompts on /dev/tty for
|
||||||
|
# passphrases, but our docker exec has no TTY. Adding SSH_ASKPASS support is
|
||||||
|
# possible but not implemented in v1.
|
||||||
|
#
|
||||||
|
# Each ssh entry is a JSON object (jq -c) with keys:
|
||||||
|
# Host SSH Host alias
|
||||||
|
# IdentityFile absolute path to the private key file on the host
|
||||||
|
# Hostname the actual hostname or IP
|
||||||
|
# User SSH username
|
||||||
|
# Port SSH port (number)
|
||||||
|
# KnownHostKey (optional) host public key — written to known_hosts under
|
||||||
|
# both the Host alias and the Hostname so the lookup works
|
||||||
|
# whether SSH connects via the alias or the raw IP/host.
|
||||||
|
#
|
||||||
|
# Idempotent: safe to source multiple times.
|
||||||
|
|
||||||
|
if [ -n "${CLAUDE_BOTTLE_LIB_SSH_SOURCED:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
CLAUDE_BOTTLE_LIB_SSH_SOURCED=1
|
||||||
|
|
||||||
|
_iso_lib_ssh_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./log.sh
|
||||||
|
. "${_iso_lib_ssh_dir}/log.sh"
|
||||||
|
|
||||||
|
# ssh_validate_entries <json_object>... — checks that each entry has the
|
||||||
|
# required fields and that its IdentityFile exists on the host. Dies on the
|
||||||
|
# first problem.
|
||||||
|
ssh_validate_entries() {
|
||||||
|
local entry name key
|
||||||
|
for entry in "$@"; do
|
||||||
|
name="$(printf '%s' "$entry" | jq -r '.Host // empty')"
|
||||||
|
key="$(printf '%s' "$entry" | jq -r '.IdentityFile // empty')"
|
||||||
|
[ -n "$name" ] || die "ssh entry missing required field 'Host': ${entry}"
|
||||||
|
[ -n "$key" ] || die "ssh entry '${name}' missing required field 'IdentityFile'"
|
||||||
|
# Expand a leading ~ so callers can use ~/... paths.
|
||||||
|
key="${key/#\~/$HOME}"
|
||||||
|
[ -f "$key" ] || die "ssh key file not found for host '${name}': ${key}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ssh_setup <container> <stage_dir> <json_object>... — sets up SSH in the
|
||||||
|
# container so node (Claude) can authenticate using each entry's key without
|
||||||
|
# the key file being readable by node.
|
||||||
|
#
|
||||||
|
# Lifecycle:
|
||||||
|
# 1. Create ~/.ssh (700) for node and /root/.claude-bottle-keys (700) for root.
|
||||||
|
# 2. docker cp each key into /root/.claude-bottle-keys/, chown root, chmod 600.
|
||||||
|
# 3. Boot ssh-agent at /run/claude-bottle-agent.sock (root-only), ssh-add each
|
||||||
|
# key, delete the key file, rmdir the keys staging dir.
|
||||||
|
# 4. Boot a root-owned socat forwarder on /run/claude-bottle-agent-public.sock
|
||||||
|
# (mode 666) proxying to the agent socket. Bridges the UID-match check
|
||||||
|
# that would otherwise reject node's connections (see file header).
|
||||||
|
# 5. Install ~/.ssh/config (IdentityAgent → public socket) and
|
||||||
|
# ~/.ssh/known_hosts under node's home.
|
||||||
|
ssh_setup() {
|
||||||
|
local container="${1:?ssh_setup: missing container}"
|
||||||
|
local stage_dir="${2:?ssh_setup: missing stage dir}"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
local container_home="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}"
|
||||||
|
local container_ssh="${container_home}/.ssh"
|
||||||
|
local agent_socket="/run/claude-bottle-agent.sock"
|
||||||
|
local public_socket="/run/claude-bottle-agent-public.sock"
|
||||||
|
local keys_dir="/root/.claude-bottle-keys"
|
||||||
|
|
||||||
|
# ~/.ssh for node (700, owned by node).
|
||||||
|
docker exec -u 0 "$container" mkdir -p "$container_ssh" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chown node:node "$container_ssh" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chmod 700 "$container_ssh" >/dev/null
|
||||||
|
|
||||||
|
# /root/.claude-bottle-keys for root (700, root-owned). /root is already 700
|
||||||
|
# in node:22-slim, so node can't traverse here either way; setting both
|
||||||
|
# layers keeps the intent explicit.
|
||||||
|
docker exec -u 0 "$container" mkdir -p "$keys_dir" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chown root:root "$keys_dir" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chmod 700 "$keys_dir" >/dev/null
|
||||||
|
|
||||||
|
local config_file="${stage_dir}/ssh_config"
|
||||||
|
local known_hosts_file="${stage_dir}/ssh_known_hosts"
|
||||||
|
: > "$config_file"
|
||||||
|
chmod 600 "$config_file"
|
||||||
|
: > "$known_hosts_file"
|
||||||
|
chmod 600 "$known_hosts_file"
|
||||||
|
|
||||||
|
local entry name key hostname user port known_host_key key_basename container_key_path
|
||||||
|
local container_key_paths=()
|
||||||
|
for entry in "$@"; do
|
||||||
|
name="$(printf '%s' "$entry" | jq -r '.Host')"
|
||||||
|
key="$(printf '%s' "$entry" | jq -r '.IdentityFile')"
|
||||||
|
hostname="$(printf '%s' "$entry" | jq -r '.Hostname')"
|
||||||
|
user="$(printf '%s' "$entry" | jq -r '.User')"
|
||||||
|
port="$(printf '%s' "$entry" | jq -r '.Port')"
|
||||||
|
known_host_key="$(printf '%s' "$entry" | jq -r '.KnownHostKey // empty')"
|
||||||
|
|
||||||
|
key="${key/#\~/$HOME}"
|
||||||
|
key_basename="$(basename "$key")"
|
||||||
|
container_key_path="${keys_dir}/${key_basename}"
|
||||||
|
|
||||||
|
info "copying ssh key for '${name}' -> ${container} (root-only staging)"
|
||||||
|
docker cp "$key" "${container}:${container_key_path}" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chown root:root "$container_key_path" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chmod 600 "$container_key_path" >/dev/null
|
||||||
|
|
||||||
|
container_key_paths+=("$container_key_path")
|
||||||
|
|
||||||
|
# No IdentityFile — IdentityAgent points SSH at the public (forwarded)
|
||||||
|
# socket. Pointing at the real agent socket directly would be rejected
|
||||||
|
# by ssh-agent's UID-match check (see file header).
|
||||||
|
printf 'Host %s\n HostName %s\n User %s\n Port %s\n IdentityAgent %s\n\n' \
|
||||||
|
"$name" "$hostname" "$user" "$port" "$public_socket" >> "$config_file"
|
||||||
|
|
||||||
|
if [ -n "$known_host_key" ]; then
|
||||||
|
# Write under both the Host alias and the Hostname so SSH finds the key
|
||||||
|
# whether the connection uses the alias (`ssh <name>`) or a raw IP/host
|
||||||
|
# (e.g. git remote URLs that bypass the alias). Skip the duplicate when
|
||||||
|
# they're already the same string.
|
||||||
|
if [ "$port" = "22" ]; then
|
||||||
|
printf '%s %s\n' "$name" "$known_host_key" >> "$known_hosts_file"
|
||||||
|
[ "$hostname" != "$name" ] && printf '%s %s\n' "$hostname" "$known_host_key" >> "$known_hosts_file"
|
||||||
|
else
|
||||||
|
printf '[%s]:%s %s\n' "$name" "$port" "$known_host_key" >> "$known_hosts_file"
|
||||||
|
[ "$hostname" != "$name" ] && printf '[%s]:%s %s\n' "$hostname" "$port" "$known_host_key" >> "$known_hosts_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Boot the agent, load each key, delete the key files, then start the
|
||||||
|
# root-owned socat forwarder that exposes a node-accessible socket. One
|
||||||
|
# docker exec so the whole sequence is atomic — if any step fails (e.g.
|
||||||
|
# passphrase-protected key), set -e dies before we leave behind a
|
||||||
|
# half-initialized agent.
|
||||||
|
info "starting in-container ssh-agent at ${agent_socket} (forwarded via ${public_socket})"
|
||||||
|
local setup_script="set -eu
|
||||||
|
ssh-agent -a ${agent_socket} >/dev/null
|
||||||
|
"
|
||||||
|
local kp
|
||||||
|
for kp in "${container_key_paths[@]}"; do
|
||||||
|
setup_script+="SSH_AUTH_SOCK=${agent_socket} ssh-add ${kp}
|
||||||
|
rm -f ${kp}
|
||||||
|
"
|
||||||
|
done
|
||||||
|
setup_script+="rmdir ${keys_dir} 2>/dev/null || true
|
||||||
|
# Start the forwarder. Detach from the calling shell so it survives this
|
||||||
|
# docker exec returning. socat (running as root) connects to the agent on
|
||||||
|
# node's behalf; the agent's UID-match check sees uid 0 and accepts.
|
||||||
|
nohup socat UNIX-LISTEN:${public_socket},fork,reuseaddr,mode=666 UNIX-CONNECT:${agent_socket} </dev/null >/dev/null 2>&1 &
|
||||||
|
# Wait briefly for the forwarder to bind. Without this, an SSH client that
|
||||||
|
# fires immediately after this script returns can race the listener and hit
|
||||||
|
# ENOENT/ECONNREFUSED on the public socket.
|
||||||
|
i=0
|
||||||
|
while [ \$i -lt 20 ]; do
|
||||||
|
[ -S ${public_socket} ] && break
|
||||||
|
i=\$((i + 1))
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[ -S ${public_socket} ] || { echo 'claude-bottle: socat forwarder failed to bind ${public_socket}' >&2; exit 1; }
|
||||||
|
"
|
||||||
|
docker exec -u 0 "$container" sh -c "$setup_script"
|
||||||
|
|
||||||
|
info "writing ${container_ssh}/config"
|
||||||
|
docker cp "$config_file" "${container}:${container_ssh}/config" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chown node:node "${container_ssh}/config" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chmod 600 "${container_ssh}/config" >/dev/null
|
||||||
|
|
||||||
|
if [ -s "$known_hosts_file" ]; then
|
||||||
|
info "writing ${container_ssh}/known_hosts"
|
||||||
|
docker cp "$known_hosts_file" "${container}:${container_ssh}/known_hosts" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chown node:node "${container_ssh}/known_hosts" >/dev/null
|
||||||
|
docker exec -u 0 "$container" chmod 600 "${container_ssh}/known_hosts" >/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user