diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..eef0376 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/Liohtml/agentbox/security/advisories/new + about: Please report security issues privately via GitHub Security Advisories. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1a13725 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +# What & why + + + +## Scope + + + +- [ ] `src/cli/` — arg parsing, help, config, command router +- [ ] `src/docker/` — image build, container run/exec, mounts +- [ ] `src/session/` — tmux entry script, TTY sizing +- [ ] `assets/` — Dockerfile / Observer +- [ ] `src/types.ts` / `CONTRACT.md` — contract change (explain below!) +- [ ] Docs / CI / meta + +## Checklist + +- [ ] `npm run typecheck` passes +- [ ] `npm test` passes +- [ ] No build step, no new runtime dependencies (see CONTRIBUTING.md) +- [ ] Contract friction with `CONTRACT.md` / `src/types.ts` is described below (or n/a) + +## Notes + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..be9972b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + dev-dependencies: + dependency-type: development + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cbd1bb..d575301 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ["**"] + branches: [main] pull_request: permissions: @@ -14,16 +14,22 @@ concurrency: jobs: build: - name: typecheck & test (Node 22) + name: typecheck & test (Node ${{ matrix.node }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [22, 24] steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - - name: Setup Node 22 + - name: Setup Node ${{ matrix.node }} uses: actions/setup-node@v4 with: - node-version: "22" + node-version: ${{ matrix.node }} cache: npm - name: Install dependencies @@ -34,3 +40,32 @@ jobs: - name: Test run: npm test + + pack-smoke: + name: npm pack smoke test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Pack tarball + run: npm pack --pack-destination "$RUNNER_TEMP" + + - name: Install tarball into a fresh project and run the CLI + run: | + mkdir -p "$RUNNER_TEMP/consumer" + cd "$RUNNER_TEMP/consumer" + npm init -y >/dev/null + npm install "$RUNNER_TEMP"/liohtml-agentbox-*.tgz + # The installed bin must run without any repo-local dev setup. + npx agentbox --help + npx agentbox doctor || true # doctor exits non-zero without Docker; it must still *run* + npx agentbox doctor 2>&1 | grep -q "agentbox doctor" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e8cf71a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,75 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + checks: + name: typecheck, test & tag/version match + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: npm ci + - run: npm run typecheck + - run: npm test + + - name: Tag must match package.json version + run: | + tag="${GITHUB_REF_NAME#v}" + pkg="$(node -p "require('./package.json').version")" + if [ "$tag" != "$pkg" ]; then + echo "::error::tag v$tag does not match package.json version $pkg — refusing to publish a mismatched release." + exit 1 + fi + + pack-smoke: + name: npm pack smoke test + needs: checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: npm ci + + - name: Pack tarball and run it as an end user would + run: | + npm pack --pack-destination "$RUNNER_TEMP" + mkdir -p "$RUNNER_TEMP/consumer" + cd "$RUNNER_TEMP/consumer" + npm init -y >/dev/null + npm install "$RUNNER_TEMP"/liohtml-agentbox-*.tgz + npx agentbox --help + npx agentbox doctor 2>&1 | grep -q "agentbox doctor" + + publish: + name: npm publish (provenance) + needs: pack-smoke + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + - run: npm ci + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5ceb3fc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,121 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project +adheres to [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Fixed (review follow-up) +- The container no longer runs with `--rm`: a container that exits early + (entry-script crash) now stays around for `docker logs` to inspect instead + of vanishing immediately. `up` removes a stale stopped container before + starting fresh; `down` still removes it explicitly; `waitForSession` now + also gives up early if the container itself exits instead of polling out + the full timeout. +- Reattaching now reports the **running container's actual** `--yolo` state + (via a container label) instead of echoing back the current invocation's + flag, which is ignored on reattach and could previously claim the wrong + thing in either direction. +- A value flag given with no value (e.g. a trailing `--gpus`) is now a hard + error instead of silently being dropped. +- `validateConfig` (and the "workspace must exist" check) now only gate + `up`/`rebuild`; `shell`/`down` stay usable even with a currently-invalid + `MEM`/`CPUS`/workspace. `doctor` now reports every `validateConfig` + problem explicitly instead of a separate, narrower ad hoc port check. +- `~/.claude.json` symlinked to a directory is now a clear error (previously + it silently passed through and Docker mounted a directory). +- The generated entry script's temp directory is verified (owner uid + no + group/other permissions) after creation, closing a race where another + local user could pre-create it to intercept the mounted script. +- Launcher: dropped the shell-based global-`tsx`-on-PATH fallback (it forwarded + raw argv through a shell, letting metacharacters be interpreted on + Windows); `tsx` is a runtime dependency now, so a missing local install is + a clear, actionable error instead. +- CI/release workflows: `persist-credentials: false` on all checkouts; the + release workflow now verifies the pushed tag matches `package.json`'s + version, runs the `npm pack` tarball smoke test as a publish gate, and + disables dependency caching (a compromised cache must never influence what + gets published). +- Dockerfile: `CLAUDE_CODE_VERSION`/`TSX_VERSION` build-arg defaults are now + pinned exact versions instead of `latest`, so a plain rebuild is + reproducible; override the args for an intentional upgrade. + +### Fixed +- **Detaching no longer destroys the sandbox.** The container now runs + detached with the entry script as PID 1 and your terminal attaches via + `docker exec`, so `Ctrl+b d` behaves as documented (box keeps running). + Previously the tmux attach client was PID 1: detach killed every agent and + `--rm` deleted the container. +- **Teammates appear as tmux panes again.** The lead is launched with + `--teammate-mode tmux`; since Claude Code v2.1.179 the upstream default is + `in-process`, which silently disabled agentbox's split-pane layout. +- Observer: project/session names are HTML-escaped before rendering + (stored-XSS guard), the server handles bind errors (`EADDRINUSE`) with a + readable message, the observer tmux window keeps crash output visible + (`remain-on-exit`), and dead code was removed. +- CLI: invalid `--port`/`--memory`/`--cpus` values, unknown flags, stray + positionals, and `:` in mount paths fail fast with clear errors instead of + surfacing as cryptic Docker failures after a minutes-long build; a + `--workspace` typo no longer silently creates and mounts an empty + directory. +- `rebuild` refuses to run while the box is up (it used to rebuild the image + and then silently reattach to the old container); reattaching with tuning + flags now warns that they are ignored; stale stopped containers are removed + before `docker run` instead of causing a name-conflict error. +- `down` no longer reports success when `docker rm -f` actually failed. +- `~/.claude.json` edge cases: a directory there is now a clear error (was: a + broken mount), and symlinked configs (dotfile managers) are followed + correctly. +- The generated entry script lives in a per-user `0700` temp subdirectory + instead of a fixed world-shared `/tmp` path (tamper/TOCTOU + collision). +- Launcher: local `tsx` is invoked via `node` directly — no shell, so paths + with spaces work and `cmd.exe` metacharacters can't inject on Windows. +- `doctor` warns when the host uid differs from the container's `agent` + (1000), which breaks writes to the bind mounts. + +### Changed +- Dockerfile: `@anthropic-ai/claude-code` and `tsx` versions are overridable + via build args (`CLAUDE_CODE_VERSION`, `TSX_VERSION`) for reproducible + builds. +- Package renamed to the scoped **`@liohtml/agentbox`** for npm publishing + (the bare name `agentbox` is an npm security placeholder). The installed + command is still `agentbox`. +- `tsx` moved from devDependencies to dependencies so the published package + runs via `npx`/global install without extra setup. +- CI: Node 22 + 24 matrix, plus an `npm pack` smoke test that installs the + tarball and runs the CLI as an end user would. + +### Added +- **`--yolo` flag**: the Claude lead runs with + `--dangerously-skip-permissions` (teammates inherit it). Opt-in per + invocation, never the default — see README → Security & trust. +- **Persistent tool-cache volumes**: named Docker volumes for `~/.npm`, + `~/.cargo/registry` and pip's cache. The box itself stays disposable + (`down` / `up`'s stale-container cleanup); only downloaded packages + survive. +- **Git identity + `gh` auth forwarding**: `~/.gitconfig` and `~/.config/gh` + are bind-mounted read-only when present, and the `gh` CLI is installed in + the image — agents can commit and open PRs as you. Credentials are never + baked into the image, but note they *are* readable at runtime by anything + running inside the container, same as the existing Claude credential mount. +- **`--gpus` passthrough** to `docker run --gpus` (e.g. `all`, `device=0`). +- `doctor` warns when the local image architecture doesn't match the host + (emulated images are painfully slow under qemu). +- `SECURITY.md`, `CODE_OF_CONDUCT.md`, PR template, issue-template config, + Dependabot config, `RELEASING.md`, `ROADMAP.md`, this changelog. +- Tag-triggered release workflow with npm provenance. + +### Removed +- `PUBLISH.md` (one-time bootstrap doc) — replaced by `RELEASING.md`. + +## [0.1.0] — 2026-07-29 + +### Added +- Initial release: `agentbox up | shell | rebuild | doctor | down`. +- Docker image (Ubuntu 24.04 + Node 22 + Python 3 + Rust + Claude Code + tsx), + non-root `agent` user, no baked-in credentials. +- Host bind mounts for workspace + Claude auth reuse (`~/.claude`, + `~/.claude.json`). +- tmux session with Claude lead (Agent Teams) + Observer dashboard window. +- Zero-dependency Observer web server on `127.0.0.1:4317`. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6a0a015 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,42 @@ +# Code of Conduct + +This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +## Our pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic +status, nationality, personal appearance, race, caste, color, religion, or +sexual identity and orientation. + +## Our standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes + +Examples of unacceptable behavior: + +- Sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without their explicit permission + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported via a GitHub issue (for non-sensitive matters) or, for anything you'd +rather not post publicly, by contacting the maintainer directly: +[@Liohtml](https://github.com/Liohtml). All complaints will be reviewed and +investigated promptly and fairly. + +(Security vulnerabilities have their own private channel — see +[SECURITY.md](SECURITY.md); please don't use it for conduct reports.) + +For the full text including enforcement guidelines, see the +[Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). diff --git a/CONTRACT.md b/CONTRACT.md index 2ee3166..36ac775 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -45,6 +45,8 @@ Invoked as `agentbox [flags]` (via `bin/agentbox.mjs` or - `--cpus ` — docker cpu limit, e.g. `4` (also env `CPUS`). - `--port ` — observer port (default 4317). - `--network ` — `docker build --network` mode, e.g. `host` (also env `DOCKER_NETWORK`). Unset → Docker's default build network. +- `--gpus ` — `docker run --gpus` passthrough, e.g. `all` (Linux/NVIDIA). +- `--yolo` — launch the lead with `--dangerously-skip-permissions` (teammates inherit it). Opt-in only; recorded as a `agentbox.yolo` container label so reattach can report the box's actual mode. - `--no-cache` — (rebuild/up) force a clean image build. ### Env overrides (precedence: **flag > env > default**) @@ -66,7 +68,9 @@ Defaults (`DEFAULTS` in types): - `claudeDir` default = `~/.claude`, `claudeJson` default = `~/.claude.json` - `dockerfile` = `assets/Dockerfile`, `buildContext` = `assets/` - `observerAsset` = `assets/observer.mjs` -- `entryScriptPath` = a host temp path (e.g. `os.tmpdir()/agentbox-entry.sh`) +- `entryScriptPath` = a host temp path in a per-user 0700 subdir + (`os.tmpdir()/agentbox-/entry.sh`) — never a fixed path in the shared + temp dir (tamper/TOCTOU on multi-user hosts) --- @@ -99,6 +103,14 @@ export const session: SessionModule; // see src/types.ts | `config.claudeJson` (`~/.claude.json`) | `/home/agent/.claude.json` | **touch first** so Docker mounts a file, not a dir | | `config.entryScriptPath` | `/agentbox/entry.sh` | run via `bash` (no execute-bit reliance) | | `config.observerAsset` | `/agentbox/observer.mjs` | the dashboard | +| `config.gitconfigPath` *(if present)* | `/home/agent/.gitconfig` | **read-only** — git identity reuse | +| `config.ghConfigDir` *(if present)* | `/home/agent/.config/gh` | **read-only** — gh CLI auth reuse | + +Named cache volumes (`CACHE_VOLUMES` in types.ts): `agentbox-npm-cache`, +`agentbox-cargo-registry`, `agentbox-pip-cache` → the box itself stays +disposable (removed via `down`, or replaced by `up`'s stale-container +cleanup — see §6.7), only tool downloads persist. Host identity mounts are +read-only, existence-gated, and NEVER copied into the image. - Publish `-p :`. - Apply `--memory ` and `--cpus `. @@ -117,8 +129,19 @@ export const session: SessionModule; // see src/types.ts `main`. 4. **Launch the entry script via `bash /agentbox/entry.sh`** so a missing execute bit never blocks startup. -5. Set `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` in the `main` window. +5. Set `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` in the `main` window, and + launch the lead with `--teammate-mode tmux`: since Claude Code v2.1.179 + the default teammateMode is `in-process`, which silently disables the + split-pane layout. 6. Read the real TTY size via `stty size` so tmux isn't stuck at 80×24. +7. **The tmux attach client must NEVER be container PID 1.** The container + runs detached (`docker run -d`, deliberately NOT `--rm`); the entry script + sets the session up and idles while it exists, and clients attach via + `docker exec … tmux attach`. Otherwise `C-b d` (detach) exits PID 1 and + kills every agent. Not using `--rm` means a container that exits early + (entry-script crash) stays inspectable via `docker logs`; `up` removes a + stale stopped container before starting fresh, and `down` removes it + explicitly. --- diff --git a/PUBLISH.md b/PUBLISH.md deleted file mode 100644 index 6d7ef2a..0000000 --- a/PUBLISH.md +++ /dev/null @@ -1,101 +0,0 @@ -# Publishing `agentbox` to GitHub - -Step-by-step to publish this directory as the **public** repo -`Liohtml/agentbox`. Run everything from the repo root: - -```bash -cd ~/sandbox/workspace/agentbox -``` - -Requirements: `git`, and the GitHub CLI `gh` (v2.87+) authenticated as -`Liohtml` (`gh auth status` should show ✓ Logged in). All these tools are -already present and authenticated on this machine. - ---- - -## 0. Prerequisite: git identity (only if unset) - -The publish creates a commit, so git needs a name and email. Check, and set if -blank: - -```bash -git config user.name # if empty, set it: -git config user.email # if empty, set it: - -git config user.name "Lio" -git config user.email "lionel.machire@googlemail.com" -``` - -(Use `--global` instead of bare flags if you want it applied for all repos.) - ---- - -## 1. Publish (primary path) - -### 1a. If this is NOT yet a git repo - -```bash -git init -b main -git add -A -git commit -m "Initial commit: agentbox CLI" -``` - -### 1b. If it IS already a git repo - -This directory is already initialized (branch `master`, with an initial -commit). Just rename the branch to `main` and capture any uncommitted work: - -```bash -git branch -M main -git add -A -git commit -m "Add project metadata: license, CI, contributing, gitignore" || echo "nothing to commit" -``` - -### 1c. Create the remote repo and push (one command) - -```bash -gh repo create Liohtml/agentbox \ - --public \ - --source=. \ - --remote=origin \ - --description "One-command, Docker-isolated agentic coding sandbox: tmux + Claude Code (Agent Teams) + web Observer." \ - --push -``` - -This creates `github.com/Liohtml/agentbox`, wires it up as the `origin` -remote, and pushes the current branch in a single step. - ---- - -## 2. Fallback (if `gh repo create --push` fails partway) - -Create the empty repo, then add the remote and push manually: - -```bash -# Create the empty public repo (no push): -gh repo create Liohtml/agentbox \ - --public \ - --description "One-command, Docker-isolated agentic coding sandbox: tmux + Claude Code (Agent Teams) + web Observer." - -# Wire up origin (only if it isn't already set): -git remote add origin https://github.com/Liohtml/agentbox.git -# If origin already exists, update it instead: -# git remote set-url origin https://github.com/Liohtml/agentbox.git - -# Push and set upstream: -git push -u origin main -``` - ---- - -## 3. Verify - -```bash -git remote -v # origin → github.com/Liohtml/agentbox -git log --oneline -1 # the commit you just pushed -gh repo view Liohtml/agentbox # repo summary in the terminal -gh repo view --web # open the repo in a browser -``` - -You should see the public repo at with -`node_modules/` excluded (via `.gitignore`) and `package-lock.json` tracked. diff --git a/README.md b/README.md index fc80395..6fb3b70 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # agentbox +[![CI](https://github.com/Liohtml/agentbox/actions/workflows/ci.yml/badge.svg)](https://github.com/Liohtml/agentbox/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Node >= 22](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](package.json) + > One command spins up a Docker-isolated agentic coding sandbox — tmux + Claude Code (Agent Teams) + a live web Observer. `agentbox up` boots a disposable Ubuntu container preloaded with Node 22, Python 3, Rust, and Claude Code, bind-mounts your project **and your host Claude login** into it, and drops you into a tmux session: the Claude **lead** runs on the left, its **teammates** spawn on the right, and a live **Observer** dashboard runs in its own window — reachable from your host browser at . @@ -44,7 +48,7 @@ node bin/agentbox.mjs doctor node bin/agentbox.mjs up --workspace ~/code/myapp ``` -First run builds the image (a few minutes). Later runs reuse it and start instantly. When you exit, the container is removed (`--rm`); your work and credentials persist on the host. +First run builds the image (a few minutes). Later runs reuse it and start instantly. Your work and credentials always live on the host (bind-mounted, never baked in) — the container itself is disposable: `agentbox down` removes it explicitly, and `agentbox up` cleans up a leftover stopped container automatically before starting a fresh one. ### Install it on your PATH (recommended) @@ -60,9 +64,14 @@ agentbox # 'up' is the default command — runs in the current dir You can also invoke it without linking via `npx`: ```bash -npx agentbox up # from within the cloned repo +npx . up # from within the cloned repo ``` +> Note: the package name on npm is **`@liohtml/agentbox`** (the bare name +> `agentbox` on the registry is an unrelated npm security placeholder — don't +> `npx agentbox` outside the cloned repo). Once published, use +> `npx @liohtml/agentbox up`; the installed command is still `agentbox`. + --- ## Commands @@ -87,6 +96,8 @@ Run `agentbox help ` for details on any command. | `--cpus ` | Docker `--cpus` limit, e.g. `4`. | `CPUS` | | `--port ` | Observer web port (default `4317`). | — | | `--network ` | `docker build --network` mode, e.g. `host`. | `DOCKER_NETWORK` | +| `--gpus ` | `docker run --gpus` passthrough, e.g. `all` (Linux/NVIDIA). | — | +| `--yolo` | Lead runs with `--dangerously-skip-permissions`; teammates inherit it. Opt-in — only for workloads you trust. | — | | `--no-cache` | Force a clean image build (`up` / `rebuild`). | — | ### Env overrides @@ -113,7 +124,7 @@ WORKSPACE=~/code/myapp MEM=8g CPUS=6 agentbox up --port 5000 1. **Builds the image** from `assets/Dockerfile` if it's missing — Ubuntu 24.04 with Node 22, Python 3, Rust (rustup), git, tmux, and Claude Code + `tsx` installed globally. It runs as the non-root user **`agent`** and never contains credentials. 2. **Prepares the host bind mounts** (below), touching `~/.claude.json` first so Docker mounts a *file*, not a directory. 3. **Generates a tmux entry script** sized to your real terminal (read via `stty size`, so tmux isn't stuck at 80×24). -4. **Runs the container** (`docker run -it`, with `--memory`/`--cpus` limits and the Observer port published), launching the entry script via **`bash /agentbox/entry.sh`** — using `bash` so a missing execute bit can never block startup. If the container is already running, it reattaches instead of starting a second session. +4. **Runs the container detached** (`docker run -d`, with `--memory`/`--cpus` limits and the Observer port published), launching the entry script via **`bash /agentbox/entry.sh`** — using `bash` so a missing execute bit can never block startup. The entry script sets the tmux session up and stays alive as PID 1; agentbox then attaches your terminal via `docker exec … tmux attach`. Detaching (`Ctrl+b d`) only ends that client — the box, the lead, and all teammates keep running. If the container is already running, `up` just reattaches. Deliberately not `--rm`: if the entry script fails, the container stays around so `docker logs` can show you why; agentbox removes any stale stopped container itself before the next `up`, and `agentbox down` removes it explicitly. ### Bind mounts (host → container) @@ -124,12 +135,21 @@ WORKSPACE=~/code/myapp MEM=8g CPUS=6 agentbox up --port 5000 | `~/.claude.json` | `/home/agent/.claude.json` | Claude config file (touched first so Docker mounts a file) | | generated `entry.sh` | `/agentbox/entry.sh` | the tmux bootstrap, run via `bash` | | `assets/observer.mjs` | `/agentbox/observer.mjs` | the Observer dashboard | +| `~/.gitconfig` *(if present)* | `/home/agent/.gitconfig` **(read-only)** | git identity — agents can commit as you | +| `~/.config/gh` *(if present)* | `/home/agent/.config/gh` **(read-only)** | `gh` CLI auth — agents can open PRs | + +Additionally, three **named Docker volumes** persist tool caches across boxes +(`agentbox-npm-cache` → `~/.npm`, `agentbox-cargo-registry` → `~/.cargo/registry`, +`agentbox-pip-cache` → `~/.cache/pip`). The box itself stays disposable +(`agentbox down` / `up`'s stale-container cleanup); only downloaded packages +survive, so fresh boxes skip the cold-start tax. Remove the caches any time +with `docker volume rm`. ### The tmux layout Two windows: -- **`main`** — the Claude Code lead, launched with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. Teammates the lead spawns appear as panes here. +- **`main`** — the Claude Code lead, launched with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` and `--teammate-mode tmux` (required since Claude Code v2.1.179, where the default became `in-process` — without it teammates would not appear as panes). Teammates the lead spawns appear as panes here. - **`observer`** — the Observer web server (its own window, never a pane in `main`). | Keys | Action | @@ -152,10 +172,11 @@ The `main` window starts the Claude Code lead with Agent Teams enabled. Just tal ## Security & trust -agentbox isolates **resources** (CPU/memory caps, a throwaway `--rm` container, a non-root `agent` user) — but it is **not a security sandbox for untrusted code**. Two things to be clear about: +agentbox isolates **resources** (CPU/memory caps, a disposable container, a non-root `agent` user) — but it is **not a security sandbox for untrusted code**. Two things to be clear about: -- **Your host Claude credentials are mounted read-write.** `~/.claude` and `~/.claude.json` carry your OAuth token, the list of every project path you've opened, and full session transcripts under `~/.claude/projects`. Anything running inside the box can read or modify them. Only run workloads you trust. +- **Your host Claude credentials are mounted read-write.** `~/.claude` and `~/.claude.json` carry your OAuth token, the list of every project path you've opened, and full session transcripts under `~/.claude/projects`. Anything running inside the box can read or modify them. That includes `~/.claude/settings.json` and hooks — code in the box could plant a hook that your **host** Claude Code executes later, i.e. the mount is a container-to-host escape channel for untrusted workloads. Only run workloads you trust. (Narrowing this mount to just credentials is on the [roadmap](ROADMAP.md).) - **The container is a workbench, not a jail.** Treat it as a clean, disposable, resource-capped place for *your own* agentic workflows — not as a barrier against malicious code. (The Observer is published to `127.0.0.1` only, so its unauthenticated dashboard never leaks to the LAN.) +- **`--yolo` removes the last human checkpoint.** With it, the lead (and every teammate) runs `--dangerously-skip-permissions` — no permission prompts inside the box. That's exactly what a disposable container is for, but combined with the mounted credentials and read-only `gh` auth it means agents can do anything *you* can. Opt-in per invocation; never the default. --- @@ -206,6 +227,8 @@ Baked into the implementation and the [contract](CONTRACT.md): 3. **The Observer lives in its own window**, never a pane in `main` — Claude's tmux teammate mode only splits the lead's window into panes if that window is otherwise empty. 4. **The entry script is launched via `bash`**, so a missing execute bit can't block startup. 5. **TTY size is read with `stty size`** so tmux opens at your real terminal dimensions instead of the 80×24 default. +6. **The tmux attach client is never container PID 1.** The container runs detached and your terminal attaches via `docker exec`, so `Ctrl+b d` detaches without tearing down the sandbox. (If the attach client were PID 1, a detach would kill every agent and stop the container outright.) +7. **The lead is launched with `--teammate-mode tmux`** — since Claude Code v2.1.179 the default is `in-process`, which would silently disable the split-pane team layout agentbox is built around. --- diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..f7b9810 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,43 @@ +# Releasing + +How to cut a release of `@liohtml/agentbox`. npm publishing is intended to be +done from CI via the tag-triggered release workflow — not from a laptop. + +## Checklist + +1. **Ensure `main` is green.** CI (typecheck, tests, pack smoke test) must pass. +2. **Bump the version** in `package.json` (semver) and update the lockfile: + + ```bash + npm version --no-git-tag-version + ``` + +3. **Update `CHANGELOG.md`:** move entries from *Unreleased* into a new + `## [x.y.z] — YYYY-MM-DD` section. +4. **Commit and push** the version bump + changelog on `main` (via PR). +5. **Tag and push the tag:** + + ```bash + git tag -a vX.Y.Z -m "vX.Y.Z" + git push origin vX.Y.Z + ``` + +6. The `release.yml` workflow runs on the `v*` tag: it verifies the tag matches + `package.json`'s version, re-runs typecheck/test, runs the `npm pack` + tarball smoke test, then publishes to npm with + `--provenance --access public`. Any of those failing blocks the publish. +7. **Create a GitHub Release** from the tag, pasting the changelog section. + +## npm notes + +- The package is published **scoped** as `@liohtml/agentbox` (the bare name + `agentbox` is an npm security placeholder and cannot be claimed). The + installed command is still `agentbox`. +- Publishing authenticates with the `NPM_TOKEN` repository secret + (`NODE_AUTH_TOKEN` in the workflow), plus `id-token: write` for provenance. + Migrating to npm Trusted Publishing (OIDC, no long-lived token) is a good + follow-up but isn't configured yet — don't assume it's active. +- `prepublishOnly` re-runs typecheck + tests as a local safety net. +- Dependency caching is deliberately **off** in the release workflow (unlike + CI): a compromised or poisoned npm/GitHub Actions cache must never be able + to influence what gets published. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a5335f9 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,66 @@ +# Roadmap + +Prioritized improvement backlog, curated from ecosystem research +(comparable sandboxes: textcortex/claude-code-sandbox, claude-pod, ai-pod, +claudeman; Anthropic's devcontainer reference; Agent Teams docs, 2026-07). +Ordered by impact-to-effort. PRs welcome — please open an issue first for +anything marked M/L effort. + +## Done + +1. ✅ **Force split-pane teammate mode** — the lead launches with + `--teammate-mode tmux` (since Claude Code v2.1.179 the default is + `in-process`, which silently disabled agentbox's signature layout). +2. ✅ **`--yolo` flag** — opt-in `--dangerously-skip-permissions` for the + lead (teammates inherit it). Off by default; see README → Security. +3. ✅ **Persistent tool-cache volumes** — named volumes for `~/.npm`, + `~/.cargo/registry`, and pip's cache. The box itself stays disposable + (`down` / `up`'s stale-container cleanup). +4. ✅ **Git identity + `gh` auth forwarding** — `~/.gitconfig` and + `~/.config/gh` are mounted read-only when present; `gh` is installed in + the image. Borrow host identity, never store it. +5. ✅ **ARM64 / Apple Silicon check** — `doctor` warns on image/host arch + mismatch (qemu emulation); `--gpus` passthrough shipped alongside. + +## Next (medium effort) + +6. **Named boxes / one container per project** *(M, high)* — derive + container name, tmux session, and Observer port from the workspace path + (plus `--name` override) so two projects never share/steal one box. + Removes the biggest documented footgun. +7. **Credential isolation** *(M, high)* — mount only + `.credentials.json` + a minimal `.claude.json` into a per-box dir + instead of all of `~/.claude` (which carries transcripts and every + project path). Also fixes the Observer showing other projects' + sessions. +8. **Per-project config** *(M, high)* — optional `agentbox.json` (extra + apt packages, setup commands, env, mounts, resource defaults) or a + custom Dockerfile `FROM agentbox:latest`. Explicitly *not* full + devcontainer.json compatibility. +9. **Session git-branch safety net** *(M, med-high)* — opt-in: create + `agentbox/` branch on `up`, print a diff summary on `down`. +10. **Podman support** *(M, med)* — detect `podman` when `docker` is + absent; rootless-by-default strengthens the isolation story. +11. **Headless mode: `agentbox run ""`** *(M, med)* — run + `claude -p` in the box without tmux/TTY, stream output, exit with + Claude's status. Makes agentbox scriptable for cron/CI. + +## Later + +12. **Opt-in egress firewall (`--restrict-network`)** *(M-L, med-high)* — + port Anthropic's devcontainer `init-firewall.sh` (default-deny outbound, + allowlist api.anthropic.com/npm/GitHub; `NET_ADMIN` for init, then drop). +13. **Observer: Agent-Teams hook feed** *(M, med)* — register + `TeammateIdle`/`TaskCreated`/`TaskCompleted` hooks that POST to the + Observer for an authoritative live task board instead of inferred + JSONL activity. +14. **`doctor`/`down` polish** *(S, low-med)* — auto-pick the next free + Observer port on `up` (pairs with named boxes, #6); `--platform` + pass-through for cross-arch builds. + +## Non-goals + +- Full devcontainer.json compatibility (enterprise-bloat path). +- Being a security jail — see SECURITY.md; the egress firewall is + hardening, not a sandbox guarantee. +- Baking credentials or per-user state into the image. Ever. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0ba666c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,45 @@ +# Security Policy + +## Supported versions + +Only the latest release (and `main`) receive security fixes. + +## Reporting a vulnerability + +Please report vulnerabilities **privately** via +[GitHub Security Advisories](https://github.com/Liohtml/agentbox/security/advisories/new) +— do not open a public issue for security-sensitive reports. You should +receive a response within a few days. + +## Threat model — read before reporting + +agentbox is a **workbench, not a jail**. By design: + +- Your host `~/.claude` directory and `~/.claude.json` are bind-mounted + **read-write** into the container. They contain your OAuth token, the list + of project paths you've opened, and full session transcripts. Anything + running inside the box can read or modify them — including + `~/.claude/settings.json` and hooks, which your **host** Claude Code + executes. In other words, for untrusted workloads this mount is a + container-to-host escape channel. Only run workloads you trust. + (Narrowing the mount to just credentials is on the roadmap.) +- The container isolates *resources* (CPU/memory caps, a disposable + container, non-root `agent` user) — it is **not** a security boundary + against malicious code. +- `~/.gitconfig` and `~/.config/gh` are bind-mounted **read-only** when + present so agents can commit and open PRs as you. Read-only stops the + container from *modifying* them, but anything inside can still *read* + your `gh` token and use it — same trust model as the Claude credentials. +- `--yolo` runs the lead (and every teammate) with + `--dangerously-skip-permissions` — no human checkpoint before an agent + acts. Combined with the mounts above, that means an agent can do anything + you can. Opt-in per invocation; never the default. +- The Observer dashboard is unauthenticated by design; its port is published + to `127.0.0.1` **only** and must never be exposed to the LAN. + +Reports that reduce to "code inside the container can access the mounted +credentials" describe documented, intended behavior. Reports we absolutely +want: anything that breaks the documented guarantees above — e.g. the +Observer becoming reachable from non-loopback interfaces, container escape +paths agentbox introduces, injection via agentbox's own CLI/entry-script +generation, or credential exposure *beyond* the documented mounts. diff --git a/assets/Dockerfile b/assets/Dockerfile index 04e45ef..a1334f9 100644 --- a/assets/Dockerfile +++ b/assets/Dockerfile @@ -33,9 +33,22 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && rm -rf /var/lib/apt/lists/* \ && node --version && npm --version +# ── GitHub CLI (auth is NEVER baked in — ~/.config/gh is bind-mounted ro) ───── +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update && apt-get install -y --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* \ + && gh --version + # ── Global npm tools: Claude Code + tsx ─────────────────────────────────────── -# Pinned to global prefix; no auth baked in. -RUN npm install -g @anthropic-ai/claude-code tsx \ +# No auth baked in. Pinned defaults for reproducible builds; override for an +# intentional upgrade: +# docker build --build-arg CLAUDE_CODE_VERSION=2.1.221 --build-arg TSX_VERSION=4.24.0 … +ARG CLAUDE_CODE_VERSION=2.1.220 +ARG TSX_VERSION=4.23.1 +RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" "tsx@${TSX_VERSION}" \ && npm cache clean --force # ── Non-root user `agent` ───────────────────────────────────────────────────── @@ -44,6 +57,10 @@ RUN npm install -g @anthropic-ai/claude-code tsx \ RUN userdel -r ubuntu 2>/dev/null || true \ && useradd --create-home --shell /bin/bash --uid 1000 agent \ && mkdir -p /agentbox /workspace \ + # Pre-create the cache-volume mount points OWNED BY agent — a named + # volume materializing a missing dir would otherwise be root-owned and + # break npm/cargo/pip inside the box. + && mkdir -p /home/agent/.npm /home/agent/.cargo/registry /home/agent/.cache/pip /home/agent/.config \ && chown -R agent:agent /home/agent /agentbox /workspace \ && chmod 755 /agentbox diff --git a/assets/observer.mjs b/assets/observer.mjs index acf518a..8885f2e 100644 --- a/assets/observer.mjs +++ b/assets/observer.mjs @@ -10,10 +10,11 @@ * It surfaces live agent-team activity by tailing the Claude projects dir. */ import { createServer } from "node:http"; -import { readdirSync, readFileSync, statSync, existsSync } from "node:fs"; +import { readdirSync, statSync, existsSync } from "node:fs"; import { join } from "node:path"; -const PORT = Number(process.env.OBSERVER_PORT || 4317); +const RAW_PORT = Number(process.env.OBSERVER_PORT || 4317); +const PORT = Number.isInteger(RAW_PORT) && RAW_PORT > 0 && RAW_PORT <= 65535 ? RAW_PORT : 4317; const HOST = "0.0.0.0"; const PROJECTS_DIR = process.env.CLAUDE_PROJECTS_DIR || "/home/agent/.claude/projects"; @@ -49,15 +50,6 @@ function collectSessions() { return out.slice(0, 50); } -function tail(file, n) { - try { - const lines = readFileSync(file, "utf8").trim().split("\n"); - return lines.slice(-n); - } catch { - return []; - } -} - const PAGE = ` agentbox observer @@ -81,6 +73,10 @@ const PAGE = ` loading…