From 289c0e098d974538de59cb16d820cb464b937ea4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:24:26 +0000 Subject: [PATCH 01/26] Add v2 revamp requirements doc Requirements for the aibox rewrite: always-yolo single container per project, persistent background lifecycle, shared aibox-home volume, built-in backup/restore, wildcard *.aibox.localhost dev-server proxy, and a standalone v1 data migration script. Records settled decisions, the reduced command surface, deletions from v1, and acceptance criteria. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- REVAMP.md | 374 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 REVAMP.md diff --git a/REVAMP.md b/REVAMP.md new file mode 100644 index 0000000..b2321fd --- /dev/null +++ b/REVAMP.md @@ -0,0 +1,374 @@ +# aibox v2 — Revamp Requirements + +Requirements for rewriting aibox around how it is actually used: one trusted +machine, one user, Claude Code in yolo mode, containers that are cheap to +enter and impossible to lose data in. + +This document is the spec for the rewrite. It records the decisions already +made (with rationale), the exact command surface, the architecture, and what +gets deleted. A separate one-time migration script (also specced here) moves +existing data into the new layout. + +--- + +## 1. Why + +The current `bin/aibox` is 2,379 lines, ~20 commands, and optimizes for +safety features and isolation modes that go unused, while getting the one +thing that matters wrong: **data durability**. + +Concrete problems in the current implementation: + +- **Destructive lifecycle by default.** When the last `claude`/`shell` + session exits, the script silently runs `docker compose down` + (`bin/aibox:1154-1170`). The container is *removed*, not stopped — every + apt package, global npm install, and any file outside a mounted volume is + destroyed. This has caused real data loss. A second destructive path: + running `aibox claude` in a different mode than the container was started + with (`--yolo` vs `--safe`) also `down`s the running container + (`bin/aibox:1447-1453`). +- **Fragmented session history.** Auth/session state lives in a volume named + per image — `aibox-auth-` (`bin/aibox:1068`). Change the image tag + and you get a fresh volume: new login, empty session list, old sessions + stranded in the previous volume. Multiple such volumes now exist, each + holding a slice of history. +- **No first-class backup.** Backups currently depend on an external + hand-rolled script. +- **Port forwarding is manual and clunky.** Each forwarded port is a + `alpine/socat` sidecar container created by hand (`aibox port-forward`). + In practice this was abandoned in favor of asking Claude to open a + Cloudflare quick tunnel — workable, but slow, public, and not ideal. +- **Unused surface area.** Safe mode + domain-allowlist firewall, restricted + sudo, `--copy` / `--worktree` isolation, named instances, `--repo` clone + mode, compose file generation, `init` / WebStorm integration, `clean`, + `nuke`, `doctor`, `volumes`, `disk` — none of it is used. It exists to + serve hypothetical users, and it is where the 2,400 lines went. + +## 2. Product decisions (settled) + +These were decided explicitly; the rewrite must not relitigate them. + +| # | Decision | Choice | Rationale | +|---|----------|--------|-----------| +| D1 | Language / distribution | Single bash file, published to npm as `aibox-cli` (same as today) | New scope is ~500 lines, not 2,400; zero runtime deps beyond Docker | +| D2 | Security posture | Always yolo. No safe mode, no firewall, no restricted sudo | Isolation comes from the container boundary itself; the modes were never used | +| D3 | Container lifecycle | Container **keeps running in the background** when the last session exits. `aibox stop` stops it explicitly. It is only ever *removed* when the image changes (and then only recreated, never left absent) | Instant re-attach; idle containers cost ~nothing; kills the data-loss footgun | +| D4 | Instances | Exactly one container per project directory; multiple terminals just `exec` into the same container. No named instances | Matches real usage | +| D5 | Session/home storage | One **global** named volume `aibox-home` mounted at `/home/aibox`, shared by all project containers | One login, one session history, one thing to back up; survives image changes by construction | +| D6 | Project mount | Bind-mount the project directory at **the same absolute path as on the host** (today's default-mode behavior, `bin/aibox:475`) | Claude session keys are derived from cwd — keeping the path keeps every existing session valid with zero migration | +| D7 | Backup | Built-in `aibox backup` / `aibox restore` — clean and simple, must actually work | Replaces the external script | +| D8 | Migration of old data | A **separate standalone script** (not part of the CLI) that merges both live `aibox-auth-*` volumes **and** old backup folders into the new `aibox-home` volume | One-time operation; keeps the CLI clean | +| D9 | Dev-server access | Host-side reverse proxy with wildcard subdomains: `http://..aibox.localhost` → container port. Replaces port-forward sidecars and ad-hoc Cloudflare tunnels for local use | `*.localhost` resolves to loopback natively in all modern browsers — zero setup, no sudo, no dnsmasq. `/etc/hosts` can't do wildcards, so no hosts-file step | +| D10 | Base image | `node:${node_version}-bookworm` (Debian 12), `node_version` configurable in `~/.aibox/config`, default `22` | Debian-based official node images are the standard container base; glibc means nothing is uninstallable. Node version is a one-line config change | +| D11 | Command surface | Exactly the commands in §3. Everything else is deleted | See §8 for the deletion list | + +## 3. Command surface + +``` +aibox # same as `aibox claude` +aibox claude [args...] # ensure image/container/proxy, then run claude (yolo) inside; extra args pass through (--resume, -c, etc.) +aibox shell [cmd...] # zsh in the container, or run a one-off command +aibox stop [--all] # stop this project's container (--all: every aibox container + proxy). Never deletes anything +aibox status # all aibox containers: project, state, uptime, image; proxy URLs; volume size +aibox backup [dir] # snapshot aibox-home volume to a tar.gz on the host +aibox restore # restore a backup into aibox-home (with automatic pre-restore safety backup) +aibox update # update CLI (npm), rebuild image if needed, mark containers for recreation +aibox version # CLI version, image version, docker info +aibox help +``` + +Rules: + +- Unknown command → help + exit 1. No interactive prompts anywhere except + destructive confirmations (`restore`) and first-run niceties. +- `aibox claude` and `aibox shell` are the only commands that create things; + everything they need (image, network, volume, container, proxy) is created + idempotently on demand. There is no `up`/`build`/`init`. +- Keep v1's non-blocking update notice (`bin/aibox:2304-2326`): a cached, + 24h-throttled npm version check that prints a one-line hint when a newer + `aibox-cli` exists. It must never block or slow a command. +- Every command must be safe to run concurrently from multiple terminals + (two tabs running `aibox claude` simultaneously on the same project must + not race the container creation — use a lock or tolerate-exists creation). + +## 4. Architecture + +### 4.1 Naming and identity + +- Project identity = absolute path of the project directory (the cwd where + `aibox` is invoked; symlinks resolved). +- Container name: `aibox--` where `` is the sanitized + directory basename and `` is the first 6 hex chars of the SHA-256 of + the absolute path. Two directories both named `app` never collide. +- Proxy hostname label: `` (see §5); on slug collision between two + *running* projects, the later one uses `-`. `aibox status` + always shows the canonical URL. +- Docker network: `aibox` (bridge), shared by all project containers and the + proxy. +- Volume: `aibox-home`, mounted at `/home/aibox` in every project container. +- Image: `aibox:` (e.g. `aibox:2.0.0`), so image staleness is + detectable by tag comparison alone. + +### 4.2 Container spec + +- Plain `docker run` / `docker exec` — **no docker compose**, no generated + YAML. One container needs no orchestrator, and compose's `down` semantics + were part of the original footgun. +- Mounts: + - `aibox-home:/home/aibox` + - `:` (bind, rw), workdir = that path +- `CLAUDE_CONFIG_DIR=/home/aibox/.claude` (unchanged from v1, so existing + `.claude` contents drop in as-is). +- User `aibox` (uid 1000), passwordless sudo, login shell zsh. Note: + `node:*-bookworm` ships a `node` user already occupying uid 1000 — the + Dockerfile must remove it (`userdel -r node`) before creating `aibox`. +- `--add-host host.docker.internal:host-gateway` so the container can reach + services on the Mac (kept from v1). +- `docker exec` forwards `TERM`, `COLORTERM`, `LANG`, and any `ANTHROPIC_*` + vars set on the host (kept from v1; the IDE-integration env plumbing is + not kept). +- Long-running init process: `sleep infinity` (or equivalent) so the + container stays up independent of sessions. `--restart unless-stopped` so + it survives Docker/Colima restarts. No healthcheck needed. +- Sessions are `docker exec -it` — a session ending never affects the + container. + +### 4.3 Lifecycle + +``` +aibox claude/shell: + docker running? → if colima installed but stopped: colima start; else error with install hint + image aibox: exists? → build if missing (first run or post-update) + container exists? + ├─ no → create (docker run -d) + ├─ yes, stopped → docker start + └─ yes, running → (nothing) + container image ≠ aibox:? + → if no active exec sessions: recreate (rm + run; aibox-home and project + bind survive by construction; warn that apt-installed packages reset) + → if sessions active: warn and continue on the old image + proxy running? → start if not (§5) + docker exec -it ... +``` + +- `claude` is invoked with `--dangerously-skip-permissions` plus any + passthrough args. +- **Nothing happens on session exit.** No idle-detection, no auto-stop, no + down. The container idles at ~zero CPU until the next attach or an + explicit `aibox stop`. +- `aibox stop` = `docker stop` only. A stopped container preserves its + writable layer (apt installs etc.); next `aibox claude` starts it again in + ~1s. +- The only code path that ever runs `docker rm` on a project container is + image-change recreation, which immediately recreates it. + +### 4.4 Image + +Dockerfile is embedded in the script (heredoc, as today) and written to +`~/.aibox/Dockerfile` at build time: + +- `FROM node:${node_version}-bookworm` — ships git, python3, make/g++, + openssl etc. out of the box. +- apt: `zsh sudo ripgrep fzf jq less procps curl` (keep this list short — + the container persists, so Claude apt-installs anything else once and it + sticks). +- Claude Code installed via the native installer into `/home/aibox/.local` + **at first container start** (entrypoint checks, installs if missing) — + i.e. the binary lives in the `aibox-home` volume, so `claude update` + self-updates persist across container recreation and image rebuilds, and + all projects share one install. This is a hard constraint, not a + preference: v1 bakes the installer into the image's home dir + (`bin/aibox:556`), but in v2 the `aibox-home` volume mounts over all of + `/home/aibox`, shadowing anything the image put there — so nothing may be + installed into the home directory at image-build time. +- The entrypoint also fixes ownership of `/home/aibox` on start (migrated + v1 volumes can contain root-owned files; v1 did the same for `.claude`, + `bin/aibox:572`). +- Optional user extension: if `~/.aibox/Dockerfile.extra` exists, its + contents are appended to the generated Dockerfile before build. This is + the supported way to make custom tooling survive image-change recreations. + +### 4.5 State on the host + +``` +~/.aibox/ + config # key=value, see §7 + Dockerfile # generated at build (informational; regenerated each build) + Dockerfile.extra # optional, user-authored + Caddyfile # generated proxy config (§5) +~/aibox-backups/ # default backup destination (§6) +``` + +`~/.config/aibox` (v1) is left untouched; the migration script may read it, +the new CLI never does. + +## 5. Dev-server proxy + +Goal: Claude starts `vite` / `next dev` / `wrangler dev` on any port inside +the container, and the host browser reaches it immediately at a stable URL — +no commands, no restarts, no sidecars, no tunnels. + +- One shared container `aibox-proxy` (image: `caddy:2-alpine`), on the + `aibox` network, publishing `127.0.0.1:80->80`. Started lazily by the + first `aibox claude`/`shell`; stopped by `aibox stop --all`. +- Routing rule: request `Host` of the form `..aibox.localhost` + reverse-proxies to `aibox--:` over the Docker network. + Because the proxy reaches containers directly on the internal network, **no + container ports are ever published** — any port works dynamically, ports + the app opens after container start included. +- Config: a small generated Caddyfile (`~/.aibox/Caddyfile`) mounted into + the proxy. When a project container is created or renamed, the CLI + regenerates the file and reloads the proxy (`caddy reload` via exec — + ~instant, no dropped connections). +- WebSockets must work (Caddy's `reverse_proxy` handles them by default) — + vite HMR is the primary consumer. +- `aibox status` and container-start output print the concrete base URL, + e.g. `http://5173.myapp.aibox.localhost`. +- Inside the container, set an env var (e.g. `AIBOX_URL_BASE=myapp.aibox.localhost`) + so Claude can tell the user the right URL for whatever port it just opened. +- If port 80 on the host is taken, fall back to `proxy_port` from config + (default fallback 8080) and include the port in printed URLs + (`http://5173.myapp.aibox.localhost:8080`). +- Non-goals: HTTPS (plain http on loopback is fine), public sharing + (Cloudflare tunnels remain possible manually; a built-in `aibox share` is + a future idea, §9), and CLI tools that don't respect `*.localhost` DNS + (curl needs `--resolve`; documented, not solved — `/etc/hosts` has no + wildcard support so there is no standard hosts-file fix). + +## 6. Backup and restore + +Design principle: a backup is one portable file; restore is dumb and +predictable; nothing in either path can delete data it didn't just save. + +- `aibox backup [dir]` + - Snapshots the **entire `aibox-home` volume** (sessions, `.claude.json`, + credentials, shell history, claude binary, npm globals) to + `/aibox-home--.tar.gz`. Default dir: + `~/aibox-backups` (overridable in config). + - Implemented as a throwaway helper container mounting the volume + **read-only** and streaming `tar` to the host. Safe to run while + containers are up; source is never written to. + - Prints archive path + size; keeps every backup (no rotation in v2 — the + user deletes old ones; a `backup_keep` config knob is a future idea). +- `aibox restore ` + - `` is an archive produced by `aibox backup`. + - Steps: (1) require confirmation, (2) automatically run a safety backup + of the current volume first, (3) stop containers using the volume, + (4) wipe the volume and extract the archive into it, (5) restart what + was running. + - Restore is **replace**, not merge — merging heterogeneous histories is + the migration script's job (§6.1). This keeps restore trivially + predictable: after restore, the volume equals the backup, and the state + it replaced is itself a backup. + +### 6.1 Migration script (separate, one-time) + +`scripts/migrate-to-v2.sh` — lives in the repo, runs standalone (not part of +the npm bin, never called by the CLI). + +Sources, all merged into the (possibly already-populated) `aibox-home` +volume: + +1. **Every `aibox-auth-*` Docker volume** found on the machine (running or + stopped v1 containers don't matter; sources are only ever read). +2. **Old backup folders** produced by the user's existing external backup + script, passed as arguments: `migrate-to-v2.sh [backup-dir ...]`. + +Merge rules: + +- Everything except `.claude.json`: copy the **entire volume tree + no-clobber** — don't enumerate directories (v1 volumes contain at least + `projects/`, `todos/`, `session-env/`, `file-history/`, + `shell-snapshots/`, per `bin/aibox:2047`, and the set may vary by Claude + Code version). Sessions are one file per UUID, so a plain union is + correct. Count copied vs skipped and report. +- `.claude.json`: start from the newest copy (by mtime), then merge the + `projects` map keys from every other copy via `jq` (newest wins per key). + OAuth/credentials come from the newest copy only. +- Session-key paths: default-mode v1 sessions are keyed by host paths (D6) + and need **no remapping**. Old `--copy`/`--worktree` sessions keyed under + `/workspace/...` are copied as-is (harmless), with an optional + `--map /workspace/foo=/Users/me/foo` flag to rekey them if ever wanted. +- Idempotent: running it twice changes nothing the second time. +- Strictly read-only on all sources. It never stops, deletes, or edits v1 + containers, volumes, or backup folders — the user deletes those manually + after verifying (`docker volume rm`, etc.; the script prints the exact + cleanup commands as its final output but does not run them). + +## 7. Config + +`~/.aibox/config`, `key=value`, all optional: + +``` +node_version=22 # base image tag: node:-bookworm +proxy_port=80 # host port for the dev-server proxy +backup_dir=~/aibox-backups +``` + +No per-project config file in v2 (v1's `.aibox` is ignored). Changing +`node_version` takes effect via `aibox update` (image rebuild → containers +recreate on next start). + +## 8. Deleted from v1 + +Removed entirely, with no deprecation shims — v2 is a clean break +(major-version bump of `aibox-cli`): + +- Commands: `up`, `down`, `build`, `init`, `port-forward`, `volumes`, + `disk`, `clean`, `nuke`, `doctor`. +- Flags: `-n/--name`, `-r/--repo`, `-b/--branch`, `-c/--copy`, + `-w/--worktree`, `-y/--yolo` (now the only behavior), `-s/--safe`, + `-i/--image`, `--all`/`--clean` on `down`. +- Mechanisms: compose file generation (`compose.dev.yaml`), socat + port-forward sidecars, network firewall + `AIBOX_EXTRA_DOMAINS`, + restricted sudo, sensitive-file detection, WebStorm/JetBrains config + generation, per-image `aibox-auth-*` volumes, auto-`down` on last session + exit, Colima/Docker auto-*install* (auto-*start* of an installed Colima + stays; installation becomes a printed one-liner hint). + +Target size: **~500 lines** of bash. If an addition pushes past that, +something from this spec is being over-built. + +## 9. Non-goals / future ideas + +Not in v2; recorded so they aren't accidentally half-built: + +- `aibox share ` — public URL via `cloudflared` quick tunnel (the + current manual workflow, automated). +- Backup rotation (`backup_keep=N`). +- Linux hosts as a first-class target (nothing should actively break, but + macOS + Colima/Docker Desktop/OrbStack is what gets tested). +- Multiple containers per project, non-Claude agent presets, Homebrew tap + refresh. + +## 10. Acceptance criteria + +The rewrite is done when all of these hold: + +1. `cd proj && aibox` on a fresh machine (Docker present): builds image, + creates volume/network/container/proxy, lands in a yolo Claude session. +2. Exit Claude, run `aibox claude` again → re-attached in under a second; + `apt install imagemagick` from a previous session is still installed. +3. Two terminal tabs, same project: both `aibox claude` concurrently → two + sessions, one container, no race errors. +4. `aibox stop && aibox claude` → container starts again with all state + (including apt layer) intact. +5. Claude runs `npx vite` (port 5173) inside the container → + `http://5173..aibox.localhost` serves it in the host browser with + working HMR, with no port ever having been published or configured. +6. `aibox update` after a version bump → next `aibox claude` recreates the + container on the new image; all Claude sessions, login, and `claude` + binary version are unchanged (home volume); a warning notes the apt + layer reset. +7. `aibox backup` while a session is running → single `.tar.gz`; + `aibox restore` of it onto a wiped volume reproduces login + full + session list (`claude --resume` shows history). +8. `scripts/migrate-to-v2.sh ` on a machine with existing + `aibox-auth-*` volumes → one `aibox-home` volume where `claude --resume` + in a previously-used project lists sessions that originated from *both* + the live volumes and the old backup folders; running it again reports + zero new files. +9. Deleting any project container (`docker rm -f`) loses no Claude data — + next `aibox claude` recreates it and every session is still there. +10. `grep -c '' bin/aibox` is in the ~500-line ballpark, and no command + outside §3 exists. From 018b8e1779ab5ffdf8f88c94b088aa371a43507c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:27:08 +0000 Subject: [PATCH 02/26] Fix v1 facts in revamp doc after full-script audit Corrects the command count (16, not ~20), documents the mode-switch down path and project-dir mutation by up/init, the greedy global flag parser, missing host dotfile mounts, and compose-YAML-via-stdin. Adds v2 requirements derived from the audit: verbatim claude arg passthrough, TTY only when attached, docker build --pull, and a persistent-home note for git identity. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- REVAMP.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/REVAMP.md b/REVAMP.md index b2321fd..9de65f1 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -13,7 +13,7 @@ existing data into the new layout. ## 1. Why -The current `bin/aibox` is 2,379 lines, ~20 commands, and optimizes for +The current `bin/aibox` is 2,379 lines, 16 commands, and optimizes for safety features and isolation modes that go unused, while getting the one thing that matters wrong: **data durability**. @@ -40,9 +40,18 @@ Concrete problems in the current implementation: Cloudflare quick tunnel — workable, but slow, public, and not ideal. - **Unused surface area.** Safe mode + domain-allowlist firewall, restricted sudo, `--copy` / `--worktree` isolation, named instances, `--repo` clone - mode, compose file generation, `init` / WebStorm integration, `clean`, + mode, compose orchestration, `init` / WebStorm integration, `clean`, `nuke`, `doctor`, `volumes`, `disk` — none of it is used. It exists to serve hypothetical users, and it is where the 2,400 lines went. +- **It writes into your project.** Plain `aibox up` auto-runs init when + `compose.dev.yaml` is missing (`bin/aibox:1314-1327`), dropping + `compose.dev.yaml`, `.aibox`, and `.idea/workspace.xml` into the project + and editing its `.gitignore`. +- **Greedy global flag parsing.** All argv is scanned for aibox flags before + dispatch (`bin/aibox:251-325`), so `aibox claude -c` becomes aibox's + `--copy` instead of Claude's `--continue`; interactive prompts plus + unconditional `docker exec -it` also make non-interactive use + (`claude -p` in a pipe) impossible. ## 2. Product decisions (settled) @@ -81,6 +90,11 @@ Rules: - Unknown command → help + exit 1. No interactive prompts anywhere except destructive confirmations (`restore`) and first-run niceties. +- Everything after `claude` passes through to claude **verbatim** — v2 has + no global flags, so nothing gets swallowed the way v1's parser ate `-c` + (`bin/aibox:251-325`). +- Allocate a TTY (`docker exec -it`) only when stdin is a terminal, so + `aibox claude -p "..."` and piped/scripted use work; v1 hard-coded `-it`. - `aibox claude` and `aibox shell` are the only commands that create things; everything they need (image, network, volume, container, proxy) is created idempotently on demand. There is no `up`/`build`/`init`. @@ -127,6 +141,9 @@ Rules: - `docker exec` forwards `TERM`, `COLORTERM`, `LANG`, and any `ANTHROPIC_*` vars set on the host (kept from v1; the IDE-integration env plumbing is not kept). +- No host dotfiles are mounted (`.gitconfig`, `~/.ssh` — same as v1). This + is fine in v2 precisely because the home volume persists: configure git + identity or SSH keys once inside the container and they stick forever. - Long-running init process: `sleep infinity` (or equivalent) so the container stays up independent of sessions. `--restart unless-stopped` so it survives Docker/Colima restarts. No healthcheck needed. @@ -187,6 +204,9 @@ Dockerfile is embedded in the script (heredoc, as today) and written to - Optional user extension: if `~/.aibox/Dockerfile.extra` exists, its contents are appended to the generated Dockerfile before build. This is the supported way to make custom tooling survive image-change recreations. +- Build with `docker build --pull` so the `node:*-bookworm` base actually + refreshes on rebuilds (v1 never pulled, so its base only updated by + accident). ### 4.5 State on the host @@ -319,12 +339,16 @@ Removed entirely, with no deprecation shims — v2 is a clean break - Flags: `-n/--name`, `-r/--repo`, `-b/--branch`, `-c/--copy`, `-w/--worktree`, `-y/--yolo` (now the only behavior), `-s/--safe`, `-i/--image`, `--all`/`--clean` on `down`. -- Mechanisms: compose file generation (`compose.dev.yaml`), socat +- Mechanisms: compose orchestration (v1 pipes generated YAML into + `docker compose -f -`) and the JetBrains-facing `compose.dev.yaml`, socat port-forward sidecars, network firewall + `AIBOX_EXTRA_DOMAINS`, restricted sudo, sensitive-file detection, WebStorm/JetBrains config - generation, per-image `aibox-auth-*` volumes, auto-`down` on last session - exit, Colima/Docker auto-*install* (auto-*start* of an installed Colima - stays; installation becomes a printed one-liner hint). + generation, IDE-integration plumbing (`~/.claude/ide` ro-mount and + `ENABLE_IDE_INTEGRATION`/`CLAUDE_CODE_SSE_PORT` forwarding), per-image + `aibox-auth-*` volumes, auto-`down` on last session exit, mode-switch + `down`, Colima/Docker auto-*install* (auto-*start* of an installed + Colima/OrbStack/Docker Desktop stays; installation becomes a printed + one-liner hint). Target size: **~500 lines** of bash. If an addition pushes past that, something from this spec is being over-built. From d62dd71283c40069f9c66f09fbd485ba38bc3194 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:36:32 +0000 Subject: [PATCH 03/26] Fix external facts in revamp doc after verification Corrects browser support for *.localhost (Safari needs macOS 26 Tahoe), makes the node default track Active LTS (24; 22 is in maintenance, 20 EOL), pins the exact Claude native-installer paths and confirms .claude.json lands inside CLAUDE_CONFIG_DIR, documents the real macOS low-port publishing mechanics per runtime, and adds an empirically tested Caddyfile for the wildcard proxy routing plus the tar-while-running backup caveat. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- REVAMP.md | 63 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/REVAMP.md b/REVAMP.md index 9de65f1..1301cab 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -67,8 +67,8 @@ These were decided explicitly; the rewrite must not relitigate them. | D6 | Project mount | Bind-mount the project directory at **the same absolute path as on the host** (today's default-mode behavior, `bin/aibox:475`) | Claude session keys are derived from cwd — keeping the path keeps every existing session valid with zero migration | | D7 | Backup | Built-in `aibox backup` / `aibox restore` — clean and simple, must actually work | Replaces the external script | | D8 | Migration of old data | A **separate standalone script** (not part of the CLI) that merges both live `aibox-auth-*` volumes **and** old backup folders into the new `aibox-home` volume | One-time operation; keeps the CLI clean | -| D9 | Dev-server access | Host-side reverse proxy with wildcard subdomains: `http://..aibox.localhost` → container port. Replaces port-forward sidecars and ad-hoc Cloudflare tunnels for local use | `*.localhost` resolves to loopback natively in all modern browsers — zero setup, no sudo, no dnsmasq. `/etc/hosts` can't do wildcards, so no hosts-file step | -| D10 | Base image | `node:${node_version}-bookworm` (Debian 12), `node_version` configurable in `~/.aibox/config`, default `22` | Debian-based official node images are the standard container base; glibc means nothing is uninstallable. Node version is a one-line config change | +| D9 | Dev-server access | Host-side reverse proxy with wildcard subdomains: `http://..aibox.localhost` → container port. Replaces port-forward sidecars and ad-hoc Cloudflare tunnels for local use | `*.localhost` (multi-level included) resolves to loopback natively in Chrome/Edge and Firefox 84+ with zero setup, no sudo, no dnsmasq; Safari only gained this on macOS 26 Tahoe (WebKit bug 160504). CLI tools using the system resolver (curl) don't resolve it — documented workaround, not solved. `/etc/hosts` can't do wildcards, so there is no hosts-file step | +| D10 | Base image | `node:${node_version}-bookworm` (Debian 12), `node_version` configurable in `~/.aibox/config`, default = current Active LTS (`24` as of mid-2026; Node 22 entered maintenance Oct 2025, Node 20 is EOL) | Debian-based official node images are the standard container base; glibc means nothing is uninstallable. Node version is a one-line config change | | D11 | Command surface | Exactly the commands in §3. Everything else is deleted | See §8 for the deletion list | ## 3. Command surface @@ -184,16 +184,23 @@ aibox claude/shell: Dockerfile is embedded in the script (heredoc, as today) and written to `~/.aibox/Dockerfile` at build time: -- `FROM node:${node_version}-bookworm` — ships git, python3, make/g++, - openssl etc. out of the box. +- `FROM node:${node_version}-bookworm` — verified to ship git, python3, + make, gcc/g++, and curl out of the box (~400 MB compressed, ~1.6 GB + uncompressed). - apt: `zsh sudo ripgrep fzf jq less procps curl` (keep this list short — the container persists, so Claude apt-installs anything else once and it sticks). -- Claude Code installed via the native installer into `/home/aibox/.local` - **at first container start** (entrypoint checks, installs if missing) — - i.e. the binary lives in the `aibox-home` volume, so `claude update` - self-updates persist across container recreation and image rebuilds, and - all projects share one install. This is a hard constraint, not a +- Claude Code installed via the native installer + (`curl -fsSL https://claude.ai/install.sh | bash`) **at first container + start** (entrypoint checks, installs if missing). The installer puts a + launcher at `~/.local/bin/claude` with versions under + `~/.local/share/claude/` — both inside the `aibox-home` volume — so + `claude update` (and the native install's background auto-updates) + persist across container recreation and image rebuilds, and all projects + share one install. Current Claude Code versions verifiably create + `.claude.json` *inside* `CLAUDE_CONFIG_DIR`, so all state lands in the + volume (very old builds handled `CLAUDE_CONFIG_DIR` inconsistently — + irrelevant here since the installer always fetches current). This is a hard constraint, not a preference: v1 bakes the installer into the image's home dir (`bin/aibox:556`), but in v2 the `aibox-home` volume mounts over all of `/home/aibox`, shadowing anything the image put there — so nothing may be @@ -239,16 +246,37 @@ no commands, no restarts, no sidecars, no tunnels. - Config: a small generated Caddyfile (`~/.aibox/Caddyfile`) mounted into the proxy. When a project container is created or renamed, the CLI regenerates the file and reloads the proxy (`caddy reload` via exec — - ~instant, no dropped connections). + graceful, in-flight connections drain rather than drop). +- The core routing needs no per-project config at all — this exact + Caddyfile was tested against `caddy:2-alpine` (v2.11) and routes + `Host: 5173.myapp.aibox.localhost` to container `aibox-myapp:5173` + (labels index right-to-left from zero): + + ``` + http://*.*.aibox.localhost { + reverse_proxy aibox-{http.request.host.labels.2}:{http.request.host.labels.3} + } + ``` + + Per-project regeneration is only needed because real container names + carry the `-` suffix (§4.1) — a `map` block from slug to full + container name, rewritten on container create/rename. - WebSockets must work (Caddy's `reverse_proxy` handles them by default) — vite HMR is the primary consumer. - `aibox status` and container-start output print the concrete base URL, e.g. `http://5173.myapp.aibox.localhost`. - Inside the container, set an env var (e.g. `AIBOX_URL_BASE=myapp.aibox.localhost`) so Claude can tell the user the right URL for whatever port it just opened. -- If port 80 on the host is taken, fall back to `proxy_port` from config - (default fallback 8080) and include the port in printed URLs - (`http://5173.myapp.aibox.localhost:8080`). +- If publishing host port 80 fails (already taken, or the runtime can't), + fall back to `proxy_port` from config (default fallback 8080) and include + the port in printed URLs (`http://5173.myapp.aibox.localhost:8080`). + Low-port caveat on macOS: binding `127.0.0.1:80` specifically needs + privileges — Docker Desktop handles it via its privileged helper, while + Colima/OrbStack emulate loopback-only publishing by binding `0.0.0.0` and + rejecting non-loopback sources (old Colima versions ignored the loopback + restriction entirely, exposing the port on the LAN — acceptable here + since everything behind it is already yolo-mode dev traffic, but worth a + line in the README). - Non-goals: HTTPS (plain http on loopback is fine), public sharing (Cloudflare tunnels remain possible manually; a built-in `aibox share` is a future idea, §9), and CLI tools that don't respect `*.localhost` DNS @@ -266,8 +294,11 @@ predictable; nothing in either path can delete data it didn't just save. `/aibox-home--.tar.gz`. Default dir: `~/aibox-backups` (overridable in config). - Implemented as a throwaway helper container mounting the volume - **read-only** and streaming `tar` to the host. Safe to run while - containers are up; source is never written to. + **read-only** and streaming `tar` to the host (the pattern Docker's own + docs recommend for volume backup). Safe to run while containers are up + in the sense that the source is never written to; a session actively + appending its `.jsonl` mid-tar may be captured mid-write, which is + acceptable for append-only session logs. - Prints archive path + size; keeps every backup (no rotation in v2 — the user deletes old ones; a `backup_keep` config knob is a future idea). - `aibox restore ` @@ -320,7 +351,7 @@ Merge rules: `~/.aibox/config`, `key=value`, all optional: ``` -node_version=22 # base image tag: node:-bookworm +node_version=24 # base image tag: node:-bookworm proxy_port=80 # host port for the dev-server proxy backup_dir=~/aibox-backups ``` From f6fb6d84de56068946f054f05e8ce90876a600c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 08:35:40 +0000 Subject: [PATCH 04/26] Rewrite aibox as v2: persistent, non-destructive sandboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of bin/aibox (2,379 → 572 lines) per REVAMP.md: - 9 commands: claude (default), shell, stop, status, backup, restore, update, version, help. Always yolo; args after claude pass through verbatim; TTY allocated only when attached so 'claude -p' works piped. - One container per project (aibox--), project bind-mounted at its host path. Exiting a session never touches the container; 'stop' preserves all state; containers are recreated only when the image changes, and never while sessions are active. - One shared home volume (aibox-home) at /home/aibox holds sessions, login, and the claude binary (installed by the entrypoint on first start so self-updates persist; image-baked installs would be shadowed by the mount). - Dev-server proxy: shared Caddy container routes http://..aibox.localhost to any container port over the Docker network - no published ports, no restarts, WebSockets included. Falls back to 8080 when host port 80 is unavailable. - Built-in backup (tar of the volume via read-only helper) and restore (confirmation + automatic pre-restore safety backup). - Image: node:-bookworm (default 24, configurable in ~/.aibox/config) + zsh/sudo/ripgrep/fzf/jq; ~/.aibox/Dockerfile.extra is appended for custom layers; built with --pull. - scripts/migrate-to-v2.sh: standalone one-time merge of all v1 aibox-auth-* volumes and old backup folders into aibox-home. No-clobber file union, newest-wins .claude.json project merge, optional --map path rekeying, idempotent, sources read-only, prints cleanup commands without running them. Removed: compose orchestration, safe mode + firewall, isolation modes, named instances, repo clone, port-forward sidecars, init/WebStorm integration, clean/nuke/doctor/volumes/disk, auto-down on session exit. Tested end-to-end against a live Docker daemon: cold start with full image build, 0.6s re-attach, proxy routing (HTTP 200 via Host header, no published ports), state across stop/start, image-change recreation idle + active-session guard, 5-way parallel invocation race, backup/ restore round-trip, and migration merge including idempotent re-run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- CONTRIBUTING.md | 2 +- README.md | 223 +--- bin/aibox | 2715 +++++++------------------------------- package.json | 4 +- scripts/migrate-to-v2.sh | 252 ++++ 5 files changed, 760 insertions(+), 2436 deletions(-) create mode 100755 scripts/migrate-to-v2.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ce2911..f53470b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,4 +36,4 @@ npm run release 1. Update `version` in `package.json` 2. Commit, then `npm run release` — CI handles the rest -Note: `AIBOX_VERSION` in `bin/aibox` is separate — it tracks the Docker image format and only needs bumping when the Dockerfile or entrypoint changes (triggers automatic image rebuild for users). +Note: the Docker image tag is derived from the CLI version (`aibox:-node`), so any release automatically rebuilds users' images and recreates their containers on next run — sessions and login live in the `aibox-home` volume and are unaffected. In a git checkout (unstamped `__CLI_VERSION__`) the tag is `aibox:dev-node`. diff --git a/README.md b/README.md index 1d5627e..47a3133 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

aibox

-

Instant Docker sandboxes for AI coding agents

+

Persistent Docker sandboxes for Claude Code

npm downloads @@ -8,224 +8,103 @@

- - -> *Skip permission prompts safely. Let agents run wild. Tear everything down when you're done.* +> *One command into a yolo-mode Claude Code sandbox. Nothing gets destroyed behind your back.* ```bash -cd myproject && aibox claude --yolo +cd myproject && aibox ``` -One command to go from bare project to fully isolated Claude Code session. Changes sync both ways, the agent stays sandboxed, tear everything down when you're done. +aibox runs Claude Code with `--dangerously-skip-permissions` inside a Docker container, so the agent can run wild while your Mac stays clean. One container per project, all sharing a single persistent home volume — one login, one session history, everything survives. ## Quickstart ```bash npm install -g aibox-cli # 1. install -cd myproject # 2. go to your project -aibox claude --yolo # 3. run +cd myproject # 2. go to your project +aibox # 3. run (builds the image on first use) ``` -## Features - -- **Zero config** — don't even need Docker installed. Detects your machine, auto-installs Colima/Docker, builds an Alpine image with Claude Code + dev tools on first run -- **Safe by default** — network firewall (allowlisted domains only), restricted sudo, sensitive file detection. `--yolo` to unlock everything -- **Full isolation** — `--copy` snapshots into a Docker volume, `--worktree` creates a git worktree. Both handle uncommitted changes, submodules, and LFS -- **Parallel agents** — run multiple named instances on the same project, each with its own container -- **Editor integration** — VS Code, Cursor, JetBrains, Windsurf — set startup command to `aibox claude --yolo` -- **Clone and run** — `--repo ` clones any git repo and launches an agent session -- **Not just Claude** — container ships with Node.js, python3, git, ripgrep, build tools. Run aider, codex, or anything else -- **Just a shell script** — no daemon, no runtime dependencies, easy to fork - -## Install - -```bash -npm install -g aibox-cli -# or -brew install blitzdotdev/tap/aibox -``` +## How it works -
-Prerequisites +- **One container per project directory.** `aibox` in a project creates (or re-attaches to) that project's container. Open more terminal tabs and run `aibox` again — they attach to the same container. +- **Your project is bind-mounted at its real path.** Changes sync both ways, paths inside the container match your Mac. +- **One shared home volume (`aibox-home`).** Claude login, every session, shell history, and the `claude` binary itself live in a Docker volume mounted at `/home/aibox` in every container. Log in once, resume any session from any project, forever. +- **Nothing is destroyed implicitly.** Exiting Claude leaves the container running in the background (idle containers cost ~nothing) — the next `aibox` attaches instantly. `aibox stop` stops it; a stopped container keeps everything, including packages you apt-installed. Containers are only recreated when the image changes, and the home volume survives even that. +- **Always yolo.** The container *is* the sandbox. No permission prompts, full sudo inside. -On macOS, if Docker isn't installed, aibox will offer to install [Colima](https://github.com/abiosoft/colima) + Docker via Homebrew automatically. Also works with [Docker Desktop](https://www.docker.com/products/docker-desktop/) or [OrbStack](https://orbstack.dev). +## Dev servers -
+Anything listening on any port inside the container is instantly reachable from your browser: -## Usage - -```bash -aibox up # start container (auto-builds image on first run) -aibox claude --yolo # no prompts, full sudo, no firewall -aibox claude --safe # keep prompts, restricted sudo, firewall on -aibox claude # asks you each time -aibox claude --resume # resume most recent conversation -aibox shell # zsh inside the container -aibox down # stop and remove ``` - -### Named instances - -Run multiple containers for the same project: - -```bash -aibox --name refactor claude --yolo -aibox --name tests claude --safe -aibox --name refactor down +http://..aibox.localhost ``` -### Isolation modes - -| Mode | Flag | How it works | -|------|------|-------------| -| **Bind mount** | *(default)* | Live-sync project directory | -| **Copy** | `--copy` | Snapshot into Docker volume (git or non-git) | -| **Worktree** | `--worktree` | Lightweight git worktree on host | - -Both `--copy` and `--worktree` auto-detect uncommitted changes, submodules, and Git LFS. Each creates a `aibox/` branch. - -
-Copy mode details - -- **Git repo** — uses `git bundle` to clone tracked files (preserves history, excludes .gitignored files). Asks to include uncommitted changes. -- **Git subfolder** — asks whether to copy the full repo or just the current folder. -- **Non-git directory** — tars the folder (excluding `node_modules` and `.git`). +Claude starts `vite` on 5173 in project `myapp` → open `http://5173.myapp.aibox.localhost`. No ports to publish, no restarts, no config — a tiny shared Caddy proxy on the Docker network reaches any container port directly, WebSockets/HMR included. -
+Works out of the box in Chrome, Edge, and Firefox (`*.localhost` resolves to loopback natively). Safari needs macOS 26+. CLI tools like `curl` need `--resolve` (the system resolver doesn't do `*.localhost`). -
-Worktree mode details +## Backup & restore -Creates a `git worktree` at `~/.config/aibox/worktrees/`. Near-instant, shares remotes with the main repo. Requires a git repository. Asks to include uncommitted changes. - -
- -### Clone from URL +Everything worth keeping is in one volume, so backup is one file: ```bash -aibox --repo https://github.com/user/project.git claude --yolo -aibox --repo git@github.com:user/project.git --branch dev claude +aibox backup # ~/aibox-backups/aibox-home--.tar.gz +aibox backup /some/dir # custom destination +aibox restore # replaces the volume (auto safety-backup first) ``` -Repos cached at `~/.config/aibox/repos/` with submodules included. +Backups are safe to take while sessions are running. -### Port forwarding +## Migrating from aibox v1 -Forward ports from a running container to the host — no restart needed: +v2 is a clean break: one always-yolo container per project, one shared home volume, and no destructive lifecycle. A standalone script merges all your v1 data — every per-image `aibox-auth-*` volume **and** any old backup folders — into the new volume: ```bash -aibox port-forward 3000 # host:3000 → container:3000 -aibox port-forward 8080:3000 # host:8080 → container:3000 -aibox port-forward 3000 5173 # multiple ports -aibox port-forward --list # show active forwards -aibox port-forward --stop 3000 # stop one -aibox port-forward --stop-all # stop all +./scripts/migrate-to-v2.sh # live v1 volumes only +./scripts/migrate-to-v2.sh ~/old-backup-dir # plus old backup folders ``` -Uses a lightweight sidecar container (`alpine/socat`) on the same Docker network. Cleaned up automatically on `aibox down`. - -### Management +Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are read-only), `.claude.json` is merged newest-wins, and the script prints — but never runs — the cleanup commands for old v1 resources. -```bash -aibox status # list all aibox containers -aibox volumes # list copy volumes and worktrees -aibox disk # show disk usage breakdown -aibox clean # clean everything (containers, volumes, images, sessions) -aibox clean --volumes # only orphaned volumes -aibox clean --containers # only stopped containers -aibox clean --sessions 7 # only session data older than 7 days (default: 30) -aibox clean --docker # only dangling images + build cache -aibox clean --force # skip confirmation -aibox doctor # diagnose common issues -aibox down --clean # also remove copy volumes / worktrees -aibox down --all # stop all containers for this project -aibox nuke # remove ALL aibox containers -``` +## Commands -Containers auto-stop when the last `claude` or `shell` session exits. +| Command | What it does | +|---------|-------------| +| `aibox` / `aibox claude [args]` | Start/attach the project container, run Claude Code (yolo). Args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | +| `aibox shell [cmd]` | zsh in the container, or run a one-off command | +| `aibox stop [--all]` | Stop this project's container (`--all`: everything incl. proxy). Loses nothing | +| `aibox status` | Containers, dev URLs, home volume size | +| `aibox backup [dir]` | Snapshot the home volume to a tar.gz | +| `aibox restore ` | Restore a backup (safety-backup of current state first) | +| `aibox update` | Update the CLI; image rebuilds automatically on next run | +| `aibox version` / `help` | | -## Security modes - -| | `--yolo` | `--safe` (default) | -|---|---|---| -| **Permission prompts** | Skipped | Kept | -| **Sudo** | Full | Restricted (chown only) | -| **Network** | Unrestricted | Firewall (allowlist only) | +## Config -In safe mode, outbound traffic is restricted to Claude API, npm, GitHub, PyPI, DNS, and SSH. Add extra domains: +`~/.aibox/config` (key=value, all optional): -```bash -export AIBOX_EXTRA_DOMAINS="example.com,api.myservice.io" +``` +node_version=24 # base image: node:-bookworm +proxy_port=80 # host port for the dev-server proxy +backup_dir=~/aibox-backups ``` -## IDE integration - -
-JetBrains (WebStorm, IntelliJ, etc.) - -1. Install the [Claude Code plugin](https://plugins.jetbrains.com/plugin/claude-code) -2. Run `aibox init` in your project -3. Set the plugin's startup command to `aibox claude --yolo` - -Node.js interpreter is also configured to use the container. - -
- -
-VS Code - -1. Install the [Claude Code extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code) -2. Set the Claude Code startup command to `aibox claude --yolo` -3. Or use Dev Containers with the generated `compose.dev.yaml` - -
- -
-Cursor / Windsurf / Other editors - -Set your agent's startup command to `aibox claude --yolo`. Works anywhere you can configure a shell command. - -
+The image is `node:-bookworm` (Debian) plus zsh, sudo, ripgrep, fzf, and jq — Claude apt-installs anything else on demand, and it persists across stop/start. To make custom tooling survive image rebuilds too, put extra Dockerfile lines in `~/.aibox/Dockerfile.extra`. -## Other agents +## Prerequisites -The container ships with Node.js 20, git, git-lfs, ripgrep, zsh, python3, and build tools. Claude Code is pre-installed, but you can run anything: +Docker via [Colima](https://github.com/abiosoft/colima), [OrbStack](https://orbstack.dev), or [Docker Desktop](https://www.docker.com/products/docker-desktop/): ```bash -aibox shell # then run: aider, codex, etc. +brew install colima docker && colima start ``` -Customize the Dockerfile at `~/.config/aibox/Dockerfile`. - -## CLI reference - -| Short | Long | Description | -|-------|------|-------------| -| `-n` | `--name NAME` | Named instance (multiple containers per project) | -| `-d` | `--dir PATH` | Run in a different project directory | -| `-r` | `--repo URL` | Clone a git repo and run in it | -| `-b` | `--branch NAME` | Branch to checkout (with `--repo`) | -| `-i` | `--image NAME` | Override base Docker image | -| `-c` | `--copy` | Copy project into Docker volume (full isolation) | -| `-w` | `--worktree` | Use git worktree (lightweight isolation) | -| `-y` | `--yolo` | Skip prompts, full sudo, no firewall | -| `-s` | `--safe` | Keep prompts, restricted sudo, firewall on | -| | `--all` | With `down`: stop all project containers | -| | `--clean` | With `down`: also remove copy volumes / worktrees | -| | `--force` | With `clean`: skip confirmation prompts | - -## Config - -Per-project settings in `.aibox`: - -``` -IMAGE=aibox:latest -SHARED_MODULES=true -``` +aibox auto-starts an installed-but-stopped runtime; it won't install one for you. ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md). +See [CONTRIBUTING.md](CONTRIBUTING.md). Design/requirements for v2 are in [REVAMP.md](REVAMP.md). ## License diff --git a/bin/aibox b/bin/aibox index 56c799e..bdd6846 100755 --- a/bin/aibox +++ b/bin/aibox @@ -1,2379 +1,572 @@ #!/usr/bin/env bash -# aibox - Run AI coding tools in isolated Docker containers. +# aibox — persistent Docker sandboxes for Claude Code. # # Usage: -# aibox up Start the container -# aibox claude [--yolo|--safe] Open Claude Code -# [claude args] --yolo: skip permissions, full sudo, no firewall -# --safe: keep permissions, restricted sudo, firewall on -# Extra args are passed through to claude -# (e.g. --resume, --print, --model) -# aibox shell Open a zsh shell in the container -# aibox shell Run a command in the container -# aibox down Stop and remove the container -# aibox down --clean Also remove copy volumes / worktrees -# aibox down --all Stop all containers for this project -# aibox port-forward PORT [PORT] Forward host port(s) to container -# aibox port-forward --list List active port forwards -# aibox port-forward --stop P Stop forwarding a port -# aibox port-forward --stop-all Stop all port forwards -# aibox status List all aibox containers -# aibox volumes List copy volumes and worktrees -# aibox disk Show disk usage breakdown -# aibox clean [DAYS] Clean everything (default: 30 days) -# --containers Only stopped containers -# --volumes Only orphaned volumes -# --docker Only dangling images + build cache -# --sessions Only old Claude session data -# aibox doctor Diagnose common issues -# aibox nuke Remove ALL aibox containers (all projects) -# aibox build [--image NAME] Build the base image -# aibox init [flags] Generate compose.dev.yaml + configure WebStorm -# aibox update Update aibox to the latest version -# aibox version Show version -# aibox help Show this help (default) +# aibox [claude] [args...] Start/attach this project's container and run +# Claude Code (yolo). Extra args pass to claude +# verbatim (--resume, -p, --model, ...). +# `aibox --resume` also works. +# aibox shell [cmd...] zsh in the container, or run a one-off command +# aibox stop [--all] Stop this project's container +# (--all: every aibox container + proxy). +# Never deletes anything; next run re-attaches. +# aibox status Containers, dev-server URLs, home volume size +# aibox backup [dir] Snapshot the aibox-home volume to a tar.gz +# (default dir: ~/aibox-backups) +# aibox restore Restore a backup into aibox-home +# (auto safety-backup of current state first) +# aibox update Update the CLI; image rebuilds on next run +# aibox version Show versions +# aibox help Show this help # -# Flags: -# -n, --name NAME Run a named instance (e.g. -n refactor) -# Allows multiple containers per project -# -d, --dir PATH Run in a different project directory -# -r, --repo URL Clone a git repo and run in it -# -b, --branch NAME Branch to checkout (with --repo) -# -i, --image NAME Override base Docker image (default: aibox:latest) -# -c, --copy Copy project into container (full isolation, no host sync) -# -w, --worktree Use git worktree (lightweight isolation, stays on host) -# -y, --yolo Yolo mode: skip permissions, full sudo, no firewall -# -s, --safe Safe mode: keep permissions, restricted sudo, firewall on -# --shared-modules Share node_modules between host and container (default) -# --separate-modules Isolate node_modules in a Docker volume -# --force Skip confirmation prompts (for clean) +# Dev servers: anything listening on any port inside the container is +# reachable from the host browser at +# http://..aibox.localhost +# (Chrome/Edge/Firefox out of the box; Safari needs macOS 26+. No ports to +# publish, no restarts — the proxy reaches the container over the Docker +# network.) # -# Network firewall: -# Default: only allows Claude API, npm, GitHub, PyPI, SSH, DNS. -# Add domains: export AIBOX_EXTRA_DOMAINS="example.com,api.myservice.io" +# Config (~/.aibox/config, key=value, all optional): +# node_version=24 # base image: node:-bookworm +# proxy_port=80 # host port for the dev-server proxy +# backup_dir=~/aibox-backups +# Extra image layers: ~/.aibox/Dockerfile.extra is appended to the generated +# Dockerfile (survives image rebuilds; plain apt installs survive stop/start +# but reset when the image changes). # -# Prerequisites: -# brew install colima docker docker-compose docker-buildx -# (or: brew install orbstack) -# -# First-time setup (once ever): -# aibox build -# -# Per-project: -# aibox up # start default container -# aibox claude # open Claude Code -# aibox claude --yolo # skip permission prompts (sandboxed) -# aibox --name feat claude # Claude Code in a second container -# aibox shell # zsh in the container -# aibox shell ls -la # run a command inline -# aibox down # stop default -# aibox down --all # stop all for this project +# All Claude state (sessions, login, the claude binary itself) lives in one +# shared Docker volume: aibox-home. Containers are disposable; the volume is +# the thing `aibox backup` protects. set -euo pipefail -# ── Script identity ────────────────────────────────────────────── -SCRIPT_NAME="$(basename "$0")" -AIBOX_VERSION="9" -AIBOX_CLI_VERSION="__CLI_VERSION__" -CONFIG_DIR="${HOME}/.config/aibox" -DEFAULT_IMAGE="aibox:latest" -CONTAINER_PREFIX="aibox" +CLI_VERSION="__CLI_VERSION__" +CONFIG_DIR="${HOME}/.aibox" +VOLUME="aibox-home" +NETWORK="aibox" +PROXY="aibox-proxy" +PROXY_IMAGE="caddy:2-alpine" -# ── Colors ──────────────────────────────────────────────────── -# Disable color if not a terminal or NO_COLOR is set +# ── Output helpers ─────────────────────────────────────────────── if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then - _R='\033[0;31m' _BR='\033[1;31m' - _Y='\033[0;33m' _BY='\033[1;33m' - _G='\033[0;32m' _BG='\033[1;32m' - _C='\033[0;36m' _BC='\033[1;36m' - _M='\033[0;35m' - _B='\033[1m' _D='\033[2m' - _N='\033[0m' + _G='\033[1;32m' _Y='\033[1;33m' _R='\033[1;31m' _D='\033[2m' _B='\033[1m' _N='\033[0m' else - _R='' _BR='' _Y='' _BY='' _G='' _BG='' _C='' _BC='' _M='' _B='' _D='' _N='' + _G='' _Y='' _R='' _D='' _B='' _N='' fi - -# ── Output helpers ───────────────────────────────────────────── -_step() { - local msg="$1" - local width=60 - local pad - pad=$(( width - ${#msg} - 5 )) # 5 = "── " + " ─" - [[ $pad -lt 3 ]] && pad=3 - local line - line=$(printf '─%.0s' $(seq 1 "$pad")) - echo "" - echo -e "${_BC}── ${_B}${msg}${_N}${_BC} ${line}${_N}" - echo "" -} - -_ok() { echo -e " ${_BG}✓${_N} $*"; } -_warn() { echo -e " ${_BY}⚠${_N} ${_BY}$1${_N}"; shift || true; if [[ $# -gt 0 ]]; then for line in "$@"; do echo -e " $line"; done; fi; } -_err() { echo -e " ${_BR}✗ Error:${_N} $*" >&2; } -_info() { echo -e " ${_D}ℹ${_N} $*"; } -_file() { echo -e "${_C}$1${_N}"; } - -# ── Machine-aware Colima start ─────────────────────────────────── -_colima_start() { - local total_cpu total_mem_gb vm_cpu vm_mem vm_disk - - # Detect machine resources - if [[ "$(uname)" == "Darwin" ]]; then - total_cpu=$(sysctl -n hw.ncpu 2>/dev/null || echo 4) - total_mem_gb=$(( $(sysctl -n hw.memsize 2>/dev/null || echo 8589934592) / 1073741824 )) - else - total_cpu=$(nproc 2>/dev/null || echo 4) - total_mem_gb=$(( $(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 8388608) / 1048576 )) - fi - - # Allocate half CPU (min 2, max 4), half RAM (min 4GB, max 8GB), 100GB disk - vm_cpu=$(( total_cpu / 2 )) - [[ $vm_cpu -lt 2 ]] && vm_cpu=2 - [[ $vm_cpu -gt 4 ]] && vm_cpu=4 - vm_mem=$(( total_mem_gb / 2 )) - [[ $vm_mem -lt 4 ]] && vm_mem=4 - [[ $vm_mem -gt 8 ]] && vm_mem=8 - vm_disk=100 - - echo "Detected: ${total_cpu} CPUs, ${total_mem_gb}GB RAM" - echo "Colima VM: ${vm_cpu} CPUs, ${vm_mem}GB RAM, ${vm_disk}GB disk" - - # Try Apple Virtualization framework first (fast), fall back to QEMU - colima start \ - --cpu "$vm_cpu" --memory "$vm_mem" --disk "$vm_disk" \ - --vm-type vz --vz-rosetta 2>/dev/null \ - || colima start \ - --cpu "$vm_cpu" --memory "$vm_mem" --disk "$vm_disk" -} - -# ── Dependency check ───────────────────────────────────────────── -_check_deps() { - local missing=() - - command -v docker &>/dev/null || missing+=("docker") - command -v docker-compose &>/dev/null || docker compose version &>/dev/null || missing+=("docker-compose") - - if [[ ${#missing[@]} -eq 0 ]]; then - # Docker CLI exists — check if daemon is reachable - if ! docker info &>/dev/null; then - if command -v colima &>/dev/null; then - echo "Docker daemon not running. Starting Colima..." - _colima_start - elif [[ -d "/Applications/OrbStack.app" ]]; then - echo "Docker daemon not running. Starting OrbStack..." - open -a OrbStack - local i=0 - while ! docker info &>/dev/null && [[ $i -lt 30 ]]; do - sleep 1 - i=$((i + 1)) - done - if ! docker info &>/dev/null; then - echo "OrbStack started but Docker daemon not ready. Try again in a moment." >&2 - exit 1 - fi - elif [[ -d "/Applications/Docker.app" ]]; then - echo "Docker daemon not running. Starting Docker Desktop..." - open -a Docker - local i=0 - while ! docker info &>/dev/null && [[ $i -lt 60 ]]; do - sleep 1 - i=$((i + 1)) - done - if ! docker info &>/dev/null; then - echo "Docker Desktop started but daemon not ready. Try again in a moment." >&2 - exit 1 - fi - else - echo "Docker daemon not running. Install Docker Desktop, Colima, or OrbStack." >&2 - exit 1 - fi - fi - return - fi - - # Something is missing — offer to install - if ! command -v brew &>/dev/null; then - echo "Homebrew is required to install Docker dependencies." - echo "Will run: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" - echo "" - if _confirm_yes "Install Homebrew?"; then - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - if [[ -f /opt/homebrew/bin/brew ]]; then - eval "$(/opt/homebrew/bin/brew shellenv)" - elif [[ -f /usr/local/bin/brew ]]; then - eval "$(/usr/local/bin/brew shellenv)" - fi - if ! command -v brew &>/dev/null; then - echo "Homebrew installed but not in PATH. Open a new terminal and re-run ${SCRIPT_NAME}." >&2 - exit 1 - fi - else - echo "Aborted. Install Homebrew (https://brew.sh) then re-run ${SCRIPT_NAME}." >&2 - exit 1 - fi - fi - - echo "Missing dependencies: ${missing[*]}" - echo "Will install via Homebrew: colima docker docker-compose docker-buildx" - echo "" - if _confirm_yes "Install now?"; then - brew install colima docker docker-compose docker-buildx - - mkdir -p ~/.docker/cli-plugins - ln -sfn "$(brew --prefix)/opt/docker-compose/bin/docker-compose" ~/.docker/cli-plugins/docker-compose - - echo "" - echo "Starting Colima..." - _colima_start - echo "Docker is ready." - else - echo "Aborted. Install manually:" >&2 - echo " brew install colima docker docker-compose docker-buildx" >&2 - exit 1 - fi -} - -# ── Parse global flags ─────────────────────────────────────────── -IMAGE="$DEFAULT_IMAGE" -SHARED_MODULES=true -INSTANCE_NAME="" -PROJECT_DIR_FLAG="" -REPO_URL="" -REPO_BRANCH="" -DOWN_ALL=false -DOWN_CLEAN=false -FORCE_CLEAN=false -SKIP_PERMISSIONS=false -SAFE_MODE=false -ISOLATION="" -POSITIONAL=() - -parse_flags() { - while [[ $# -gt 0 ]]; do - case "$1" in - -i|--image) - IMAGE="${2:?'--image requires a value'}" - shift 2 - ;; - -n|--name) - INSTANCE_NAME="${2:?'--name requires a value'}" - shift 2 - ;; - -d|--dir) - PROJECT_DIR_FLAG="${2:?'--dir requires a value'}" - shift 2 - ;; - -r|--repo) - REPO_URL="${2:?'--repo requires a value'}" - shift 2 - ;; - -b|--branch) - REPO_BRANCH="${2:?'--branch requires a value'}" - shift 2 - ;; - --shared-modules) - SHARED_MODULES=true - shift - ;; - --separate-modules) - SHARED_MODULES=false - shift - ;; - -c|--copy) - [[ -n "$ISOLATION" ]] && { echo "Error: --copy and --worktree are mutually exclusive" >&2; exit 1; } - ISOLATION="copy" - shift - ;; - -w|--worktree) - [[ -n "$ISOLATION" ]] && { echo "Error: --copy and --worktree are mutually exclusive" >&2; exit 1; } - ISOLATION="worktree" - shift - ;; - --all) - DOWN_ALL=true - shift - ;; - --clean) - DOWN_CLEAN=true - shift - ;; - --force) - FORCE_CLEAN=true - shift - ;; - -y|--yolo) - SKIP_PERMISSIONS=true - shift - ;; - -s|--safe) - SAFE_MODE=true - shift - ;; - *) - POSITIONAL+=("$1") - shift - ;; +_ok() { echo -e "${_G}✓${_N} $*"; } +_info() { echo -e "${_D}·${_N} $*"; } +_warn() { echo -e "${_Y}⚠${_N} $*"; } +_die() { echo -e "${_R}✗${_N} $*" >&2; exit 1; } + +# ── Config ─────────────────────────────────────────────────────── +NODE_VERSION=24 +PROXY_PORT=80 +BACKUP_DIR="${HOME}/aibox-backups" +if [[ -f "${CONFIG_DIR}/config" ]]; then + while IFS='=' read -r k v; do + [[ "$k" == \#* || -z "$k" ]] && continue + v="${v/#\~/$HOME}" + case "$k" in + node_version) NODE_VERSION="$v" ;; + proxy_port) PROXY_PORT="$v" ;; + backup_dir) BACKUP_DIR="$v" ;; esac - done -} - -parse_flags "$@" -if [[ ${#POSITIONAL[@]} -gt 0 ]]; then - set -- "${POSITIONAL[@]}" -else - set -- -fi - -# Set container mode from flags (cmd_claude may override via interactive prompt) -if [[ "$SKIP_PERMISSIONS" == "true" ]]; then - export AIBOX_MODE="yolo" -elif [[ "$SAFE_MODE" == "true" ]]; then - export AIBOX_MODE="safe" -fi - -# ── Repo clone ─────────────────────────────────────────────────── -if [[ -n "$REPO_URL" ]]; then - # Derive a directory name from the repo URL (hash prevents collisions) - _repo_name=$(basename "$REPO_URL" .git) - _repo_hash=$(printf '%s' "$REPO_URL" | shasum | cut -c1-6) - _repo_dir="${CONFIG_DIR}/repos/${_repo_name}-${_repo_hash}" - - if [[ -d "$_repo_dir/.git" ]]; then - echo "Repo already cloned (${_repo_dir}). Reusing." - # Fetch latest and checkout branch if specified - if [[ -n "$REPO_BRANCH" ]]; then - git -C "$_repo_dir" fetch --all --quiet 2>/dev/null || true - git -C "$_repo_dir" checkout "$REPO_BRANCH" 2>/dev/null || true - fi - else - echo "Cloning ${REPO_URL}..." - mkdir -p "${CONFIG_DIR}/repos" - _clone_args=(--recursive) - [[ -n "$REPO_BRANCH" ]] && _clone_args+=(--branch "$REPO_BRANCH") - git clone "${_clone_args[@]}" "$REPO_URL" "$_repo_dir" - fi - - PROJECT_DIR_FLAG="$_repo_dir" + done < "${CONFIG_DIR}/config" fi -# ── Per-project config file (.aibox) ───────────────────────────── -if [[ -n "$PROJECT_DIR_FLAG" ]]; then - PROJECT_DIR="$(cd "$PROJECT_DIR_FLAG" 2>/dev/null && pwd)" || { - echo "Error: directory not found: $PROJECT_DIR_FLAG" >&2 - exit 1 - } +# ── Derived names ──────────────────────────────────────────────── +PROJECT_DIR="$(pwd -P)" +SLUG="$(basename "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g; s/_/-/g')" +if command -v sha256sum >/dev/null 2>&1; then + HASH6="$(printf '%s' "$PROJECT_DIR" | sha256sum | cut -c1-6)" else - PROJECT_DIR="$(pwd)" + HASH6="$(printf '%s' "$PROJECT_DIR" | shasum -a 256 | cut -c1-6)" fi -PROJECT_CONF="${PROJECT_DIR}/.aibox" +CONTAINER="aibox-${SLUG}-${HASH6}" +IMG_VER="$CLI_VERSION" +[[ "$IMG_VER" == "__CLI_VERSION__" ]] && IMG_VER="dev" +IMAGE="aibox:${IMG_VER}-node${NODE_VERSION}" -# Git root may differ from PROJECT_DIR (subfolder of a repo) -GIT_ROOT=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || true) - -# ── Safety: refuse to run in dangerous directories ─────────────── -_is_safe_project_dir() { - local dir="$1" - case "$dir" in +_require_safe_dir() { + case "$PROJECT_DIR" in "$HOME"|/|/tmp|/var|/etc|/usr|/opt|/private|/private/tmp) - return 1 ;; + _die "Refusing to sandbox ${PROJECT_DIR} — run aibox from inside a project directory." ;; esac - return 0 -} - -_require_safe_dir() { - if ! _is_safe_project_dir "$PROJECT_DIR"; then - echo "Error: refusing to run in ${PROJECT_DIR}" >&2 - echo "Run ${SCRIPT_NAME} from inside a project directory." >&2 - exit 1 - fi -} - -# ── Confirmation prompts ───────────────────────────────────────── -_confirm_yes() { - local msg="$1" reply - printf "%s [Y/n] " "$msg" >&2 - read -r reply - [[ ! "$reply" =~ ^[Nn]$ ]] } -_confirm_no() { - local msg="$1" reply - printf "%s [y/N] " "$msg" >&2 +_confirm() { + local reply + printf '%s [y/N] ' "$1" >&2 read -r reply [[ "$reply" =~ ^[Yy]$ ]] } -# ── Disk space check ────────────────────────────────────────────── -_check_disk_space() { - local required_gb="${1:-5}" context="${2:-operation}" - local available_kb - - # Check host filesystem - available_kb=$(df -k "$PROJECT_DIR" 2>/dev/null | awk 'NR==2 {print $4}') - - if [[ -z "$available_kb" || ! "$available_kb" =~ ^[0-9]+$ ]]; then - return 0 # Can't determine — don't block - fi - - local available_gb=$(( available_kb / 1048576 )) - - if (( available_kb < required_gb * 1048576 )); then - _err "${context} needs ~${_B}${required_gb}GB${_N} free, but only ${_B}${available_gb}GB${_N} available." - echo -e " Free up disk space and try again." >&2 - exit 1 - fi - - if (( available_kb < required_gb * 1048576 * 2 )); then - _warn "Low disk space (${available_gb}GB free, ${context} needs ~${required_gb}GB)." - fi -} - -# ── Project config persistence ─────────────────────────────────── -save_project_conf() { - cat > "$PROJECT_CONF" << EOF -# Auto-generated by ${SCRIPT_NAME}. Safe to edit. -IMAGE=${IMAGE} -SHARED_MODULES=${SHARED_MODULES} -EOF -} - -load_project_conf() { - if [[ -f "$PROJECT_CONF" ]]; then - local conf_image conf_shared - conf_image=$(grep '^IMAGE=' "$PROJECT_CONF" | cut -d= -f2- || true) - conf_shared=$(grep '^SHARED_MODULES=' "$PROJECT_CONF" | cut -d= -f2- || true) - if [[ "$IMAGE" == "$DEFAULT_IMAGE" && -n "$conf_image" ]]; then - IMAGE="$conf_image" - fi - if [[ -n "$conf_shared" ]]; then - SHARED_MODULES="$conf_shared" - fi +# Container state, or "absent". (docker inspect emits a stray newline on +# stdout for missing objects, so trim before defaulting.) +_state() { + local s + s="$(docker inspect "$1" --format '{{.State.Status}}' 2>/dev/null | tr -d '[:space:]')" + echo "${s:-absent}" +} + +# ── Docker runtime ─────────────────────────────────────────────── +_ensure_docker() { + command -v docker >/dev/null 2>&1 \ + || _die "docker CLI not found. Install a runtime first, e.g.: brew install colima docker && colima start" + docker info >/dev/null 2>&1 && return 0 + if command -v colima >/dev/null 2>&1; then + _info "Docker daemon not running. Starting Colima..." + colima start + elif [[ -d /Applications/OrbStack.app ]]; then + _info "Docker daemon not running. Starting OrbStack..." + open -a OrbStack + elif [[ -d /Applications/Docker.app ]]; then + _info "Docker daemon not running. Starting Docker Desktop..." + open -a Docker + else + _die "Docker daemon not running. Start your Docker runtime and retry." fi + local i=0 + until docker info >/dev/null 2>&1; do + (( i++ >= 60 )) && _die "Docker daemon did not come up. Try again in a moment." + sleep 1 + done } -load_project_conf - -# ── Validate image name ────────────────────────────────────────── -if ! [[ "$IMAGE" =~ ^[a-zA-Z0-9._/-]+(:[a-zA-Z0-9._-]+)?$ ]]; then - echo "Error: invalid image name: $IMAGE" >&2 - exit 1 -fi - -# ── Derived names ──────────────────────────────────────────────── -PROJECT_NAME="$(basename "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g')" -PROJECT_HASH="$(printf '%s' "$PROJECT_DIR" | shasum | cut -c1-6)" -BASE_CONTAINER_NAME="${CONTAINER_PREFIX}-${PROJECT_NAME}-${PROJECT_HASH}" - -if [[ -z "$INSTANCE_NAME" ]]; then - INSTANCE_NAME="main" -fi -if ! [[ "$INSTANCE_NAME" =~ ^[a-z0-9_-]+$ ]]; then - echo "Error: --name must be lowercase alphanumeric, dashes, underscores" >&2 - exit 1 -fi -CONTAINER_NAME="${BASE_CONTAINER_NAME}-${INSTANCE_NAME}" -WORKSPACE_DIR="${PROJECT_DIR}" - -# ── Isolation mode setup ───────────────────────────────────────── -COPY_VOLUME="" -WORKTREE_DIR="" - -if [[ "$ISOLATION" == "copy" ]]; then - COPY_VOLUME="${CONTAINER_NAME}-src" -elif [[ "$ISOLATION" == "worktree" ]]; then - WORKTREE_DIR="${CONFIG_DIR}/worktrees/${CONTAINER_NAME}" -fi - -# Detect isolation mode from existing container label (reconnect support) -if [[ -z "$ISOLATION" ]]; then - _existing_isolation=$(docker inspect "$CONTAINER_NAME" --format '{{index .Config.Labels "aibox.isolation"}}' 2>/dev/null || echo "") - if [[ "$_existing_isolation" == "copy" ]]; then - ISOLATION="copy" - COPY_VOLUME="${CONTAINER_NAME}-src" - elif [[ "$_existing_isolation" == "worktree" ]]; then - ISOLATION="worktree" - WORKTREE_DIR="${CONFIG_DIR}/worktrees/${CONTAINER_NAME}" - fi -fi - -# Set container-side workspace path (short paths for isolated modes, host path for IDE bind mount) -if [[ "$ISOLATION" == "copy" ]]; then - WORKSPACE_DIR="/workspace/${PROJECT_NAME}" -elif [[ "$ISOLATION" == "worktree" ]]; then - WORKSPACE_DIR="/workspace/${PROJECT_NAME}-${INSTANCE_NAME}" -fi - -# ── Dockerfile management ──────────────────────────────────────── -ensure_dockerfile() { +# ── Image ──────────────────────────────────────────────────────── +_build_image() { mkdir -p "$CONFIG_DIR" - local dockerfile="${CONFIG_DIR}/Dockerfile" - local version_file="${CONFIG_DIR}/version" - local current_version="" - - # Check if Dockerfile needs regeneration - if [[ -f "$version_file" ]]; then - current_version=$(cat "$version_file") - fi - - if [[ "$current_version" != "$AIBOX_VERSION" || ! -f "$dockerfile" ]]; then - if [[ -f "$dockerfile" && "$current_version" != "$AIBOX_VERSION" ]]; then - echo "Dockerfile outdated (v${current_version} → v${AIBOX_VERSION}). Regenerating (one-time)..." - fi - cat > "$dockerfile" << DOCKERFILE -FROM node:20-alpine - -LABEL aibox.version="${AIBOX_VERSION}" - -RUN apk add --no-cache \\ - git \\ - git-lfs \\ - curl \\ - ripgrep \\ - bash \\ - zsh \\ - sudo \\ - openssh-client \\ - python3 \\ - make \\ - g++ \\ - iptables \\ - ip6tables \\ - bind-tools \\ - su-exec - -RUN git lfs install + cat > "${CONFIG_DIR}/Dockerfile" </dev/null || true \\ + && useradd -m -u 1000 -s /usr/bin/zsh aibox \\ + && echo "aibox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/aibox \\ + && chmod 0440 /etc/sudoers.d/aibox -# Install Claude Code as aibox user via native installer -USER aibox ENV PATH="/home/aibox/.local/bin:\$PATH" -RUN curl -fsSL https://claude.ai/install.sh | bash -WORKDIR /workspace - -# Entrypoint runs as root, then execs sleep as aibox -USER root -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +COPY entrypoint.sh /usr/local/bin/aibox-entrypoint +RUN chmod +x /usr/local/bin/aibox-entrypoint +ENTRYPOINT ["/usr/local/bin/aibox-entrypoint"] CMD ["sleep", "infinity"] DOCKERFILE - - # Generate entrypoint script (runs as root at container start) - cat > "${CONFIG_DIR}/entrypoint.sh" << 'ENTRYPOINT' + [[ -f "${CONFIG_DIR}/Dockerfile.extra" ]] && cat "${CONFIG_DIR}/Dockerfile.extra" >> "${CONFIG_DIR}/Dockerfile" + cat > "${CONFIG_DIR}/entrypoint.sh" <<'ENTRYPOINT' #!/bin/bash set -e - -# Fix volume ownership (Docker creates volumes as root) -chown -R aibox:aibox /home/aibox/.claude 2>/dev/null || true -if [[ -n "${AIBOX_WORKSPACE:-}" && -d "${AIBOX_WORKSPACE}/node_modules" ]]; then - chown aibox:aibox "${AIBOX_WORKSPACE}/node_modules" 2>/dev/null || true +# /home/aibox is a named volume; normalize ownership (data migrated from v1 +# may carry other UIDs). Fast path: skip when the roots already look right. +if [[ "$(stat -c %u /home/aibox)" != "1000" \ + || "$(stat -c %u /home/aibox/.claude 2>/dev/null || echo 1000)" != "1000" ]]; then + chown -R aibox:aibox /home/aibox || true fi - -# ── Mode-dependent setup ────────────────────────────────────── -MODE="${AIBOX_MODE:-safe}" - -if [[ "$MODE" == "yolo" ]]; then - # YOLO: full sudo, no firewall - echo "aibox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/aibox - echo "[aibox] Mode: yolo (full sudo, no firewall)" - -else - # SAFE: restricted sudo (chown only), firewall active - echo "aibox ALL=(root) NOPASSWD: /bin/chown *" > /etc/sudoers.d/aibox - - # ── Firewall setup ────────────────────────────────────────── - ALLOWED_DOMAINS=( - # Claude Code / Anthropic - "api.anthropic.com" - "claude.ai" - "statsig.anthropic.com" - "statsig.com" - "sentry.io" - # npm - "registry.npmjs.org" - # GitHub - "github.com" - "api.github.com" - # PyPI - "pypi.org" - "files.pythonhosted.org" - ) - - # Extra domains from env var (comma-separated) - if [[ -n "${AIBOX_EXTRA_DOMAINS:-}" ]]; then - IFS=',' read -ra EXTRA <<< "$AIBOX_EXTRA_DOMAINS" - ALLOWED_DOMAINS+=("${EXTRA[@]}") - fi - - echo "Configuring firewall..." - - # Flush existing - iptables -F OUTPUT 2>/dev/null || true - - # Allow loopback - iptables -A OUTPUT -o lo -j ACCEPT - - # Allow established/related - iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT - - # Allow DNS - iptables -A OUTPUT -p udp --dport 53 -j ACCEPT - iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT - - # Allow SSH (git over SSH) - iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT - - # Resolve and allow each domain - for domain in "${ALLOWED_DOMAINS[@]}"; do - domain=$(echo "$domain" | xargs) - [[ -z "$domain" ]] && continue - ips=$(dig +short A "$domain" 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true) - for ip in $ips; do - iptables -A OUTPUT -d "$ip" -j ACCEPT - done - done - - # Allow Docker host (IDE integration) - host_ip=$(getent hosts host.docker.internal 2>/dev/null | awk '{print $1}' || true) - if [[ -n "$host_ip" ]]; then - iptables -A OUTPUT -d "$host_ip" -j ACCEPT - fi - - # Default deny - iptables -A OUTPUT -j DROP - - echo "[aibox] Mode: safe (firewall active, ${#ALLOWED_DOMAINS[@]} domains allowed)" +# First start with an empty home volume: install Claude Code into the volume +# so the binary and its self-updates persist across container recreation. +# (Anything baked into the image's home dir would be shadowed by the mount.) +if [[ ! -x /home/aibox/.local/bin/claude ]]; then + runuser -u aibox -- bash -c 'curl -fsSL --connect-timeout 15 https://claude.ai/install.sh | bash' || true fi - -chmod 0440 /etc/sudoers.d/aibox - -# Drop to aibox user and exec CMD (su-exec preserves env and properly execs) -exec su-exec aibox "$@" +exec "$@" ENTRYPOINT - echo "$AIBOX_VERSION" > "$version_file" - echo "Created Dockerfile (v${AIBOX_VERSION})" - fi + _info "Building ${IMAGE} (one-time; rebuilt only when aibox or node_version changes)..." + docker build --pull -t "$IMAGE" "$CONFIG_DIR" + _ok "Image built." } -# ── Uncommitted changes helper ──────────────────────────────────── -# Detects uncommitted changes (tracked + untracked) and offers to include them. -# Sets INCLUDE_UNCOMMITTED=true and populates temp files if user says yes. -INCLUDE_UNCOMMITTED=false -UNCOMMITTED_DIFF="" -UNCOMMITTED_UNTRACKED_TAR="" - -_check_uncommitted() { - [[ -z "$GIT_ROOT" ]] && return 0 - - local has_tracked_changes=false - local has_untracked=false - - if ! git -C "$GIT_ROOT" diff --quiet 2>/dev/null || ! git -C "$GIT_ROOT" diff --cached --quiet 2>/dev/null; then - has_tracked_changes=true - fi - - local untracked_files - untracked_files=$(git -C "$GIT_ROOT" ls-files --others --exclude-standard 2>/dev/null || true) - if [[ -n "$untracked_files" ]]; then - has_untracked=true - fi - - if [[ "$has_tracked_changes" == "false" && "$has_untracked" == "false" ]]; then - return 0 - fi - - echo -e " Uncommitted changes detected:" - [[ "$has_tracked_changes" == "true" ]] && echo -e " ${_M}Modified/staged files${_N}" - [[ "$has_untracked" == "true" ]] && echo -e " ${_M}Untracked files${_N} (${_B}$(echo "$untracked_files" | wc -l | xargs)${_N} files)" - echo "" - if _confirm_yes "Include uncommitted changes?"; then - INCLUDE_UNCOMMITTED=true +_ensure_image() { + docker image inspect "$IMAGE" >/dev/null 2>&1 || _build_image +} - # Capture diff of all tracked changes (staged + unstaged) against HEAD - if [[ "$has_tracked_changes" == "true" ]]; then - UNCOMMITTED_DIFF=$(mktemp) - git -C "$GIT_ROOT" diff HEAD > "$UNCOMMITTED_DIFF" - fi +# ── Dev-server proxy (Caddy) ───────────────────────────────────── +# One shared reverse proxy on the aibox network. Any request to +# ..aibox.localhost is proxied to : over the +# Docker network — no ports are ever published on project containers. - # Tar up untracked files (preserving paths relative to project root) - if [[ "$has_untracked" == "true" ]]; then - UNCOMMITTED_UNTRACKED_TAR=$(mktemp) - echo "$untracked_files" | tar -C "$GIT_ROOT" -cf "$UNCOMMITTED_UNTRACKED_TAR" -T - - fi - else - _info "Proceeding without uncommitted changes." - fi +# All v2 project containers, oldest first: "slugname" per line. +_project_rows() { + docker ps -a --filter label=aibox.slug \ + --format '{{.CreatedAt}}\t{{.Label "aibox.slug"}}\t{{.Names}}' 2>/dev/null \ + | sort | cut -f2,3 } -_cleanup_uncommitted() { - [[ -n "$UNCOMMITTED_DIFF" ]] && rm -f "$UNCOMMITTED_DIFF" - [[ -n "$UNCOMMITTED_UNTRACKED_TAR" ]] && rm -f "$UNCOMMITTED_UNTRACKED_TAR" +# Emit "publiclabel name" pairs; the oldest project keeps the bare slug, +# later slug collisions get slug-hash6. +_proxy_pairs() { + local slug name key seen=" " + while IFS=$'\t' read -r slug name; do + [[ -z "$name" ]] && continue + key="$slug" + [[ "$seen" == *" $slug "* ]] && key="${slug}-${name##*-}" + seen="${seen}${slug} " + echo "$key $name" + done < <(_project_rows) } -# Apply uncommitted changes to a local directory (worktree) -_apply_uncommitted_local() { - local target_dir="$1" - - if [[ "$INCLUDE_UNCOMMITTED" != "true" ]]; then - return 0 - fi - - if [[ -n "$UNCOMMITTED_DIFF" && -s "$UNCOMMITTED_DIFF" ]]; then - git -C "$target_dir" apply --allow-empty "$UNCOMMITTED_DIFF" 2>/dev/null \ - && _ok "Applied uncommitted tracked changes." \ - || _warn "Some tracked changes could not be applied." - fi - - if [[ -n "$UNCOMMITTED_UNTRACKED_TAR" && -s "$UNCOMMITTED_UNTRACKED_TAR" ]]; then - tar -C "$target_dir" -xf "$UNCOMMITTED_UNTRACKED_TAR" \ - && _ok "Copied untracked files." \ - || _warn "Some untracked files could not be copied." - fi +_pub_label() { + local key name + while read -r key name; do + [[ "$name" == "$CONTAINER" ]] && { echo "$key"; return; } + done < <(_proxy_pairs) + echo "$SLUG" } -# Apply uncommitted changes inside a docker volume -_apply_uncommitted_volume() { - local volume="$1" - local workspace="$2" - - if [[ "$INCLUDE_UNCOMMITTED" != "true" ]]; then - return 0 - fi - - if [[ -n "$UNCOMMITTED_DIFF" && -s "$UNCOMMITTED_DIFF" ]]; then - cat "$UNCOMMITTED_DIFF" | docker run --rm -i --entrypoint sh \ - -v "${volume}:${workspace}" \ - "$IMAGE" -c " - cd '${workspace}' - cat > /tmp/uncommitted.patch - git apply --allow-empty /tmp/uncommitted.patch - rm /tmp/uncommitted.patch - chown -R aibox:aibox '${workspace}' - " 2>/dev/null \ - && _ok "Applied uncommitted tracked changes." \ - || _warn "Some tracked changes could not be applied." - fi - - if [[ -n "$UNCOMMITTED_UNTRACKED_TAR" && -s "$UNCOMMITTED_UNTRACKED_TAR" ]]; then - cat "$UNCOMMITTED_UNTRACKED_TAR" | docker run --rm -i --entrypoint sh \ - -v "${volume}:${workspace}" \ - "$IMAGE" -c " - cd '${workspace}' - tar -xf - - chown -R aibox:aibox '${workspace}' - " 2>/dev/null \ - && _ok "Copied untracked files." \ - || _warn "Some untracked files could not be copied." - fi +_gen_caddyfile() { + echo "# Generated by aibox — do not edit (regenerated on every run)." + echo "http://*.*.aibox.localhost {" + echo " map {http.request.host.labels.2} {upstream} {" + local key name + while read -r key name; do + echo " ${key} ${name}" + done < <(_proxy_pairs) + echo " default invalid-project" + echo " }" + echo " reverse_proxy {upstream}:{http.request.host.labels.3}" + echo "}" } -# ── Submodule / LFS helpers ─────────────────────────────────────── -_has_submodules() { - [[ -n "$GIT_ROOT" && -f "${GIT_ROOT}/.gitmodules" ]] +# Host port the proxy actually publishes; empty string when it's 80. +_proxy_suffix() { + local p + p="$(docker port "$PROXY" 80/tcp 2>/dev/null | head -1)" + p="${p##*:}" + [[ -n "$p" && "$p" != "80" ]] && echo ":${p}" || true } -_has_lfs() { - [[ -n "$GIT_ROOT" ]] && git -C "$GIT_ROOT" lfs ls-files --size 2>/dev/null | head -1 | grep -q . 2>/dev/null +_ensure_proxy() { + mkdir -p "$CONFIG_DIR" + local file="${CONFIG_DIR}/Caddyfile" tmp="${CONFIG_DIR}/Caddyfile.$$" + # Truncate-write in place: the running proxy bind-mounts this exact inode, + # so replacing the file (mv) would detach it. Per-PID temp file: parallel + # aibox invocations each generate their own (identical) copy. + _gen_caddyfile > "$tmp" + if ! cmp -s "$tmp" "$file" 2>/dev/null; then + cat "$tmp" > "$file" + fi + rm -f "$tmp" + + local state + state="$(_state "$PROXY")" + case "$state" in + running) + docker exec "$PROXY" caddy reload --config /etc/caddy/Caddyfile >/dev/null 2>&1 \ + || docker restart "$PROXY" >/dev/null 2>&1 || true + ;; + absent) + local port="$PROXY_PORT" + if ! docker run -d --name "$PROXY" --network "$NETWORK" --restart unless-stopped \ + -p "127.0.0.1:${port}:80" \ + -v "${file}:/etc/caddy/Caddyfile:ro" \ + "$PROXY_IMAGE" >/dev/null 2>&1; then + docker rm -f "$PROXY" >/dev/null 2>&1 || true + if [[ "$port" == "80" ]]; then + port=8080 + _warn "Host port 80 unavailable — proxy on ${port} instead (URLs get :${port})." + docker run -d --name "$PROXY" --network "$NETWORK" --restart unless-stopped \ + -p "127.0.0.1:${port}:80" \ + -v "${file}:/etc/caddy/Caddyfile:ro" \ + "$PROXY_IMAGE" >/dev/null 2>&1 \ + || { _warn "Dev-server proxy failed to start; containers still work."; return 0; } + else + _warn "Dev-server proxy failed to start on port ${port}; containers still work." + return 0 + fi + fi + ;; + *) + docker start "$PROXY" >/dev/null 2>&1 || true + docker exec "$PROXY" caddy reload --config /etc/caddy/Caddyfile >/dev/null 2>&1 || true + ;; + esac } -# Post-checkout for a local directory (worktree) -_post_checkout_local() { - local target_dir="$1" - - if _has_submodules; then - _info "Initializing submodules..." - git -C "$target_dir" submodule update --init --recursive 2>/dev/null \ - && _ok "Submodules initialized." \ - || _warn "Some submodules could not be initialized." - fi - - if _has_lfs; then - _info "Fetching LFS objects..." - git -C "$target_dir" lfs pull 2>/dev/null \ - && _ok "LFS objects fetched." \ - || _warn "Some LFS objects could not be fetched." - fi -} +# ── Container ──────────────────────────────────────────────────── +CREATED=false -# Post-checkout inside a docker volume -_post_checkout_volume() { - local volume="$1" - local workspace="$2" +_ensure_container() { + docker network create "$NETWORK" >/dev/null 2>&1 || true + docker volume create "$VOLUME" >/dev/null 2>&1 || true - if _has_submodules; then - _info "Initializing submodules..." - docker run --rm --entrypoint sh \ - -v "${volume}:${workspace}" \ - "$IMAGE" -c " - cd '${workspace}' - git submodule update --init --recursive - chown -R aibox:aibox '${workspace}' - " 2>/dev/null \ - && _ok "Submodules initialized." \ - || _warn "Some submodules could not be initialized." - fi + local state + state="$(_state "$CONTAINER")" - if _has_lfs; then - _info "Fetching LFS objects..." - docker run --rm --entrypoint sh \ - -v "${volume}:${workspace}" \ - "$IMAGE" -c " - cd '${workspace}' - git lfs pull - chown -R aibox:aibox '${workspace}' - " 2>/dev/null \ - && _ok "LFS objects fetched." \ - || _warn "Some LFS objects could not be fetched." + # Recreate only when the image changed (new aibox version or node_version). + # The home volume and project bind survive by construction. + if [[ "$state" != "absent" ]]; then + local cur_img + cur_img="$(docker inspect "$CONTAINER" --format '{{.Config.Image}}' 2>/dev/null || echo '')" + if [[ "$cur_img" != "$IMAGE" ]]; then + local active=0 + if [[ "$state" == "running" ]]; then + active="$(docker top "$CONTAINER" -o pid,args 2>/dev/null | tail -n +2 | grep -vc 'sleep infinity' || true)" + active="${active//[^0-9]/}" + fi + if [[ "${active:-0}" -gt 0 ]]; then + _warn "Image updated (${cur_img} → ${IMAGE}) but ${active} session(s) active — keeping the old container for now." + else + _info "Image changed (${cur_img} → ${IMAGE}). Recreating container — sessions/login/project files persist; apt-installed packages reset (use ~/.aibox/Dockerfile.extra to keep them)." + docker rm -f "$CONTAINER" >/dev/null + state=absent + fi + fi fi -} -# ── Size estimation ─────────────────────────────────────────────── -_estimate_repo_size() { - # Estimate checkout size from git packed objects (rough: ~2x pack size for checkout) - local size_kb - size_kb=$(git -C "$GIT_ROOT" count-objects -v 2>/dev/null | awk '/size-pack/ {print $2}') - if [[ -n "$size_kb" && "$size_kb" =~ ^[0-9]+$ ]]; then - echo $(( size_kb * 2 )) # KB, rough estimate - fi + if [[ "$state" == "absent" ]]; then + _require_safe_dir + local suffix="" + [[ "$PROXY_PORT" != "80" ]] && suffix=":${PROXY_PORT}" + local run_err="" + # `|| true`: a second terminal may have created it in parallel — the + # running check below is what matters. + run_err="$(docker run -d --name "$CONTAINER" \ + --network "$NETWORK" \ + --restart unless-stopped \ + --add-host host.docker.internal:host-gateway \ + -v "${VOLUME}:/home/aibox" \ + -v "${PROJECT_DIR}:${PROJECT_DIR}" \ + -w "$PROJECT_DIR" \ + -e CLAUDE_CONFIG_DIR=/home/aibox/.claude \ + -e "AIBOX_URL_BASE=${SLUG}.aibox.localhost${suffix}" \ + -l "aibox.slug=${SLUG}" \ + -l "aibox.path=${PROJECT_DIR}" \ + "$IMAGE" 2>&1 >/dev/null)" || true + CREATED=true + elif [[ "$state" != "running" ]]; then + docker start "$CONTAINER" >/dev/null + fi + + local i=0 + until [[ "$(docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null)" == "true" ]]; do + (( i++ >= 25 )) && _die "Container failed to start.${run_err:+ ${run_err}}" + docker start "$CONTAINER" >/dev/null 2>&1 || true + sleep 0.2 + done } -_estimate_dir_size() { - local dir="$1" - # du -sk: total size in KB - # Try GNU du (--exclude), fall back to BSD du (-I), fall back to plain du - du -sk --exclude='node_modules' --exclude='.git' "$dir" 2>/dev/null | awk '{print $1}' \ - || du -sk -I 'node_modules' -I '.git' "$dir" 2>/dev/null | awk '{print $1}' \ - || du -sk "$dir" 2>/dev/null | awk '{print $1}' \ - || true +_ensure_claude_bin() { + docker exec -u aibox "$CONTAINER" test -x /home/aibox/.local/bin/claude 2>/dev/null && return 0 + _info "Installing Claude Code into the shared home volume (one-time)..." + docker exec -u aibox "$CONTAINER" bash -c 'curl -fsSL https://claude.ai/install.sh | bash' || true + docker exec -u aibox "$CONTAINER" test -x /home/aibox/.local/bin/claude 2>/dev/null \ + || _die "Claude Code install failed (network?). Retry, or run: aibox shell 'curl -fsSL https://claude.ai/install.sh | bash'" } -_format_size() { - local kb="$1" - if [[ -z "$kb" || ! "$kb" =~ ^[0-9]+$ ]]; then - echo "unknown size" - return - fi - if (( kb >= 1048576 )); then - echo "$(( kb / 1048576 ))GB" - elif (( kb >= 1024 )); then - echo "$(( kb / 1024 ))MB" - else - echo "${kb}KB" +_ensure_all() { + _ensure_docker + _ensure_image + _ensure_container + _ensure_proxy + if [[ "$CREATED" == "true" ]]; then + _ok "Container ready. Dev servers: http://.$(_pub_label).aibox.localhost$(_proxy_suffix)" fi } -_confirm_size() { - local size_kb="$1" what="$2" - if [[ -z "$size_kb" || ! "$size_kb" =~ ^[0-9]+$ ]]; then - return 0 # Can't estimate — don't block - fi - local formatted - formatted=$(_format_size "$size_kb") - if (( size_kb > 1048576 )); then - echo -e " Estimated size: ${_BY}~${formatted}${_N}" - else - echo -e " Estimated size: ${_G}~${formatted}${_N}" - fi - if (( size_kb > 1048576 )); then - # Over 1GB — ask for confirmation - if ! _confirm_yes "Continue with ${what}?"; then - echo "Aborted." - exit 0 - fi - fi +# ── exec plumbing ──────────────────────────────────────────────── +_dexec() { + local tty_args=(-i) + [[ -t 0 && -t 1 ]] && tty_args=(-it) + local env_args=( + -e "TERM=${TERM:-xterm-256color}" + -e "COLORTERM=${COLORTERM:-truecolor}" + -e "LANG=${LANG:-C.UTF-8}" + ) + local var + while IFS= read -r var; do + env_args+=(-e "${var}=${!var}") + done < <(env | grep -o '^ANTHROPIC_[A-Za-z0-9_]*' || true) + docker exec "${tty_args[@]}" -u aibox -w "$PROJECT_DIR" "${env_args[@]}" "$CONTAINER" "$@" } -# ── Isolation: copy mode ────────────────────────────────────────── - -_copy_via_git() { - local mount_path="$1" - local branch_name="aibox/${INSTANCE_NAME}" - - _confirm_size "$(_estimate_repo_size)" "copy" - _check_uncommitted - - _step "Copying repo into volume (branch: ${branch_name})" - - local bundle_file - bundle_file=$(mktemp) - if ! git -C "$GIT_ROOT" bundle create "$bundle_file" --all 2>/dev/null; then - rm -f "$bundle_file" - _cleanup_uncommitted - docker volume rm "$COPY_VOLUME" 2>/dev/null || true - _err "git bundle failed. Is this a git repository with commits?" - exit 1 - fi - - if ! cat "$bundle_file" | docker run --rm -i --entrypoint sh \ - -v "${COPY_VOLUME}:${mount_path}" \ - "$IMAGE" -c " - cat > /tmp/repo.bundle - mkdir -p '${mount_path}' - cd '${mount_path}' - git clone /tmp/repo.bundle . - rm /tmp/repo.bundle - git checkout -b '${branch_name}' 2>/dev/null || git checkout '${branch_name}' - chown -R aibox:aibox '${mount_path}' - "; then - rm -f "$bundle_file" - _cleanup_uncommitted - docker volume rm "$COPY_VOLUME" 2>/dev/null || true - _err "Failed to clone into volume." - exit 1 - fi - - rm -f "$bundle_file" - - _post_checkout_volume "$COPY_VOLUME" "$mount_path" - _apply_uncommitted_volume "$COPY_VOLUME" "$mount_path" - _cleanup_uncommitted +# ── Commands ───────────────────────────────────────────────────── +cmd_claude() { + _ensure_all + _ensure_claude_bin + local args=() has_skip=false a + for a in "$@"; do [[ "$a" == "--dangerously-skip-permissions" ]] && has_skip=true; done + [[ "$has_skip" == "false" ]] && args+=(--dangerously-skip-permissions) + args+=("$@") + _dexec claude "${args[@]}" } -_copy_via_tar() { - local mount_path="$1" - local source_dir="$2" - local use_git_archive=false - - # If inside a git repo, use git archive (respects .gitignore, excludes .git/) - if [[ -n "$GIT_ROOT" ]]; then - use_git_archive=true - fi - - _confirm_size "$(_estimate_dir_size "$source_dir")" "copy" - - _step "Copying folder into volume" - - if [[ "$use_git_archive" == "true" ]]; then - # Use git ls-files to get all tracked + untracked-but-not-ignored files - # This copies the live working state while respecting .gitignore - _info "Copying files (respects .gitignore, includes uncommitted changes)" - if ! (cd "$source_dir" && \ - { git ls-files -z; git ls-files -z --others --exclude-standard; } | \ - tar -cf - --null -T - 2>/dev/null) | docker run --rm -i --entrypoint sh \ - -v "${COPY_VOLUME}:${mount_path}" \ - "$IMAGE" -c " - mkdir -p '${mount_path}' - cd '${mount_path}' - tar -xf - - chown -R aibox:aibox '${mount_path}' - "; then - docker volume rm "$COPY_VOLUME" 2>/dev/null || true - _err "Failed to copy into volume." - exit 1 - fi +cmd_shell() { + _ensure_all + if [[ $# -eq 0 ]]; then + _dexec zsh + elif [[ $# -eq 1 ]]; then + _dexec zsh -lc "$1" else - _info "Copying files (excluding node_modules, .git)" - if ! tar -C "$source_dir" -cf - \ - --exclude='node_modules' \ - --exclude='.git' \ - . | docker run --rm -i --entrypoint sh \ - -v "${COPY_VOLUME}:${mount_path}" \ - "$IMAGE" -c " - mkdir -p '${mount_path}' - cd '${mount_path}' - tar -xf - - chown -R aibox:aibox '${mount_path}' - "; then - docker volume rm "$COPY_VOLUME" 2>/dev/null || true - _err "Failed to copy into volume." - exit 1 - fi + _dexec zsh -lc "$(printf '%q ' "$@")" fi } -_prepare_copy_volume() { - _check_disk_space 2 "copy volume" - - docker volume create "$COPY_VOLUME" >/dev/null 2>&1 || true - - # Check if volume already has content (--entrypoint bypasses su-exec drop) - local has_content - has_content=$(docker run --rm --entrypoint sh -v "${COPY_VOLUME}:/mnt/check" "$IMAGE" -c "ls -A /mnt/check 2>/dev/null | head -1" 2>/dev/null || true) - - if [[ -n "$has_content" ]]; then - _info "Copy volume already populated ($(_file "$COPY_VOLUME")). Reusing." - return - fi - - if [[ -z "$GIT_ROOT" ]]; then - # Not a git repo — tar copy of the folder - _copy_via_tar "$WORKSPACE_DIR" "$PROJECT_DIR" - elif [[ "$GIT_ROOT" == "$PROJECT_DIR" ]]; then - # At repo root — git bundle - _copy_via_git "$WORKSPACE_DIR" +cmd_stop() { + _ensure_docker + if [[ "${1:-}" == "--all" ]]; then + local names + names="$(docker ps --filter 'name=^aibox-' --format '{{.Names}}')" + [[ -z "$names" ]] && { _info "No aibox containers running."; return 0; } + echo "$names" | xargs docker stop >/dev/null + _ok "Stopped: $(echo "$names" | tr '\n' ' ')(state preserved)" + elif docker ps --format '{{.Names}}' | grep -Fxq "$CONTAINER"; then + docker stop "$CONTAINER" >/dev/null + _ok "Stopped ${CONTAINER} (state preserved — run aibox to start it again)." else - # In a subfolder of a git repo — ask - echo "" - _info "You're in a subfolder of a git repo." - echo -e " Repo root: $(_file "$GIT_ROOT")" - echo -e " Current folder: $(_file "$PROJECT_DIR")" - echo "" - if _confirm_yes "Copy the full repo? (No = copy only this folder)"; then - _copy_via_git "$WORKSPACE_DIR" - else - _copy_via_tar "$WORKSPACE_DIR" "$PROJECT_DIR" - fi + _info "Not running: ${CONTAINER}" fi - - _ok "Copied to volume $(_file "$COPY_VOLUME")" } -# ── Isolation: worktree mode ───────────────────────────────────── -_prepare_worktree() { - if [[ -z "$GIT_ROOT" ]]; then - _err "${_B}--worktree${_N} requires a git repository." - exit 1 - fi - - local branch_name="aibox/${INSTANCE_NAME}" - - if [[ -d "$WORKTREE_DIR" ]]; then - _info "Worktree already exists ($(_file "$WORKTREE_DIR")). Reusing." - return +cmd_status() { + _ensure_docker + local rows + rows="$(docker ps -a --filter label=aibox.slug \ + --format '{{.Names}}\t{{.State}}\t{{.Image}}\t{{.Label "aibox.path"}}' 2>/dev/null)" + if [[ -z "$rows" ]]; then + echo "No aibox containers. Run aibox in a project directory to start one." + else + local suffix key name + suffix="$(_proxy_suffix)" + printf "%-34s %-9s %-22s %s\n" "CONTAINER" "STATE" "DEV URL" "PROJECT" + while IFS=$'\t' read -r cname cstate cimage cpath; do + local url="-" + while read -r key name; do + [[ "$name" == "$cname" ]] && url=".${key}.aibox.localhost${suffix}" + done < <(_proxy_pairs) + printf "%-34s %-9s %-22s %s\n" "$cname" "$cstate" "$url" "$cpath" + [[ "$cimage" != "$IMAGE" ]] && _info " ${cname}: image ${cimage} (current: ${IMAGE} — recreated on next run)" + done <<< "$rows" fi + echo "" + local pstate + pstate="$(_state "$PROXY")" + echo "Proxy: ${pstate}$( [[ "$pstate" == running ]] && echo " (http://..aibox.localhost$(_proxy_suffix))" )" + if docker volume inspect "$VOLUME" >/dev/null 2>&1; then + local size + size="$(docker run --rm -v "${VOLUME}:/v:ro" alpine du -sh /v 2>/dev/null | cut -f1 || echo '?')" + echo "Home volume: ${VOLUME} (${size:-?}) — sessions, login, claude binary. Protect with: aibox backup" + fi +} + +_do_backup() { + local dir="$1" prefix="${2:-aibox-home}" + mkdir -p "$dir" + dir="$(cd "$dir" && pwd)" + local name="${prefix}-${IMG_VER}-$(date -u +%Y%m%d-%H%M%S).tar.gz" + docker run --rm -v "${VOLUME}:/home/aibox:ro" -v "${dir}:/backup" alpine \ + tar czf "/backup/${name}" -C /home/aibox . >/dev/null + echo "${dir}/${name}" +} + +cmd_backup() { + _ensure_docker + docker volume inspect "$VOLUME" >/dev/null 2>&1 \ + || _die "No ${VOLUME} volume yet — nothing to back up." + local out + out="$(_do_backup "${1:-$BACKUP_DIR}")" + _ok "Backup written: ${out} ($(du -h "$out" | cut -f1))" +} + +cmd_restore() { + [[ $# -ge 1 ]] || _die "Usage: aibox restore " + local file="$1" + [[ -f "$file" ]] || _die "Not found: ${file}" + file="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" + _ensure_docker + docker volume create "$VOLUME" >/dev/null 2>&1 || true + + echo "This replaces the contents of ${VOLUME} (sessions, login, claude binary)" + echo "with: ${file}" + echo "A safety backup of the current state is taken first." + _confirm "Restore?" || { echo "Aborted."; return 0; } - _confirm_size "$(_estimate_repo_size)" "worktree" - _check_uncommitted - - mkdir -p "${CONFIG_DIR}/worktrees" - - _step "Creating worktree (branch: ${branch_name})" + local running + running="$(docker ps --filter "volume=${VOLUME}" --format '{{.Names}}')" + [[ -n "$running" ]] && { _info "Stopping: $(echo "$running" | tr '\n' ' ')"; echo "$running" | xargs docker stop >/dev/null; } - # Try creating with a new branch, fall back to existing branch - if git -C "$GIT_ROOT" worktree add "$WORKTREE_DIR" -b "$branch_name" 2>/dev/null; then - true - elif git -C "$GIT_ROOT" worktree add "$WORKTREE_DIR" "$branch_name" 2>/dev/null; then - true - else - _cleanup_uncommitted - _err "Failed to create worktree. Is this a git repository?" - exit 1 + local has_data + has_data="$(docker run --rm -v "${VOLUME}:/v:ro" alpine sh -c 'ls -A /v 2>/dev/null | head -1')" + if [[ -n "$has_data" ]]; then + local safety + safety="$(_do_backup "$BACKUP_DIR" "aibox-home-pre-restore")" + _ok "Safety backup: ${safety}" fi - _post_checkout_local "$WORKTREE_DIR" - _apply_uncommitted_local "$WORKTREE_DIR" - _cleanup_uncommitted + docker run --rm -v "${VOLUME}:/v" -v "$(dirname "$file"):/backup:ro" alpine \ + sh -c "find /v -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf '/backup/$(basename "$file")' -C /v" + _ok "Restored." - _ok "Created worktree at $(_file "$WORKTREE_DIR")" + [[ -n "$running" ]] && { echo "$running" | xargs docker start >/dev/null; _ok "Restarted: $(echo "$running" | tr '\n' ' ')"; } } -# ── Compose YAML generation ────────────────────────────────────── -# Auth volume shared across all containers using the same base image. -# name: field prevents docker compose from prefixing with project name. -AUTH_VOLUME="aibox-auth-$(echo "$IMAGE" | sed 's/[^a-zA-Z0-9]/-/g')" - -_volumes_yaml() { - local indent="$1" - local prefix="$2" - - if [[ "$ISOLATION" == "copy" ]]; then - echo "${indent}- ${COPY_VOLUME}:${WORKSPACE_DIR}" - elif [[ "$ISOLATION" == "worktree" ]]; then - echo "${indent}- \"${WORKTREE_DIR}:${WORKSPACE_DIR}\"" +cmd_update() { + local script_path + script_path="$(realpath "$0" 2>/dev/null || readlink -f "$0" 2>/dev/null || echo "$0")" + if [[ "$script_path" == */Cellar/* || "$script_path" == */homebrew/* ]]; then + command -v brew >/dev/null 2>&1 || _die "Installed via Homebrew but brew not found. Run: brew upgrade aibox" + _info "Updating via Homebrew..." + brew update && brew upgrade aibox + elif command -v npm >/dev/null 2>&1 && npm list -g aibox-cli >/dev/null 2>&1; then + _info "Updating via npm..." + npm update -g aibox-cli else - echo "${indent}- \"${prefix}:${WORKSPACE_DIR}\"" - fi - echo "${indent}- ${AUTH_VOLUME}:/home/aibox/.claude" - - # Mount host's IDE lock files so Claude Code can discover JetBrains/VS Code plugins - local host_ide_dir="${HOME}/.claude/ide" - if [[ -d "$host_ide_dir" ]]; then - echo "${indent}- \"${host_ide_dir}:/home/aibox/.claude/ide:ro\"" - fi - - if [[ "$SHARED_MODULES" == "false" && -z "$ISOLATION" ]]; then - echo "${indent}- node_modules:${WORKSPACE_DIR}/node_modules" + _die "Could not detect install method. Update manually: brew upgrade aibox / npm update -g aibox-cli" fi + _ok "Updated. The image rebuilds and containers recreate automatically on your next aibox run (sessions and login persist)." } -_top_volumes_yaml() { - printf "volumes:\n" - printf " ${AUTH_VOLUME}:\n" - printf " external: true\n" - if [[ "$ISOLATION" == "copy" ]]; then - printf " ${COPY_VOLUME}:\n" - printf " external: true\n" - fi - if [[ "$SHARED_MODULES" == "false" && -z "$ISOLATION" ]]; then - printf " node_modules:\n" - fi +cmd_version() { + echo "aibox v${CLI_VERSION} (image ${IMAGE})" } -_environment_yaml() { - local indent="$1" - echo "${indent}- ANTHROPIC_API_KEY=\${ANTHROPIC_API_KEY:-}" - echo "${indent}- CLAUDE_CONFIG_DIR=/home/aibox/.claude" - echo "${indent}- AIBOX_MODE=\${AIBOX_MODE:-safe}" - echo "${indent}- AIBOX_EXTRA_DOMAINS=\${AIBOX_EXTRA_DOMAINS:-}" - echo "${indent}- AIBOX_WORKSPACE=${WORKSPACE_DIR}" +cmd_help() { + awk '/^# aibox/,/^[^#]/{if(/^#/) print}' "$0" | sed 's/^# \{0,1\}//' } -# Build -e flags for docker exec from current terminal environment -_docker_exec() { - # Usage: _docker_exec container command [args...] - local container="$1" - shift - - local env_args=( - -e "TERM=${TERM:-xterm-256color}" - -e "COLORTERM=${COLORTERM:-truecolor}" - -e "LANG=${LANG:-C.UTF-8}" - ) - - # Forward Claude Code env vars if set on host - local forward_vars=( - ANTHROPIC_MODEL - ANTHROPIC_BASE_URL - CLAUDE_CODE_USE_BEDROCK - CLAUDE_CODE_USE_VERTEX - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC - ) - for var in "${forward_vars[@]}"; do - local val - eval "val=\${$var:-}" - [[ -n "$val" ]] && env_args+=(-e "$var=$val") - done - - # Forward JetBrains / VS Code IDE integration env vars - if [[ "${ENABLE_IDE_INTEGRATION:-}" == "true" ]]; then - env_args+=(-e "ENABLE_IDE_INTEGRATION=true") - env_args+=(-e "TERMINAL_EMULATOR=${TERMINAL_EMULATOR:-}") - [[ -n "${CLAUDE_CODE_SSE_PORT:-}" ]] && env_args+=(-e "CLAUDE_CODE_SSE_PORT=${CLAUDE_CODE_SSE_PORT}") - [[ -n "${TERM_SESSION_ID:-}" ]] && env_args+=(-e "TERM_SESSION_ID=${TERM_SESSION_ID}") - env_args+=(-e "CLAUDE_CODE_IDE_HOST_OVERRIDE=host.docker.internal") - fi - - docker exec -it -u aibox "${env_args[@]}" "$container" "$@" -} - -# Auto-stop container if no active exec sessions remain -_maybe_stop_container() { - local container="$1" - # Check if container is still running - if ! docker ps --format '{{.Names}}' | grep -Fxq "$container" 2>/dev/null; then - return 0 - fi - # Count processes that are NOT "sleep infinity" (the idle CMD) - local active - active=$(docker top "$container" -o pid,args 2>/dev/null \ - | tail -n +2 \ - | grep -v -c 'sleep infinity' || echo "0") - active=$(echo "$active" | tr -d '[:space:]') - - if [[ "$active" -le 0 ]]; then - _info "No active sessions. Stopping container..." - dc down --remove-orphans >/dev/null 2>&1 - _ok "Container stopped." - fi -} - -_labels_yaml() { - local indent="$1" - echo "${indent}aibox.project: \"${PROJECT_NAME}\"" - echo "${indent}aibox.path: \"${PROJECT_DIR}\"" - echo "${indent}aibox.instance: \"${INSTANCE_NAME}\"" - echo "${indent}aibox.isolation: \"${ISOLATION}\"" -} - -generate_compose() { - cat << YAML -name: "${CONTAINER_NAME}" -services: - dev: - image: ${IMAGE} - container_name: ${CONTAINER_NAME} - cap_add: - - NET_ADMIN - extra_hosts: - - "host.docker.internal:host-gateway" - labels: -$(_labels_yaml " ") - volumes: -$(_volumes_yaml " " "$PROJECT_DIR") - working_dir: ${WORKSPACE_DIR} - environment: -$(_environment_yaml " ") - stdin_open: true - tty: true -$(_top_volumes_yaml) -YAML -} - -write_project_compose() { - local target="${PROJECT_DIR}/compose.dev.yaml" - - cat > "$target" << YAML -# Generated by ${SCRIPT_NAME} — point WebStorm Node.js interpreter here. -# Settings → Node.js → Docker Compose → this file → service: dev -name: "${CONTAINER_NAME}" -services: - dev: - image: ${IMAGE} - container_name: ${CONTAINER_NAME} - cap_add: - - NET_ADMIN - extra_hosts: - - "host.docker.internal:host-gateway" - labels: -$(_labels_yaml " ") - volumes: -$(_volumes_yaml " " ".") - working_dir: ${WORKSPACE_DIR} - environment: -$(_environment_yaml " ") - stdin_open: true - tty: true -$(_top_volumes_yaml) -YAML - echo "Created ${target}" -} - -# ── WebStorm .idea/workspace.xml ───────────────────────────────── -_sed_inplace() { - if sed --version 2>/dev/null | grep -q 'GNU'; then - sed -i "$@" - else - sed -i '' "$@" - fi -} - -configure_webstorm() { - local idea_dir="${PROJECT_DIR}/.idea" - local ws_file="${idea_dir}/workspace.xml" - local compose_path="${PROJECT_DIR}/compose.dev.yaml" - local interpreter_value="docker-compose://[${compose_path}]:dev//usr/local/bin/node" - - mkdir -p "$idea_dir" - - if [[ -f "$ws_file" ]]; then - if grep -q '"nodejs_interpreter_path"' "$ws_file"; then - _sed_inplace "s|\"nodejs_interpreter_path\": \"[^\"]*\"|\"nodejs_interpreter_path\": \"${interpreter_value}\"|" "$ws_file" - echo "Updated nodejs_interpreter_path in .idea/workspace.xml" - elif grep -q '"keyToString"' "$ws_file"; then - local tmpfile - tmpfile=$(mktemp) - awk -v interp="$interpreter_value" ' - /"keyToString": \{/ { - print - print " \"nodejs_interpreter_path\": \"" interp "\"," - print " \"nodejs_package_manager_path\": \"npm\"," - print " \"javascript.preferred.runtime.type.id\": \"node\"," - print " \"credentialsType com.jetbrains.nodejs.remote.NodeJSCreateRemoteSdkForm\": \"Docker Compose\"," - next - } - { print } - ' "$ws_file" > "$tmpfile" - mv "$tmpfile" "$ws_file" - echo "Injected Node.js Docker Compose config into .idea/workspace.xml" - else - echo "Warning: workspace.xml has unexpected format. Configure manually:" - echo " Settings → Node.js → Docker Compose → compose.dev.yaml → service: dev" - fi - else - cat > "$ws_file" << WSXML - - - - -WSXML - echo "Created .idea/workspace.xml with Docker Compose Node.js runtime" - fi -} - -# ── Docker Compose wrapper ─────────────────────────────────────── -dc() { - generate_compose | docker compose \ - -f - \ - --project-directory "$PROJECT_DIR" \ - "$@" -} - -# ── Commands ───────────────────────────────────────────────────── - -cmd_build() { - _check_deps - _check_disk_space 5 "Docker build" - ensure_dockerfile - _step "Building ${IMAGE}" - docker build -t "$IMAGE" "$CONFIG_DIR" - _ok "Image built." -} - -ensure_init() { - if [[ ! -f "${PROJECT_DIR}/compose.dev.yaml" ]]; then - _require_safe_dir - _step "Initializing project (one-time)" - echo "Will create compose.dev.yaml, .aibox, and .idea/workspace.xml." - if _confirm_yes "Initialize?"; then - _init_files - echo "" - else - echo "Aborted. Run '${SCRIPT_NAME} init' manually when ready." >&2 - exit 1 - fi - fi -} - -cmd_up() { - _check_deps - ensure_init - - if ! docker image inspect "$IMAGE" &>/dev/null; then - echo "Image '${IMAGE}' not found. Building (one-time)..." - cmd_build - else - # Check if image version matches script version - local img_version - img_version=$(docker inspect "$IMAGE" --format '{{index .Config.Labels "aibox.version"}}' 2>/dev/null || echo "") - if [[ "$img_version" != "$AIBOX_VERSION" ]]; then - echo "Image outdated (v${img_version:-0} → v${AIBOX_VERSION}). Rebuilding (one-time)..." - cmd_build - fi - fi - - # Check for sensitive files in bind mount mode (only for new containers) - if [[ -z "$ISOLATION" ]] && ! docker ps --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME" 2>/dev/null; then - local sensitive_files=() - local sensitive_patterns=(".env" ".env.*" - "credentials.json" "service-account*.json" - ".npmrc" ".pypirc" "id_rsa" "id_ed25519") - for pattern in "${sensitive_patterns[@]}"; do - while IFS= read -r -d '' f; do - sensitive_files+=("${f#"$PROJECT_DIR"/}") - done < <(find "$PROJECT_DIR" -maxdepth 2 -name "$pattern" -not -path '*/node_modules/*' -not -path '*/.git/*' -print0 2>/dev/null || true) - done - - if [[ ${#sensitive_files[@]} -gt 0 ]]; then - echo "" - _warn "Sensitive files found in project directory:" - for f in "${sensitive_files[@]}"; do - echo -e " $(_file "$f")" - done - echo "" - echo -e " These will be visible inside the container (bind mount)." - echo -e " The network firewall (safe mode) limits exfiltration, but for" - echo -e " stronger isolation use ${_B}--copy${_N} or ${_B}--worktree${_N} instead." - echo "" - if ! _confirm_yes "Continue with bind mount?"; then - echo -e " Re-run with ${_B}--copy${_N} or ${_B}--worktree${_N}." - exit 0 - fi - fi - fi - - # Warn if bind-mounting a subfolder (no .git in container) - if [[ -z "$ISOLATION" && -n "$GIT_ROOT" && "$GIT_ROOT" != "$PROJECT_DIR" ]]; then - _info "Mounting subfolder $(_file "${PROJECT_DIR#"$GIT_ROOT"/}")" - echo -e " Git won't work inside the container (.git/ is in the parent)." - echo -e " Use ${_B}--copy${_N} or ${_B}--worktree${_N} for full repo access, or run from the repo root." - fi - - # Prepare isolation volumes/worktrees if needed - if [[ "$ISOLATION" == "copy" ]]; then - _prepare_copy_volume - elif [[ "$ISOLATION" == "worktree" ]]; then - _prepare_worktree - fi - - if ! docker ps --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then - # Ensure shared auth volume exists (external: true requires it) - docker volume create "$AUTH_VOLUME" &>/dev/null || true - _step "Starting ${CONTAINER_NAME}" - dc up -d - _ok "Container running." - else - _ok "$(_file "$CONTAINER_NAME") is already running." - fi -} - -cmd_claude() { - local skip=false - local firewall=true - - # Check if --dangerously-skip-permissions was passed directly as a claude arg - local has_skip_flag=false - for arg in "$@"; do - [[ "$arg" == "--dangerously-skip-permissions" ]] && has_skip_flag=true - done - - if [[ "$SKIP_PERMISSIONS" == "true" || "$has_skip_flag" == "true" ]]; then - # --yolo or explicit --dangerously-skip-permissions: everything loose - skip=true - firewall=false - export AIBOX_MODE="yolo" - elif [[ "$SAFE_MODE" == "true" ]]; then - # --safe: everything locked - skip=false - firewall=true - export AIBOX_MODE="safe" - else - # No flag: ask about each setting - echo "" - echo "Container settings (per-session, not saved):" - echo "" - - if _confirm_yes "Skip Claude Code permission prompts? (container is sandboxed)"; then - skip=true - fi - - if _confirm_yes "Enable network firewall? (blocks all except Claude API, npm, GitHub, PyPI)"; then - firewall=true - else - firewall=false - fi - - if [[ "$firewall" == "true" ]]; then - export AIBOX_MODE="safe" - else - export AIBOX_MODE="yolo" - fi - - echo "" - fi - - # If container is running in a different mode, restart it - if docker ps --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then - local current_mode - current_mode=$(docker exec "$CONTAINER_NAME" sh -c 'echo ${AIBOX_MODE:-safe}' 2>/dev/null || echo "unknown") - if [[ "$current_mode" != "$AIBOX_MODE" ]]; then - _step "Restarting container in ${AIBOX_MODE} mode (was: ${current_mode})" - dc down --remove-orphans >/dev/null - fi - fi - - cmd_up - - local claude_args=() - if [[ "$skip" == "true" && "$has_skip_flag" == "false" ]]; then - claude_args+=(--dangerously-skip-permissions) - fi - # Forward all remaining args to claude (--resume, --print, --model, etc.) - if [[ $# -gt 0 ]]; then - claude_args+=("$@") - fi - - local _exec_rc=0 - if [[ ${#claude_args[@]} -gt 0 ]]; then - _docker_exec "$CONTAINER_NAME" claude "${claude_args[@]}" || _exec_rc=$? - else - _docker_exec "$CONTAINER_NAME" claude || _exec_rc=$? - fi - _maybe_stop_container "$CONTAINER_NAME" - return "$_exec_rc" -} - -cmd_shell() { - if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -Fxq "$CONTAINER_NAME"; then - cmd_up - fi - - local _exec_rc=0 - shift # remove "shell" from args - if [[ $# -gt 0 ]]; then - # Inline command: aibox shell echo hello - _docker_exec "$CONTAINER_NAME" zsh -c "$*" || _exec_rc=$? - else - _docker_exec "$CONTAINER_NAME" zsh || _exec_rc=$? - fi - _maybe_stop_container "$CONTAINER_NAME" - return "$_exec_rc" -} - -# ── Port forwarding ────────────────────────────────────────────── -PF_PREFIX="aibox-pf" - -_pf_container_name() { - # aibox-pf-{target_container}-{host_port} - echo "${PF_PREFIX}-${CONTAINER_NAME}-${1}" -} - -_pf_list() { - local pf_containers - pf_containers=$(docker ps -a --filter "name=${PF_PREFIX}-${CONTAINER_NAME}-" --format '{{.Names}} {{.Status}}' 2>/dev/null) || true - if [[ -z "$pf_containers" ]]; then - echo "No active port forwards for ${CONTAINER_NAME}" - return - fi - printf "%-40s %-8s %-8s %s\n" "FORWARDER" "HOST" "TARGET" "STATUS" - printf "%-40s %-8s %-8s %s\n" "---------" "----" "------" "------" - while IFS= read -r line; do - local name status - name=$(echo "$line" | awk '{print $1}') - status=$(echo "$line" | cut -d' ' -f2-) - local host_port target_port - host_port=$(docker inspect "$name" --format '{{index .Config.Labels "aibox.pf.host_port"}}' 2>/dev/null || echo "?") - target_port=$(docker inspect "$name" --format '{{index .Config.Labels "aibox.pf.container_port"}}' 2>/dev/null || echo "?") - printf "%-40s %-8s %-8s %s\n" "$name" "$host_port" "$target_port" "$status" - done <<< "$pf_containers" -} - -_pf_stop() { - local host_port="$1" - local pf_name - pf_name=$(_pf_container_name "$host_port") - if docker ps -a --format '{{.Names}}' | grep -Fxq "$pf_name"; then - docker rm -f "$pf_name" >/dev/null - _ok "Stopped port forward :${host_port}" - else - _err "No port forward found on host port ${host_port}" - return 1 - fi -} - -_pf_stop_all() { - local pf_containers - pf_containers=$(docker ps -a --filter "name=${PF_PREFIX}-${CONTAINER_NAME}-" --format '{{.Names}}' 2>/dev/null) || true - if [[ -z "$pf_containers" ]]; then - echo "No active port forwards for ${CONTAINER_NAME}" - return - fi - echo "$pf_containers" | xargs docker rm -f >/dev/null - _ok "Stopped all port forwards for ${CONTAINER_NAME}" -} - -_pf_start() { - local spec="$1" - local host_port container_port - - if [[ "$spec" == *:* ]]; then - host_port="${spec%%:*}" - container_port="${spec##*:}" - else - host_port="$spec" - container_port="$spec" - fi - - # Validate ports are numbers - if ! [[ "$host_port" =~ ^[0-9]+$ ]] || ! [[ "$container_port" =~ ^[0-9]+$ ]]; then - _err "Invalid port spec: ${spec} (expected PORT or HOST_PORT:CONTAINER_PORT)" - return 1 - fi - - local pf_name - pf_name=$(_pf_container_name "$host_port") - - # Check if already forwarding (running) - if docker ps --format '{{.Names}}' | grep -Fxq "$pf_name"; then - _info "Already forwarding host:${host_port} → container:${container_port}" - return 0 - fi - - # Remove stale/stopped sidecar with the same name - if docker ps -a --format '{{.Names}}' | grep -Fxq "$pf_name"; then - docker rm -f "$pf_name" >/dev/null - fi - - # Get the compose network name (take first if multiple) - local network - network=$(docker inspect "$CONTAINER_NAME" --format '{{range $net,$v := .NetworkSettings.Networks}}{{$net}}{{"\n"}}{{end}}' 2>/dev/null | head -1) - if [[ -z "$network" ]]; then - _err "Cannot determine network for ${CONTAINER_NAME}" - return 1 - fi - - # Pull socat image if needed (only first time) - if ! docker image inspect alpine/socat &>/dev/null; then - _info "Pulling alpine/socat image..." - docker pull alpine/socat >/dev/null - fi - - # Start sidecar socat container on the same network - # Use container name as DNS target (compose networks register containers by name) - if ! docker run -d \ - --name "$pf_name" \ - --network "$network" \ - --publish "${host_port}:${host_port}" \ - --label "aibox.pf.target=${CONTAINER_NAME}" \ - --label "aibox.pf.host_port=${host_port}" \ - --label "aibox.pf.container_port=${container_port}" \ - --restart unless-stopped \ - alpine/socat \ - "TCP-LISTEN:${host_port},fork,reuseaddr" "TCP-CONNECT:${CONTAINER_NAME}:${container_port}" \ - >/dev/null 2>&1; then - _err "Failed to forward port ${host_port} (is it already in use?)" - return 1 - fi - - _ok "Forwarding host:${host_port} → container:${container_port}" -} - -_pf_cleanup() { - # Remove all port-forward sidecars for a container - local target_name="$1" - local pf_containers - pf_containers=$(docker ps -a --filter "name=${PF_PREFIX}-${target_name}-" --format '{{.Names}}' 2>/dev/null) || true - if [[ -n "$pf_containers" ]]; then - echo "$pf_containers" | xargs docker rm -f >/dev/null - _ok "Removed port forwards" - fi -} - -cmd_port_forward() { - shift # remove "port-forward" from args - - # Handle flags - case "${1:-}" in - --list|-l) - _pf_list - return - ;; - --stop-all) - _pf_stop_all - return - ;; - --stop) - shift - if [[ -z "${1:-}" ]]; then - _err "Usage: ${SCRIPT_NAME} port-forward --stop PORT" - return 1 - fi - _pf_stop "$1" - return - ;; - "") - _err "Usage: ${SCRIPT_NAME} port-forward PORT [PORT...]" - echo " PORT can be HOST_PORT:CONTAINER_PORT or just PORT (same for both)" - echo "" - echo " Examples:" - echo " ${SCRIPT_NAME} port-forward 3000" - echo " ${SCRIPT_NAME} port-forward 8080:3000" - echo " ${SCRIPT_NAME} port-forward 3000 5173" - echo "" - echo " Management:" - echo " ${SCRIPT_NAME} port-forward --list" - echo " ${SCRIPT_NAME} port-forward --stop 3000" - echo " ${SCRIPT_NAME} port-forward --stop-all" - return 1 - ;; - esac - - # Ensure container is running - if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -Fxq "$CONTAINER_NAME"; then - cmd_up - fi - - # Forward each port (don't exit on failure so remaining ports still get tried) - local _pf_failed=0 - for spec in "$@"; do - [[ "$spec" == -* ]] && continue - _pf_start "$spec" || _pf_failed=1 - done - return "$_pf_failed" -} - -cmd_down() { - if [[ "$DOWN_ALL" == "true" ]]; then - local project_containers - project_containers=$(docker ps -a --format '{{.Names}}' | grep -F "$BASE_CONTAINER_NAME" | grep -v "^${PF_PREFIX}-") || true - if [[ -n "$project_containers" ]]; then - echo "Will remove all containers for ${PROJECT_NAME}:" - echo "$project_containers" | sed 's/^/ /' - echo "" - if _confirm_no "Remove all?"; then - # Clean up port-forward sidecars first - while IFS= read -r cname; do - _pf_cleanup "$cname" - done <<< "$project_containers" - echo "$project_containers" | xargs docker rm -f - echo "Removed all containers for ${PROJECT_NAME}" - if [[ "$DOWN_CLEAN" == "true" ]]; then - # Clean up copy volumes and worktrees for removed containers - while IFS= read -r cname; do - docker volume rm "${cname}-src" 2>/dev/null && echo "Removed copy volume ${cname}-src" || true - local wt="${CONFIG_DIR}/worktrees/${cname}" - if [[ -d "$wt" ]]; then - if [[ -n "$GIT_ROOT" ]]; then - git -C "$GIT_ROOT" worktree remove "$wt" 2>/dev/null && echo "Removed worktree ${wt}" || true - else - echo "Warning: cannot remove worktree ${wt} (not in a git repo). Remove manually." - fi - fi - done <<< "$project_containers" - else - # Check if any had isolation resources - local has_isolation=false - while IFS= read -r cname; do - if docker volume inspect "${cname}-src" &>/dev/null || [[ -d "${CONFIG_DIR}/worktrees/${cname}" ]]; then - has_isolation=true - break - fi - done <<< "$project_containers" - if [[ "$has_isolation" == "true" ]]; then - echo "Note: copy volumes / worktrees kept. Use --clean to remove." - fi - fi - else - echo "Aborted." - fi - else - echo "No containers found for ${PROJECT_NAME}" - fi - else - _pf_cleanup "$CONTAINER_NAME" - if docker ps -a --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then - dc down --remove-orphans - _ok "Removed $(_file "$CONTAINER_NAME")" - else - _info "No container found: $(_file "$CONTAINER_NAME")" - fi - # Clean up isolation resources (works even if container is already gone) - if [[ "$DOWN_CLEAN" == "true" ]]; then - if [[ -n "$COPY_VOLUME" ]]; then - docker volume rm "$COPY_VOLUME" 2>/dev/null && echo "Removed copy volume ${COPY_VOLUME}" || true - fi - if [[ -n "$WORKTREE_DIR" && -d "$WORKTREE_DIR" ]]; then - if [[ -n "$GIT_ROOT" ]]; then - git -C "$GIT_ROOT" worktree remove "$WORKTREE_DIR" 2>/dev/null && echo "Removed worktree ${WORKTREE_DIR}" || true - else - echo "Warning: cannot remove worktree ${WORKTREE_DIR} (not in a git repo). Remove manually." - fi - fi - elif [[ "$ISOLATION" == "copy" && -n "$COPY_VOLUME" ]] && docker volume inspect "$COPY_VOLUME" &>/dev/null; then - echo "Copy volume kept: ${COPY_VOLUME} (use --clean to remove)" - elif [[ "$ISOLATION" == "worktree" && -n "$WORKTREE_DIR" && -d "$WORKTREE_DIR" ]]; then - echo "Worktree kept: ${WORKTREE_DIR} (use --clean to remove)" - fi - fi -} - -cmd_status() { - local containers - containers=$(docker ps -a --filter "name=${CONTAINER_PREFIX}-" -q) || true - - if [[ -z "$containers" ]]; then - echo "No ${SCRIPT_NAME} containers found." - return - fi - - printf "%-30s %-10s %-10s %-6s %-8s %s\n" "CONTAINER" "INSTANCE" "ISOLATION" "MODE" "STATUS" "PROJECT PATH" - printf "%-30s %-10s %-10s %-6s %-8s %s\n" "---------" "--------" "---------" "----" "------" "------------" - - docker ps -a \ - --filter "name=${CONTAINER_PREFIX}-" \ - --format '{{.Names}}' \ - | grep -v "^${PF_PREFIX}-" \ - | while IFS= read -r name; do - local instance isolation mode state path - instance=$(docker inspect "$name" --format '{{index .Config.Labels "aibox.instance"}}' 2>/dev/null || echo "") - isolation=$(docker inspect "$name" --format '{{index .Config.Labels "aibox.isolation"}}' 2>/dev/null || echo "") - state=$(docker inspect "$name" --format '{{.State.Status}}' 2>/dev/null || echo "") - path=$(docker inspect "$name" --format '{{index .Config.Labels "aibox.path"}}' 2>/dev/null || echo "") - # Extract AIBOX_MODE from container env - mode=$(docker inspect "$name" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep '^AIBOX_MODE=' | cut -d= -f2- || echo "") - [[ -z "$instance" ]] && instance="-" - [[ -z "$isolation" ]] && isolation="-" - [[ -z "$mode" ]] && mode="-" - [[ -z "$path" ]] && path="-" - printf "%-30s %-10s %-10s %-6s %-8s %s\n" "$name" "$instance" "$isolation" "$mode" "$state" "$path" - done -} - -cmd_nuke() { - local containers - containers=$(docker ps -a --filter "name=${CONTAINER_PREFIX}-" -q) || true - if [[ -n "$containers" ]]; then - echo "This will remove ALL ${SCRIPT_NAME} containers across ALL projects:" - docker ps -a --filter "name=${CONTAINER_PREFIX}-" --format " {{.Names}} ({{.Status}})" - echo "" - if _confirm_no "Remove all?"; then - docker ps -a --filter "name=${CONTAINER_PREFIX}-" -q | xargs docker rm -f - echo "All containers removed." - else - echo "Aborted." - fi - else - echo "No ${SCRIPT_NAME} containers found." - fi -} - -cmd_volumes() { - # List copy volumes - local volumes - volumes=$(docker volume ls --format '{{.Name}}' | grep -F "${CONTAINER_PREFIX}-" | grep -- '-src$') || true - - # List worktree dirs - local worktree_dir="${CONFIG_DIR}/worktrees" - local worktrees="" - if [[ -d "$worktree_dir" ]]; then - worktrees=$(ls -1 "$worktree_dir" 2>/dev/null) || true - fi - - if [[ -z "$volumes" && -z "$worktrees" ]]; then - echo "No isolation volumes or worktrees found." - return - fi - - printf "%-50s %-10s %-8s %s\n" "NAME" "TYPE" "STATUS" "CREATED" - printf "%-50s %-10s %-8s %s\n" "----" "----" "------" "-------" - - if [[ -n "$volumes" ]]; then - while IFS= read -r vol; do - local status="orphan" - local container_name="${vol%-src}" - if docker ps -a --format '{{.Names}}' | grep -Fxq "$container_name" 2>/dev/null; then - status="in use" - fi - local created - created=$(docker volume inspect "$vol" --format '{{.CreatedAt}}' 2>/dev/null | cut -d' ' -f1 || echo "-") - printf "%-50s %-10s %-8s %s\n" "$vol" "copy" "$status" "$created" - done <<< "$volumes" - fi - - if [[ -n "$worktrees" ]]; then - while IFS= read -r wt; do - local status="orphan" - if docker ps -a --format '{{.Names}}' | grep -Fxq "$wt" 2>/dev/null; then - status="in use" - fi - local wt_path="${worktree_dir}/${wt}" - printf "%-50s %-10s %-8s %s\n" "$wt_path" "worktree" "$status" "-" - done <<< "$worktrees" - fi -} - -# ── Volume size helper ─────────────────────────────────────────── -_volume_size() { - local vol="$1" - docker run --rm -v "${vol}:/mnt/vol" alpine sh -c 'du -sh /mnt/vol 2>/dev/null | cut -f1' 2>/dev/null || echo "?" -} - -cmd_disk() { - _check_deps - - _step "Disk usage report" - - # Auth volumes - echo -e " ${_B}Auth volumes:${_N}" - local auth_vols - auth_vols=$(docker volume ls --format '{{.Name}}' | grep '^aibox-auth-') || true - if [[ -n "$auth_vols" ]]; then - while IFS= read -r vol; do - echo " ${vol}: $(_volume_size "$vol")" - done <<< "$auth_vols" - else - echo " (none)" - fi - - # Containers - echo "" - echo -e " ${_B}Containers:${_N}" - local containers - containers=$(docker ps -a --filter "name=${CONTAINER_PREFIX}-" --format '{{.Names}}\t{{.Size}}' 2>/dev/null | grep -v "^${PF_PREFIX}-") || true - if [[ -n "$containers" ]]; then - while IFS= read -r line; do - echo " ${line}" - done <<< "$containers" - else - echo " (none)" - fi - - # Copy volumes - echo "" - echo -e " ${_B}Copy volumes:${_N}" - local copy_vols - copy_vols=$(docker volume ls --format '{{.Name}}' | grep -F "${CONTAINER_PREFIX}-" | grep -- '-src$') || true - if [[ -n "$copy_vols" ]]; then - while IFS= read -r vol; do - echo " ${vol}: $(_volume_size "$vol")" - done <<< "$copy_vols" - else - echo " (none)" - fi - - # node_modules volumes - echo "" - echo -e " ${_B}node_modules volumes:${_N}" - local nm_vols - nm_vols=$(docker volume ls --format '{{.Name}}' | grep '_node_modules$') || true - if [[ -n "$nm_vols" ]]; then - while IFS= read -r vol; do - echo " ${vol}: $(_volume_size "$vol")" - done <<< "$nm_vols" - else - echo " (none)" - fi - - # Worktrees - echo "" - echo -e " ${_B}Worktrees:${_N}" - local worktree_dir="${CONFIG_DIR}/worktrees" - if [[ -d "$worktree_dir" ]] && ls -1 "$worktree_dir" 2>/dev/null | head -1 | grep -q .; then - du -sh "${worktree_dir}"/* 2>/dev/null | while IFS= read -r line; do - echo " ${line}" - done - else - echo " (none)" - fi - - # Docker system summary - echo "" - echo -e " ${_B}Docker system:${_N}" - docker system df 2>/dev/null | while IFS= read -r line; do - echo " ${line}" - done -} - -cmd_clean() { - _check_deps - - shift # remove "clean" from args - - # Parse clean-specific flags - local clean_containers=false clean_volumes=false clean_docker=false clean_sessions=false - local days="30" - local filter_set=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --containers) clean_containers=true; filter_set=true; shift ;; - --volumes) clean_volumes=true; filter_set=true; shift ;; - --docker) clean_docker=true; filter_set=true; shift ;; - --sessions) clean_sessions=true; filter_set=true; shift ;; - [0-9]*) days="$1"; shift ;; - *) shift ;; - esac - done - - # No filter flags = clean everything - if [[ "$filter_set" == "false" ]]; then - clean_containers=true; clean_volumes=true; clean_docker=true; clean_sessions=true - fi - - _step "Scanning for cleanup targets" - - local has_work=false - - # 1. Stopped/exited aibox containers - local stopped="" - if [[ "$clean_containers" == "true" ]]; then - stopped=$(docker ps -a \ - --filter "name=${CONTAINER_PREFIX}-" \ - --filter "status=exited" \ - --filter "status=created" \ - --format '{{.Names}}' | grep -v "^${PF_PREFIX}-" | sort -u) || true - - if [[ -n "$stopped" ]]; then - has_work=true - echo -e " ${_B}Stopped containers:${_N}" - while IFS= read -r c; do echo " $c"; done <<< "$stopped" - fi - fi - - # 2. Orphaned volumes - local orphan_vols=() - if [[ "$clean_volumes" == "true" ]]; then - local copy_vols - copy_vols=$(docker volume ls --format '{{.Name}}' | grep -F "${CONTAINER_PREFIX}-" | grep -- '-src$') || true - if [[ -n "$copy_vols" ]]; then - while IFS= read -r vol; do - local container_name="${vol%-src}" - if ! docker ps -a --format '{{.Names}}' | grep -Fxq "$container_name" 2>/dev/null; then - orphan_vols+=("$vol") - fi - done <<< "$copy_vols" - fi - - local nm_vols - nm_vols=$(docker volume ls --format '{{.Name}}' | grep '_node_modules$') || true - if [[ -n "$nm_vols" ]]; then - while IFS= read -r vol; do - local mounted_by - mounted_by=$(docker ps -a -q --filter "volume=${vol}" 2>/dev/null) || true - if [[ -z "$mounted_by" ]]; then - orphan_vols+=("$vol") - fi - done <<< "$nm_vols" - fi - - if [[ ${#orphan_vols[@]} -gt 0 ]]; then - has_work=true - echo -e " ${_B}Orphaned volumes:${_N}" - for v in "${orphan_vols[@]}"; do echo " $v"; done - fi - fi - - # 3. Dangling Docker images + build cache - local dangling_count=0 - if [[ "$clean_docker" == "true" ]]; then - local dangling - dangling=$(docker images -f "dangling=true" -q) || true - [[ -n "$dangling" ]] && dangling_count=$(echo "$dangling" | wc -l | tr -d ' ') - - if [[ "$dangling_count" -gt 0 ]]; then - has_work=true - echo -e " ${_B}Dangling images:${_N} ${dangling_count}" - fi - - local build_cache - build_cache=$(docker system df --format '{{.Type}}\t{{.Reclaimable}}' 2>/dev/null | grep '^Build' | cut -f2) || true - if [[ -n "$build_cache" && "$build_cache" != "0B" ]]; then - has_work=true - echo -e " ${_B}Build cache (reclaimable):${_N} ${build_cache}" - fi - fi - - # 4. Old Claude sessions in auth volumes - local auth_vols_list="" - local old_sessions_found=false - local sessions_skipped=false - if [[ "$clean_sessions" == "true" ]]; then - auth_vols_list=$(docker volume ls --format '{{.Name}}' | grep '^aibox-auth-') || true - - if [[ -n "$auth_vols_list" ]]; then - while IFS= read -r avol; do - # Skip if any running container mounts this volume - local running_users - running_users=$(docker ps -q --filter "volume=${avol}" 2>/dev/null) || true - if [[ -n "$running_users" ]]; then - _warn "Skipping ${avol} — mounted by running container(s). Stop them first." - sessions_skipped=true - continue - fi - - local count - count=$(docker run --rm -v "${avol}:/mnt/vol" alpine sh -c \ - "find /mnt/vol/projects /mnt/vol/session-env /mnt/vol/file-history /mnt/vol/shell-snapshots \ - -type f -mtime +${days} 2>/dev/null | wc -l" 2>/dev/null || echo "0") - count=$(echo "$count" | tr -d '[:space:]') - if [[ "$count" -gt 0 ]]; then - has_work=true - old_sessions_found=true - echo -e " ${_B}Old Claude sessions:${_N} ${count} files older than ${days} days in ${avol}" - fi - done <<< "$auth_vols_list" - fi - fi - - if [[ "$has_work" == "false" ]]; then - _ok "Nothing to clean." - return 0 - fi - - echo "" - if [[ "$FORCE_CLEAN" != "true" ]]; then - if ! _confirm_no "Proceed with cleanup?"; then - echo "Aborted." - return 0 - fi - fi - - # Execute cleanup - _step "Cleaning up" - - if [[ "$clean_containers" == "true" && -n "$stopped" ]]; then - while IFS= read -r c; do - _pf_cleanup "$c" - docker rm "$c" >/dev/null - _ok "Removed container: $c" - done <<< "$stopped" - fi - - if [[ "$clean_volumes" == "true" ]]; then - for v in "${orphan_vols[@]}"; do - docker volume rm "$v" >/dev/null 2>&1 \ - && _ok "Removed volume: $v" \ - || _warn "Could not remove volume: $v" - done - fi - - if [[ "$clean_docker" == "true" ]]; then - if [[ "$dangling_count" -gt 0 ]]; then - docker image prune -f >/dev/null - _ok "Removed ${dangling_count} dangling images." - fi - - docker builder prune -f >/dev/null 2>&1 \ - && _ok "Pruned build cache." || true - fi - - if [[ "$clean_sessions" == "true" && "$old_sessions_found" == "true" && -n "$auth_vols_list" ]]; then - while IFS= read -r avol; do - # Re-check: skip if a running container now uses this volume - local running_users - running_users=$(docker ps -q --filter "volume=${avol}" 2>/dev/null) || true - [[ -n "$running_users" ]] && continue - - docker run --rm -v "${avol}:/mnt/vol" alpine sh -c " - find /mnt/vol/projects /mnt/vol/session-env /mnt/vol/file-history /mnt/vol/shell-snapshots \ - -type f -mtime +${days} -delete 2>/dev/null - find /mnt/vol/projects /mnt/vol/session-env /mnt/vol/file-history /mnt/vol/shell-snapshots \ - -type d -empty -delete 2>/dev/null - " 2>/dev/null || true - done <<< "$auth_vols_list" - _ok "Cleaned Claude sessions older than ${days} days." - fi - - _ok "Cleanup complete." -} - -cmd_doctor() { - _check_deps - - _step "Running diagnostics" - local issues=0 - - # 1. Stale containers - echo -e " ${_B}Containers...${_N}" - local running - running=$(docker ps --filter "name=${CONTAINER_PREFIX}-" --format '{{.Names}}' | grep -v "^${PF_PREFIX}-") || true - if [[ -n "$running" ]]; then - while IFS= read -r c; do - local active - active=$(docker top "$c" -o pid,args 2>/dev/null \ - | tail -n +2 \ - | grep -v -c 'sleep infinity' || echo "0") - active=$(echo "$active" | tr -d '[:space:]') - if [[ "$active" -le 0 ]]; then - _warn "Stale: ${c} (running, no active sessions)" - echo -e " Fix: ${_B}aibox down${_N} or ${_B}aibox clean${_N}" - issues=$((issues + 1)) - else - _ok "${c} (${active} active session(s))" - fi - done <<< "$running" - else - _ok "No running containers" - fi - - # 2. Orphaned volumes - echo "" - echo -e " ${_B}Volumes...${_N}" - local copy_vols - copy_vols=$(docker volume ls --format '{{.Name}}' | grep -F "${CONTAINER_PREFIX}-" | grep -- '-src$') || true - if [[ -n "$copy_vols" ]]; then - while IFS= read -r vol; do - local container_name="${vol%-src}" - if ! docker ps -a --format '{{.Names}}' | grep -Fxq "$container_name" 2>/dev/null; then - _warn "Orphaned: ${vol}" - echo -e " Fix: ${_B}aibox clean${_N}" - issues=$((issues + 1)) - fi - done <<< "$copy_vols" - fi - - local nm_vols - nm_vols=$(docker volume ls --format '{{.Name}}' | grep '_node_modules$') || true - if [[ -n "$nm_vols" ]]; then - while IFS= read -r vol; do - local mounted_by - mounted_by=$(docker ps -a -q --filter "volume=${vol}" 2>/dev/null) || true - if [[ -z "$mounted_by" ]]; then - _warn "Orphaned: ${vol}" - echo -e " Fix: ${_B}aibox clean${_N}" - issues=$((issues + 1)) - fi - done <<< "$nm_vols" - fi - - # 3. Disk space - echo "" - echo -e " ${_B}Disk space...${_N}" - local available_kb - available_kb=$(df -k "${HOME}" 2>/dev/null | awk 'NR==2 {print $4}') || true - if [[ -n "$available_kb" && "$available_kb" =~ ^[0-9]+$ ]]; then - local available_gb=$(( available_kb / 1048576 )) - if (( available_gb < 5 )); then - _warn "Low disk space: ${available_gb}GB free" - echo -e " Fix: ${_B}aibox clean${_N}" - issues=$((issues + 1)) - else - _ok "Disk space: ${available_gb}GB free" - fi - fi - - docker system df --format '{{.Type}}\t{{.Size}}\t{{.Reclaimable}}' 2>/dev/null | while IFS=$'\t' read -r dtype dsize dreclaimable; do - echo -e " ${dtype}: ${dsize} (reclaimable: ${dreclaimable})" - done - - # 4. Image version - echo "" - echo -e " ${_B}Image...${_N}" - if docker image inspect "$IMAGE" &>/dev/null; then - local img_version - img_version=$(docker inspect "$IMAGE" --format '{{index .Config.Labels "aibox.version"}}' 2>/dev/null || echo "") - if [[ "$img_version" != "$AIBOX_VERSION" ]]; then - _warn "Image outdated: v${img_version:-unknown} (script: v${AIBOX_VERSION})" - echo -e " Fix: ${_B}aibox build${_N}" - issues=$((issues + 1)) - else - _ok "Image: v${AIBOX_VERSION}" - fi - - # Check running containers against current image - if [[ -n "$running" ]]; then - local latest_id - latest_id=$(docker inspect "$IMAGE" --format '{{.Id}}' 2>/dev/null || echo "") - while IFS= read -r c; do - local c_img_id - c_img_id=$(docker inspect "$c" --format '{{.Image}}' 2>/dev/null || echo "") - if [[ -n "$c_img_id" && -n "$latest_id" && "$c_img_id" != "$latest_id" ]]; then - _warn "Container ${c} uses outdated image" - echo -e " Fix: ${_B}aibox down && aibox up${_N}" - issues=$((issues + 1)) - fi - done <<< "$running" - fi - else - _warn "Image '${IMAGE}' not found" - echo -e " Fix: ${_B}aibox build${_N}" - issues=$((issues + 1)) - fi - - # 5. Dependencies - echo "" - echo -e " ${_B}Dependencies...${_N}" - local deps_ok=true - command -v docker &>/dev/null || { _warn "docker not found"; deps_ok=false; issues=$((issues + 1)); } - { docker compose version &>/dev/null || command -v docker-compose &>/dev/null; } \ - || { _warn "docker compose not found"; deps_ok=false; issues=$((issues + 1)); } - docker info &>/dev/null \ - || { _warn "Docker daemon not running"; deps_ok=false; issues=$((issues + 1)); } - [[ "$deps_ok" == "true" ]] && _ok "All dependencies present" - - # Summary - echo "" - if [[ "$issues" -eq 0 ]]; then - _ok "No issues found." - else - _warn "${issues} issue(s) found. Run ${_B}aibox clean${_N} to fix most." - fi -} - -_init_files() { - write_project_compose - save_project_conf - configure_webstorm - - local gitignore="${PROJECT_DIR}/.gitignore" - # Ensure file ends with a newline before appending - [[ -f "$gitignore" ]] && echo "" >> "$gitignore" - local entries=("compose.dev.yaml" ".aibox" ".idea/workspace.xml") - for entry in "${entries[@]}"; do - if [[ -f "$gitignore" ]]; then - grep -qxF "$entry" "$gitignore" || echo "$entry" >> "$gitignore" - else - echo "$entry" >> "$gitignore" - fi - done - echo "Updated .gitignore" - - echo "" - echo "Done! Open this project in WebStorm — Node.js runtime is configured." - echo " ${SCRIPT_NAME} up Start the container" - echo " ${SCRIPT_NAME} claude Open Claude Code" - echo " ${SCRIPT_NAME} shell Open a shell" -} - -cmd_init() { - _require_safe_dir - - echo "Will create/update in ${PROJECT_DIR}:" - echo " compose.dev.yaml, .aibox, .idea/workspace.xml, .gitignore" - echo "" - - if ! _confirm_yes "Proceed?"; then - echo "Aborted." - exit 0 - fi - - _init_files -} - -cmd_help() { - awk '/^# aibox/,/^[^#]/{if(/^#/) print}' "$0" | sed 's/^# \{0,1\}//' - echo "Version: ${AIBOX_VERSION}" -} - -cmd_version() { - echo "aibox v${AIBOX_CLI_VERSION} (image v${AIBOX_VERSION})" -} - -# ── Update check (non-blocking, cached for 24h) ───────────────── +# ── Update notice (non-blocking, cached 24h) ───────────────────── _check_for_updates() { - [[ ! -t 1 || -n "${CI:-}" || "$AIBOX_CLI_VERSION" == "__CLI_VERSION__" ]] && return 0 - - local cache_file="${CONFIG_DIR}/update-check" + [[ ! -t 1 || -n "${CI:-}" || "$CLI_VERSION" == "__CLI_VERSION__" ]] && return 0 + local cache="${CONFIG_DIR}/update-check" mkdir -p "$CONFIG_DIR" - - if [[ -f "$cache_file" ]]; then + if [[ -f "$cache" ]]; then local mtime latest - mtime=$(stat -c %Y "$cache_file" 2>/dev/null || stat -f %m "$cache_file" 2>/dev/null || echo 0) + mtime="$(stat -c %Y "$cache" 2>/dev/null || stat -f %m "$cache" 2>/dev/null || echo 0)" if (( $(date +%s) - mtime < 86400 )); then - latest=$(cat "$cache_file") - [[ -n "$latest" && "$latest" != "$AIBOX_CLI_VERSION" ]] && \ - _warn "aibox ${_B}v${latest}${_N}${_BY} available${_N} (current: v${AIBOX_CLI_VERSION}). Run: ${_B}aibox update${_N}" + latest="$(cat "$cache")" + [[ -n "$latest" && "$latest" != "$CLI_VERSION" ]] \ + && _warn "aibox v${latest} available (current: v${CLI_VERSION}). Run: aibox update" return 0 fi fi - - # Refresh cache in background (silent — notice shown on next run) ( curl -fsSL --max-time 3 "https://registry.npmjs.org/aibox-cli/latest" 2>/dev/null \ - | grep -o '"version":"[^"]*"' | head -1 | cut -d'"' -f4 > "$cache_file" + | grep -o '"version":"[^"]*"' | head -1 | cut -d'"' -f4 > "$cache" ) &>/dev/null & disown 2>/dev/null || true } -cmd_update() { - local script_path - script_path="$(realpath "$0" 2>/dev/null || readlink -f "$0" 2>/dev/null || echo "$0")" - - if [[ "$script_path" == */Cellar/* || "$script_path" == */homebrew/* ]]; then - if ! command -v brew &>/dev/null; then - _err "Installed via Homebrew but ${_B}brew${_N} not found in PATH." - echo " Run: brew upgrade aibox" - exit 1 - fi - _step "Updating via Homebrew" - brew update && brew upgrade aibox - elif command -v npm &>/dev/null && npm list -g aibox-cli &>/dev/null; then - _step "Updating via npm" - npm update -g aibox-cli - else - _err "Could not detect install method (Homebrew or npm)." - echo " Update manually:" - echo " Homebrew: brew upgrade aibox" - echo " npm: npm update -g aibox-cli" - exit 1 - fi - - _ok "Updated. Run ${_B}aibox build${_N} to rebuild the Docker image." -} - -# ── Main ───────────────────────────────────────────────────────── +# ── Dispatch ───────────────────────────────────────────────────── _check_for_updates -case "${1:-help}" in - up) cmd_up ;; - claude) shift; cmd_claude "$@" ;; - shell) cmd_shell "$@" ;; - port-forward) cmd_port_forward "$@" ;; - down) cmd_down ;; - status) cmd_status ;; - volumes) cmd_volumes ;; - disk) cmd_disk ;; - clean) cmd_clean "$@" ;; - doctor) cmd_doctor ;; - nuke) cmd_nuke ;; - build) cmd_build ;; - init) cmd_init ;; - update) cmd_update ;; - help|-h) cmd_help ;; +CMD="${1:-claude}" +[[ $# -gt 0 ]] && shift +case "$CMD" in + claude) cmd_claude "$@" ;; + shell) cmd_shell "$@" ;; + stop) cmd_stop "$@" ;; + status) cmd_status ;; + backup) cmd_backup "$@" ;; + restore) cmd_restore "$@" ;; + update) cmd_update ;; version|-v|--version) cmd_version ;; + help|-h|--help) cmd_help ;; + -*) cmd_claude "$CMD" "$@" ;; *) - echo "Unknown command: $1" - cmd_help + echo "Unknown command: $CMD" >&2 + echo "" >&2 + cmd_help >&2 exit 1 ;; esac diff --git a/package.json b/package.json index f09524c..fd03680 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aibox-cli", - "version": "0.6.0", - "description": "Run AI coding agents in isolated Docker containers", + "version": "2.0.0", + "description": "Persistent Docker sandboxes for Claude Code", "author": "repalash ", "license": "MIT", "repository": { diff --git a/scripts/migrate-to-v2.sh b/scripts/migrate-to-v2.sh new file mode 100755 index 0000000..0eee45f --- /dev/null +++ b/scripts/migrate-to-v2.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# migrate-to-v2.sh — one-time migration of aibox v1 data into the v2 +# aibox-home volume. Standalone: not part of the aibox CLI, run it once. +# +# Usage: +# ./migrate-to-v2.sh [--map OLD_PATH=NEW_PATH]... [old-backup-dir ...] +# +# What it does, in order: +# 1. Creates the aibox-home volume if missing. +# 2. Merges every `aibox-auth-*` Docker volume (v1 kept one per image +# string) into aibox-home. Sources are mounted READ-ONLY and are never +# modified or deleted. +# 3. Merges any old backup folders passed as arguments. Two layouts are +# accepted: a folder that *is* a Claude config dir (contains +# .claude.json / projects/), or a folder containing a `.claude/` dir +# (with .claude.json beside or inside it). +# 4. Session/state files merge file-by-file, no-clobber (each session is +# its own .jsonl keyed by UUID, so a plain union is correct). +# `.claude.json` is special-cased: the newest copy is the base, and the +# `projects` map is merged across all copies with newest-wins per key. +# 5. `--map OLD=NEW` rekeys sessions recorded under a container-side path +# (v1 --copy/--worktree used /workspace/...) to a real host path, e.g. +# --map /workspace/myapp=/Users/me/code/myapp. Renames both the +# projects/ directories and the .claude.json keys. +# (Path escaping is lossy — any non-alphanumeric becomes '-' — so the +# prefix match can theoretically over-match; only use --map for paths +# you recognize.) +# +# Idempotent: run it twice and the second run copies nothing new. +# It ends by PRINTING the cleanup commands for old volumes/containers — +# it never runs them. Verify sessions in v2 first, then clean up manually. + +set -euo pipefail + +VOLUME="aibox-home" +HELPER_IMAGE="alpine" + +info() { echo "· $*"; } +ok() { echo "✓ $*"; } +die() { echo "✗ $*" >&2; exit 1; } + +command -v docker >/dev/null 2>&1 || die "docker not found" +docker info >/dev/null 2>&1 || die "Docker daemon not running" +command -v python3 >/dev/null 2>&1 || die "python3 not found (needed for the .claude.json merge)" + +# ── Args ───────────────────────────────────────────────────────── +MAPS=() # OLD=NEW pairs +BACKUP_DIRS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --map) + [[ "${2:-}" == *=* ]] || die "--map needs OLD_PATH=NEW_PATH" + MAPS+=("$2"); shift 2 ;; + -h|--help) + sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) + [[ -d "$1" ]] || die "Not a directory: $1" + BACKUP_DIRS+=("$(cd "$1" && pwd)"); shift ;; + esac +done + +STAGING="$(mktemp -d)" +trap 'rm -rf "$STAGING"' EXIT + +docker volume create "$VOLUME" >/dev/null 2>&1 || true +docker run --rm -v "${VOLUME}:/dst" "$HELPER_IMAGE" mkdir -p /dst/.claude + +# ── Helpers ────────────────────────────────────────────────────── +escape() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; } + +# --map pairs in escaped form ("old=new;old2=new2") so the copy step can +# land files directly at their mapped path (keeps re-runs copy-free). +ESC_MAPS="" +for m in ${MAPS[@]+"${MAPS[@]}"}; do + ESC_MAPS="${ESC_MAPS}$(escape "${m%%=*}")=$(escape "${m#*=}");" +done + +# Copy everything except .claude.json from /src into /dst/.claude, +# file-by-file, never overwriting; paths under projects/ are rewritten +# through ESC_MAPS. Prints the number of files copied. +COPY_SCRIPT='map_path() { + case "$1" in projects/*) ;; *) printf "%s" "$1"; return;; esac + rest="${1#projects/}" + oldifs="$IFS"; IFS=";" + for pair in $ESC_MAPS; do + old="${pair%%=*}"; new="${pair#*=}" + case "$rest" in "$old"*) rest="${new}${rest#"$old"}"; break;; esac + done + IFS="$oldifs" + printf "projects/%s" "$rest" +} +cd /src && find . -type f ! -path "./.claude.json" | while IFS= read -r f; do + s="${f#./}" + d="$(map_path "$s")" + if [ ! -e "/dst/.claude/$d" ]; then + mkdir -p "/dst/.claude/$(dirname "$d")" + cp -p "$s" "/dst/.claude/$d" && echo x + fi +done | wc -l' + +# Save a source's .claude.json (if any) into staging as ..json +JSON_N=0 +stage_json() { # $1 = mtime, stdin = content + local content + content="$(cat)" + [[ -z "$content" || -z "$1" || "$1" == "0" ]] && return 0 + JSON_N=$((JSON_N + 1)) + printf '%s' "$content" > "${STAGING}/${1}.${JSON_N}.json" +} + +merge_from_volume() { # $1 = volume name + local vol="$1" copied meta + copied="$(docker run --rm -e ESC_MAPS="$ESC_MAPS" -v "${vol}:/src:ro" -v "${VOLUME}:/dst" "$HELPER_IMAGE" sh -c "$COPY_SCRIPT")" + meta="$(docker run --rm -v "${vol}:/src:ro" "$HELPER_IMAGE" sh -c \ + 'stat -c %Y /src/.claude.json 2>/dev/null || echo 0')" + docker run --rm -v "${vol}:/src:ro" "$HELPER_IMAGE" sh -c \ + 'cat /src/.claude.json 2>/dev/null || true' | stage_json "$meta" + ok "volume ${vol}: ${copied// /} new file(s)" +} + +host_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; } + +merge_from_dir() { # $1 = backup dir + local dir="$1" src json="" + if [[ -d "${dir}/.claude" ]]; then + src="${dir}/.claude" + [[ -f "${dir}/.claude.json" ]] && json="${dir}/.claude.json" + [[ -z "$json" && -f "${dir}/.claude/.claude.json" ]] && json="${dir}/.claude/.claude.json" + elif [[ -d "${dir}/projects" || -f "${dir}/.claude.json" ]]; then + src="$dir" + [[ -f "${dir}/.claude.json" ]] && json="${dir}/.claude.json" + else + info "skipping ${dir}: no Claude data recognized (expected .claude/ or projects/)" + return 0 + fi + local copied + copied="$(docker run --rm -e ESC_MAPS="$ESC_MAPS" -v "${src}:/src:ro" -v "${VOLUME}:/dst" "$HELPER_IMAGE" sh -c "$COPY_SCRIPT")" + [[ -n "$json" ]] && stage_json "$(host_mtime "$json")" < "$json" + ok "backup ${dir}: ${copied// /} new file(s)" +} + +# ── 1. Live v1 volumes ─────────────────────────────────────────── +AUTH_VOLS="$(docker volume ls --format '{{.Name}}' | grep '^aibox-auth-' || true)" +if [[ -n "$AUTH_VOLS" ]]; then + while IFS= read -r vol; do + merge_from_volume "$vol" + done <<< "$AUTH_VOLS" +else + info "no aibox-auth-* volumes found" +fi + +# ── 2. Old backup folders ──────────────────────────────────────── +for dir in ${BACKUP_DIRS[@]+"${BACKUP_DIRS[@]}"}; do + merge_from_dir "$dir" +done + +# ── 3. Merge .claude.json ──────────────────────────────────────── +# Include the destination's current .claude.json as a source, so re-runs and +# incremental migrations stay stable. +docker run --rm -v "${VOLUME}:/src/.claude:ro" "$HELPER_IMAGE" sh -c \ + 'cat /src/.claude/.claude.json 2>/dev/null || true' \ + | stage_json "$(docker run --rm -v "${VOLUME}:/v:ro" "$HELPER_IMAGE" sh -c \ + 'stat -c %Y /v/.claude.json 2>/dev/null || echo 0')" + +if ls "${STAGING}"/*.json >/dev/null 2>&1; then + python3 - "$STAGING" ${MAPS[@]+"${MAPS[@]}"} <<'PY' +import json, sys, glob, os +staging = sys.argv[1] +maps = [m.split("=", 1) for m in sys.argv[2:]] +sources = [] +for path in glob.glob(os.path.join(staging, "*.json")): + mtime = int(os.path.basename(path).split(".")[0]) + try: + with open(path) as f: + sources.append((mtime, json.load(f))) + except (json.JSONDecodeError, ValueError): + print(f"· skipping unparseable {os.path.basename(path)}", file=sys.stderr) +sources.sort(key=lambda s: s[0]) +if not sources: + sys.exit(0) +base = dict(sources[-1][1]) # newest copy wins for oauth etc. +projects = {} +for _, doc in sources: # oldest→newest: newest wins per key + projects.update(doc.get("projects", {}) or {}) +for old, new in maps: + for key in list(projects): + if key == old or key.startswith(old.rstrip("/") + "/"): + newkey = new + key[len(old.rstrip("/")):] if key != old else new + projects.setdefault(newkey, projects.pop(key)) +base["projects"] = projects +with open(os.path.join(staging, "merged.out"), "w") as f: + json.dump(base, f, indent=2) +print(f"· merged .claude.json from {len(sources)} source(s), {len(projects)} project(s)") +PY + if [[ -f "${STAGING}/merged.out" ]]; then + docker run --rm -v "${STAGING}:/stage:ro" -v "${VOLUME}:/dst" "$HELPER_IMAGE" \ + sh -c 'cp /stage/merged.out /dst/.claude/.claude.json && chmod 600 /dst/.claude/.claude.json' + ok "wrote merged .claude.json" + fi +else + info "no .claude.json found in any source" +fi + +# ── 4. --map: rename session directories already in the volume ─── +# (New copies are mapped at copy time; this handles data that landed in the +# volume before the map was applied, e.g. an earlier migration run.) +for m in ${MAPS[@]+"${MAPS[@]}"}; do + esc_old="$(escape "${m%%=*}")" + esc_new="$(escape "${m#*=}")" + docker run --rm -v "${VOLUME}:/dst" -e OLD="$esc_old" -e NEW="$esc_new" "$HELPER_IMAGE" sh -c ' + for d in /dst/.claude/projects/${OLD}*; do + [ -d "$d" ] || continue + rest="${d#/dst/.claude/projects/$OLD}" + tgt="/dst/.claude/projects/${NEW}${rest}" + if [ ! -e "$tgt" ]; then + mv "$d" "$tgt" && echo "· moved $(basename "$d") -> $(basename "$tgt")" + else + cd "$d" && find . -type f | while IFS= read -r f; do + f="${f#./}" + [ -e "$tgt/$f" ] || { mkdir -p "$tgt/$(dirname "$f")"; mv "$f" "$tgt/$f"; } + done + # Anything left in $d duplicates a file already at the target + cd / && rm -rf "$d" + echo "· merged $(basename "$d") into $(basename "$tgt")" + fi + done' +done + +# ── 5. Normalize ownership for the v2 container user (uid 1000) ── +docker run --rm -v "${VOLUME}:/dst" "$HELPER_IMAGE" chown -R 1000:1000 /dst +ok "ownership normalized (uid 1000)" + +# ── Summary ────────────────────────────────────────────────────── +TOTAL="$(docker run --rm -v "${VOLUME}:/v:ro" "$HELPER_IMAGE" sh -c 'find /v -type f | wc -l')" +SESSIONS="$(docker run --rm -v "${VOLUME}:/v:ro" "$HELPER_IMAGE" sh -c 'find /v/.claude/projects -name "*.jsonl" 2>/dev/null | wc -l')" +echo "" +ok "Done. ${VOLUME} now holds ${TOTAL// /} file(s), ${SESSIONS// /} session file(s)." +echo "" +echo "Verify with: cd && aibox claude --resume" +echo "" +echo "Nothing was deleted. Once you've verified v2 sees your sessions, clean up" +echo "the old v1 resources manually:" +V1_CONTAINERS="$(docker ps -a --filter label=aibox.instance --format '{{.Names}}' || true)" +if [[ -n "$V1_CONTAINERS" ]]; then + while IFS= read -r c; do echo " docker rm -f ${c}"; done <<< "$V1_CONTAINERS" +fi +if [[ -n "$AUTH_VOLS" ]]; then + while IFS= read -r v; do echo " docker volume rm ${v}"; done <<< "$AUTH_VOLS" +fi +if [[ -z "$V1_CONTAINERS" && -z "$AUTH_VOLS" ]]; then + echo " (none found)" +fi From 37b32a8bad02671b63d27c854b66a63fab9c9af2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 19:12:10 +0000 Subject: [PATCH 05/26] Fix all findings from three-way adversarial audit Three subagent audits (bin/aibox internals, migration data-safety, spec/docs/packaging conformance) empirically verified every finding against a live daemon; all fixes below are regression-tested the same way. Critical (data loss, all reproduced before the fix): - restore could wipe the volume after a silently-failed safety backup: command substitution suppresses set -e, so _do_backup swallowed every error and printed a path that did not exist. Every step in _do_backup now propagates failure and callers die loudly. - restore wiped the volume before validating the archive; a corrupt tar.gz left it empty. The archive is now validated (tar tzf) before confirmation, and the tarball is mounted at a fixed path instead of splicing its name into sh -c (which was also a quote/injection bug). - migration --map could rm -rf destination project dirs: identity and escape-colliding maps are refused, the merge branch now moves entries of any type no-clobber, deletes only byte-identical duplicates (cmp -s), and keeps + warns about anything that differs. - migration read the destination's live .claude.json from the wrong path, so re-runs rebuilt it from old sources only, dropping v2-era projects and OAuth. Correct path; verified surviving a re-run. High: - release.yml stamped all three __CLI_VERSION__ occurrences, turning the two dev-build sentinels always-true: every released build would have identified as 'dev' with a dead update mechanism. sed is now anchored to the assignment line (stamping simulated in tests). - npm users never received scripts/migrate-to-v2.sh (absent from package.json files); README now points at the shipped copy and a raw URL fallback. - aibox update used 'npm update -g', which does not cross the 0.6->2.0 major boundary; now 'npm install -g aibox-cli@latest', and brew/npm failures abort instead of printing a false success. - concurrent proxy creation: the race loser rm -f'd the winner's healthy proxy and fell back to 8080; now it re-checks state and adopts the winner (verified with parallel first-runs). - container recreation is serialized with a mkdir lock (TOCTOU window observed live in the audit). - migration staged same-mtime sources over each other (stage_json ran in a pipeline subshell so its counter never advanced) and aborted outright on null/non-object .claude.json sources; --map matching now respects path boundaries (myapp no longer captures myapp2). Medium/low: --init so stops take 0.3s not 10s (sleep ignored SIGTERM as PID 1); entrypoint ready-marker so first-run claude installs are not raced; ANTHROPIC_* forwarding via compgen (multiline env values forged variable names under set -u); stop --all filters by label instead of name prefix (no longer touches unrelated aibox-* containers); config parser keeps the last line without trailing newline and skips empty values; AIBOX_URL_BASE computed at exec time with the collision- resolved label and actual proxy port; stable UTC ordering for slug labels; CDPATH unset; ':'-in-path guards for docker mounts; stop rejects unknown args; version prints docker state; status shows uptime; unrelated-image node:bookworm curl added explicitly; migration copies symlinks, uses temp+rename, warns on newline filenames, stages both .claude.json layouts, warns about running v1 containers, pins alpine:3.20, and lists v1 port-forward sidecars in cleanup output. Docs: README migration/proxy-port/image notes, CONTRIBUTING stamping caveats (incl. unstamped Homebrew), REVAMP accepted-deviations appendix, stale v1 entries removed from .gitignore. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- .github/workflows/release.yml | 7 +- .gitignore | 3 - CONTRIBUTING.md | 4 + README.md | 11 +- REVAMP.md | 15 +++ bin/aibox | 202 ++++++++++++++++++++++++---------- package.json | 1 + scripts/migrate-to-v2.sh | 174 ++++++++++++++++++++--------- 8 files changed, 300 insertions(+), 117 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa41d4d..d53486d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,12 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - name: Stamp CLI version - run: sed -i "s/__CLI_VERSION__/$(node -p 'require(\"./package.json\").version')/" bin/aibox + # Anchor to the assignment line ONLY: the script contains two more + # __CLI_VERSION__ occurrences that are runtime dev-build sentinels + # ([[ "$X" == "__CLI_VERSION__" ]]) — replacing those too would make + # every released build identify as "dev" (wrong image tag, dead + # update mechanism). + run: sed -i "s/^CLI_VERSION=\"__CLI_VERSION__\"/CLI_VERSION=\"$(node -p 'require(\"./package.json\").version')\"/" bin/aibox - run: npm publish --access public --provenance homebrew: diff --git a/.gitignore b/.gitignore index dfa36fa..eaf3961 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ node_modules/ .DS_Store -compose.dev.yaml -.aibox .idea/ -.idea/workspace.xml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f53470b..dca9619 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,3 +37,7 @@ npm run release 2. Commit, then `npm run release` — CI handles the rest Note: the Docker image tag is derived from the CLI version (`aibox:-node`), so any release automatically rebuilds users' images and recreates their containers on next run — sessions and login live in the `aibox-home` volume and are unaffected. In a git checkout (unstamped `__CLI_VERSION__`) the tag is `aibox:dev-node`. + +Two stamping caveats: +- The release workflow's sed must replace ONLY the `CLI_VERSION=` assignment — the script contains two more `__CLI_VERSION__` occurrences that are runtime dev-build sentinels and must survive stamping. +- The Homebrew formula installs from the raw git tag, which is unstamped. The tap formula should `inreplace` the same assignment during install; until it does, brew installs behave like dev builds (image `aibox:dev-*`, no update notice). diff --git a/README.md b/README.md index 47a3133..1e041c7 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,11 @@ Backups are safe to take while sessions are running. v2 is a clean break: one always-yolo container per project, one shared home volume, and no destructive lifecycle. A standalone script merges all your v1 data — every per-image `aibox-auth-*` volume **and** any old backup folders — into the new volume: ```bash -./scripts/migrate-to-v2.sh # live v1 volumes only -./scripts/migrate-to-v2.sh ~/old-backup-dir # plus old backup folders +# npm installs ship the script next to the CLI: +bash "$(npm root -g)/aibox-cli/scripts/migrate-to-v2.sh" [old-backup-dir ...] + +# or fetch it directly: +curl -fsSL https://raw.githubusercontent.com/blitzdotdev/aibox/main/scripts/migrate-to-v2.sh | bash -s -- [old-backup-dir ...] ``` Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are read-only), `.claude.json` is merged newest-wins, and the script prints — but never runs — the cleanup commands for old v1 resources. @@ -90,7 +93,7 @@ proxy_port=80 # host port for the dev-server proxy backup_dir=~/aibox-backups ``` -The image is `node:-bookworm` (Debian) plus zsh, sudo, ripgrep, fzf, and jq — Claude apt-installs anything else on demand, and it persists across stop/start. To make custom tooling survive image rebuilds too, put extra Dockerfile lines in `~/.aibox/Dockerfile.extra`. +The image is `node:-bookworm` (Debian) plus a few basics (zsh, sudo, ripgrep, fzf, jq, less, procps) — Claude apt-installs anything else on demand, and it persists across stop/start. To make custom tooling survive image rebuilds too, put extra Dockerfile lines in `~/.aibox/Dockerfile.extra`. ## Prerequisites @@ -102,6 +105,8 @@ brew install colima docker && colima start aibox auto-starts an installed-but-stopped runtime; it won't install one for you. +Note on the dev-server proxy port: the proxy asks Docker for `127.0.0.1:80` only, but some Colima versions ignore the loopback restriction and publish the port on your LAN. Everything behind it is your own yolo-mode dev traffic, but if that matters to you, keep Colima current (or use OrbStack/Docker Desktop). + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). Design/requirements for v2 are in [REVAMP.md](REVAMP.md). diff --git a/REVAMP.md b/REVAMP.md index 1301cab..7fdf72e 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -396,6 +396,21 @@ Not in v2; recorded so they aren't accidentally half-built: - Multiple containers per project, non-Claude agent presets, Homebrew tap refresh. +## 9b. Accepted implementation deviations + +Recorded post-implementation; intentional: + +- Image tag is `aibox:-node` (not bare + `aibox:`) so a `node_version` config change also triggers + rebuild + recreation by pure tag comparison. +- Proxy slug-collision labels are computed over all project containers + (running or stopped), not just running ones — URLs stay stable across + stop/start. +- `aibox status` shows the image only when it differs from current (as a + "will be recreated" note) rather than as a column. +- Unstamped git checkouts run as version `dev` (image `aibox:dev-node`), + with the npm update notice disabled. + ## 10. Acceptance criteria The rewrite is done when all of these hold: diff --git a/bin/aibox b/bin/aibox index bdd6846..bca09b0 100755 --- a/bin/aibox +++ b/bin/aibox @@ -39,6 +39,7 @@ # the thing `aibox backup` protects. set -euo pipefail +unset CDPATH CLI_VERSION="__CLI_VERSION__" CONFIG_DIR="${HOME}/.aibox" @@ -46,6 +47,7 @@ VOLUME="aibox-home" NETWORK="aibox" PROXY="aibox-proxy" PROXY_IMAGE="caddy:2-alpine" +HELPER_IMAGE="alpine:3.20" # ── Output helpers ─────────────────────────────────────────────── if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then @@ -63,8 +65,10 @@ NODE_VERSION=24 PROXY_PORT=80 BACKUP_DIR="${HOME}/aibox-backups" if [[ -f "${CONFIG_DIR}/config" ]]; then - while IFS='=' read -r k v; do - [[ "$k" == \#* || -z "$k" ]] && continue + # `|| [[ -n "$k" ]]`: don't drop the last line when the file has no + # trailing newline. + while IFS='=' read -r k v || [[ -n "$k" ]]; do + [[ "$k" == \#* || -z "$k" || -z "$v" ]] && continue v="${v/#\~/$HOME}" case "$k" in node_version) NODE_VERSION="$v" ;; @@ -91,6 +95,8 @@ _require_safe_dir() { case "$PROJECT_DIR" in "$HOME"|/|/tmp|/var|/etc|/usr|/opt|/private|/private/tmp) _die "Refusing to sandbox ${PROJECT_DIR} — run aibox from inside a project directory." ;; + *:*) + _die "Project path contains ':' — docker cannot bind-mount it. Rename the directory." ;; esac } @@ -140,7 +146,7 @@ _build_image() { FROM node:${NODE_VERSION}-bookworm RUN apt-get update \\ - && apt-get install -y --no-install-recommends zsh sudo ripgrep fzf jq less procps \\ + && apt-get install -y --no-install-recommends zsh sudo ripgrep fzf jq less procps curl \\ && rm -rf /var/lib/apt/lists/* # The base image ships user 'node' at uid 1000; replace it with 'aibox' @@ -157,21 +163,24 @@ ENTRYPOINT ["/usr/local/bin/aibox-entrypoint"] CMD ["sleep", "infinity"] DOCKERFILE [[ -f "${CONFIG_DIR}/Dockerfile.extra" ]] && cat "${CONFIG_DIR}/Dockerfile.extra" >> "${CONFIG_DIR}/Dockerfile" + # Keep stray files in ~/.aibox (config, Caddyfile, caches) out of the build + # context. + printf '*\n!Dockerfile\n!entrypoint.sh\n' > "${CONFIG_DIR}/.dockerignore" cat > "${CONFIG_DIR}/entrypoint.sh" <<'ENTRYPOINT' #!/bin/bash set -e -# /home/aibox is a named volume; normalize ownership (data migrated from v1 -# may carry other UIDs). Fast path: skip when the roots already look right. -if [[ "$(stat -c %u /home/aibox)" != "1000" \ - || "$(stat -c %u /home/aibox/.claude 2>/dev/null || echo 1000)" != "1000" ]]; then - chown -R aibox:aibox /home/aibox || true -fi +rm -f /tmp/aibox-ready +# /home/aibox is a named volume; normalize ownership on every container start +# (data migrated from v1 or restored from backups may carry other UIDs). +chown -R aibox:aibox /home/aibox 2>/dev/null || true # First start with an empty home volume: install Claude Code into the volume # so the binary and its self-updates persist across container recreation. # (Anything baked into the image's home dir would be shadowed by the mount.) if [[ ! -x /home/aibox/.local/bin/claude ]]; then runuser -u aibox -- bash -c 'curl -fsSL --connect-timeout 15 https://claude.ai/install.sh | bash' || true fi +# Signals "start-up work finished" to the CLI (see _ensure_claude_bin). +touch /tmp/aibox-ready exec "$@" ENTRYPOINT _info "Building ${IMAGE} (one-time; rebuilt only when aibox or node_version changes)..." @@ -188,10 +197,14 @@ _ensure_image() { # ..aibox.localhost is proxied to : over the # Docker network — no ports are ever published on project containers. -# All v2 project containers, oldest first: "slugname" per line. +# All v2 project containers, oldest first (stable UTC creation time): +# "slug/name" per line. _project_rows() { - docker ps -a --filter label=aibox.slug \ - --format '{{.CreatedAt}}\t{{.Label "aibox.slug"}}\t{{.Names}}' 2>/dev/null \ + local ids + ids="$(docker ps -aq --filter label=aibox.slug 2>/dev/null | tr '\n' ' ')" + [[ -z "${ids// /}" ]] && return 0 + # shellcheck disable=SC2086 + docker inspect --format '{{.Created}}\t{{index .Config.Labels "aibox.slug"}}\t{{.Name}}' $ids 2>/dev/null \ | sort | cut -f2,3 } @@ -200,6 +213,7 @@ _project_rows() { _proxy_pairs() { local slug name key seen=" " while IFS=$'\t' read -r slug name; do + name="${name#/}" # docker inspect .Name has a leading slash [[ -z "$name" ]] && continue key="$slug" [[ "$seen" == *" $slug "* ]] && key="${slug}-${name##*-}" @@ -230,7 +244,7 @@ _gen_caddyfile() { echo "}" } -# Host port the proxy actually publishes; empty string when it's 80. +# Host port the proxy actually publishes; ":port" suffix, empty when 80. _proxy_suffix() { local p p="$(docker port "$PROXY" 80/tcp 2>/dev/null | head -1)" @@ -238,6 +252,13 @@ _proxy_suffix() { [[ -n "$p" && "$p" != "80" ]] && echo ":${p}" || true } +_start_proxy_on() { # $1 = host port; returns docker run's status + docker run -d --name "$PROXY" --network "$NETWORK" --restart unless-stopped \ + -p "127.0.0.1:${1}:80" \ + -v "${CONFIG_DIR}/Caddyfile:/etc/caddy/Caddyfile:ro" \ + "$PROXY_IMAGE" >/dev/null 2>&1 +} + _ensure_proxy() { mkdir -p "$CONFIG_DIR" local file="${CONFIG_DIR}/Caddyfile" tmp="${CONFIG_DIR}/Caddyfile.$$" @@ -258,22 +279,20 @@ _ensure_proxy() { || docker restart "$PROXY" >/dev/null 2>&1 || true ;; absent) - local port="$PROXY_PORT" - if ! docker run -d --name "$PROXY" --network "$NETWORK" --restart unless-stopped \ - -p "127.0.0.1:${port}:80" \ - -v "${file}:/etc/caddy/Caddyfile:ro" \ - "$PROXY_IMAGE" >/dev/null 2>&1; then - docker rm -f "$PROXY" >/dev/null 2>&1 || true - if [[ "$port" == "80" ]]; then - port=8080 - _warn "Host port 80 unavailable — proxy on ${port} instead (URLs get :${port})." - docker run -d --name "$PROXY" --network "$NETWORK" --restart unless-stopped \ - -p "127.0.0.1:${port}:80" \ - -v "${file}:/etc/caddy/Caddyfile:ro" \ - "$PROXY_IMAGE" >/dev/null 2>&1 \ + if ! _start_proxy_on "$PROXY_PORT"; then + # A parallel invocation may have won the creation race — if the name + # exists now, that proxy is healthy; use it instead of nuking it. + if [[ "$(_state "$PROXY")" != "absent" ]]; then + docker start "$PROXY" >/dev/null 2>&1 || true + return 0 + fi + if [[ "$PROXY_PORT" == "80" ]]; then + _warn "Host port 80 unavailable — proxy on 8080 instead (URLs get :8080)." + docker rm -f "$PROXY" >/dev/null 2>&1 || true + _start_proxy_on 8080 \ || { _warn "Dev-server proxy failed to start; containers still work."; return 0; } else - _warn "Dev-server proxy failed to start on port ${port}; containers still work." + _warn "Dev-server proxy failed to start on port ${PROXY_PORT}; containers still work." return 0 fi fi @@ -291,6 +310,16 @@ CREATED=false _ensure_container() { docker network create "$NETWORK" >/dev/null 2>&1 || true docker volume create "$VOLUME" >/dev/null 2>&1 || true + mkdir -p "$CONFIG_DIR" + + # Serialize create/recreate across concurrent invocations (mkdir is atomic). + # A lock older than ~15s is presumed dead and stolen. + local lock="${CONFIG_DIR}/.lock-${CONTAINER}" waited=0 + until mkdir "$lock" 2>/dev/null; do + (( waited++ >= 150 )) && { rmdir "$lock" 2>/dev/null || true; waited=0; } + sleep 0.1 + done + trap 'rmdir "$lock" 2>/dev/null || true' EXIT local state state="$(_state "$CONTAINER")" @@ -299,18 +328,18 @@ _ensure_container() { # The home volume and project bind survive by construction. if [[ "$state" != "absent" ]]; then local cur_img - cur_img="$(docker inspect "$CONTAINER" --format '{{.Config.Image}}' 2>/dev/null || echo '')" - if [[ "$cur_img" != "$IMAGE" ]]; then + cur_img="$(docker inspect "$CONTAINER" --format '{{.Config.Image}}' 2>/dev/null | tr -d '[:space:]')" + if [[ -n "$cur_img" && "$cur_img" != "$IMAGE" ]]; then local active=0 if [[ "$state" == "running" ]]; then active="$(docker top "$CONTAINER" -o pid,args 2>/dev/null | tail -n +2 | grep -vc 'sleep infinity' || true)" active="${active//[^0-9]/}" fi if [[ "${active:-0}" -gt 0 ]]; then - _warn "Image updated (${cur_img} → ${IMAGE}) but ${active} session(s) active — keeping the old container for now." + _warn "Image updated (${cur_img} → ${IMAGE}) but ${active} process(es) still running in the old container — keeping it for now. To update: exit sessions / stop dev servers, then run: aibox stop && aibox" else _info "Image changed (${cur_img} → ${IMAGE}). Recreating container — sessions/login/project files persist; apt-installed packages reset (use ~/.aibox/Dockerfile.extra to keep them)." - docker rm -f "$CONTAINER" >/dev/null + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true state=absent fi fi @@ -318,28 +347,27 @@ _ensure_container() { if [[ "$state" == "absent" ]]; then _require_safe_dir - local suffix="" - [[ "$PROXY_PORT" != "80" ]] && suffix=":${PROXY_PORT}" local run_err="" - # `|| true`: a second terminal may have created it in parallel — the - # running check below is what matters. run_err="$(docker run -d --name "$CONTAINER" \ --network "$NETWORK" \ --restart unless-stopped \ + --init \ --add-host host.docker.internal:host-gateway \ -v "${VOLUME}:/home/aibox" \ -v "${PROJECT_DIR}:${PROJECT_DIR}" \ -w "$PROJECT_DIR" \ -e CLAUDE_CONFIG_DIR=/home/aibox/.claude \ - -e "AIBOX_URL_BASE=${SLUG}.aibox.localhost${suffix}" \ -l "aibox.slug=${SLUG}" \ -l "aibox.path=${PROJECT_DIR}" \ "$IMAGE" 2>&1 >/dev/null)" || true CREATED=true elif [[ "$state" != "running" ]]; then - docker start "$CONTAINER" >/dev/null + docker start "$CONTAINER" >/dev/null 2>&1 || true fi + rmdir "$lock" 2>/dev/null || true + trap - EXIT + local i=0 until [[ "$(docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null)" == "true" ]]; do (( i++ >= 25 )) && _die "Container failed to start.${run_err:+ ${run_err}}" @@ -349,6 +377,17 @@ _ensure_container() { } _ensure_claude_bin() { + # Wait for the entrypoint's start-up work (ownership fix + first-time + # claude install) so we don't race a half-finished install. Containers + # from older images never write the marker — the claude test breaks the + # wait for those. + local i=0 + until docker exec "$CONTAINER" test -e /tmp/aibox-ready 2>/dev/null; do + docker exec -u aibox "$CONTAINER" test -x /home/aibox/.local/bin/claude 2>/dev/null && break + (( i++ >= 180 )) && break + (( i == 3 )) && _info "Waiting for first-time container setup (claude install)..." + sleep 1 + done docker exec -u aibox "$CONTAINER" test -x /home/aibox/.local/bin/claude 2>/dev/null && return 0 _info "Installing Claude Code into the shared home volume (one-time)..." docker exec -u aibox "$CONTAINER" bash -c 'curl -fsSL https://claude.ai/install.sh | bash' || true @@ -374,11 +413,14 @@ _dexec() { -e "TERM=${TERM:-xterm-256color}" -e "COLORTERM=${COLORTERM:-truecolor}" -e "LANG=${LANG:-C.UTF-8}" + -e "AIBOX_URL_BASE=$(_pub_label).aibox.localhost$(_proxy_suffix)" ) + # Forward exported ANTHROPIC_* vars (compgen, not `env` parsing — multiline + # values in unrelated vars must not fabricate variable names). local var - while IFS= read -r var; do + for var in $(compgen -A export | grep '^ANTHROPIC_' || true); do env_args+=(-e "${var}=${!var}") - done < <(env | grep -o '^ANTHROPIC_[A-Za-z0-9_]*' || true) + done docker exec "${tty_args[@]}" -u aibox -w "$PROJECT_DIR" "${env_args[@]}" "$CONTAINER" "$@" } @@ -407,11 +449,19 @@ cmd_shell() { cmd_stop() { _ensure_docker if [[ "${1:-}" == "--all" ]]; then + # Only containers this tool created (label) plus the proxy — never + # unrelated containers that happen to be named aibox-*. local names - names="$(docker ps --filter 'name=^aibox-' --format '{{.Names}}')" + names="$(docker ps --filter label=aibox.slug --format '{{.Names}}')" + if docker ps --format '{{.Names}}' | grep -Fxq "$PROXY"; then + names="${names}${names:+ +}${PROXY}" + fi [[ -z "$names" ]] && { _info "No aibox containers running."; return 0; } echo "$names" | xargs docker stop >/dev/null _ok "Stopped: $(echo "$names" | tr '\n' ' ')(state preserved)" + elif [[ -n "${1:-}" ]]; then + _die "Usage: aibox stop [--all]" elif docker ps --format '{{.Names}}' | grep -Fxq "$CONTAINER"; then docker stop "$CONTAINER" >/dev/null _ok "Stopped ${CONTAINER} (state preserved — run aibox to start it again)." @@ -424,19 +474,19 @@ cmd_status() { _ensure_docker local rows rows="$(docker ps -a --filter label=aibox.slug \ - --format '{{.Names}}\t{{.State}}\t{{.Image}}\t{{.Label "aibox.path"}}' 2>/dev/null)" + --format '{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Label "aibox.path"}}' 2>/dev/null)" if [[ -z "$rows" ]]; then echo "No aibox containers. Run aibox in a project directory to start one." else local suffix key name suffix="$(_proxy_suffix)" - printf "%-34s %-9s %-22s %s\n" "CONTAINER" "STATE" "DEV URL" "PROJECT" - while IFS=$'\t' read -r cname cstate cimage cpath; do + printf "%-34s %-18s %-24s %s\n" "CONTAINER" "STATUS" "DEV URL" "PROJECT" + while IFS=$'\t' read -r cname cstatus cimage cpath; do local url="-" while read -r key name; do [[ "$name" == "$cname" ]] && url=".${key}.aibox.localhost${suffix}" done < <(_proxy_pairs) - printf "%-34s %-9s %-22s %s\n" "$cname" "$cstate" "$url" "$cpath" + printf "%-34s %-18s %-24s %s\n" "$cname" "$cstatus" "$url" "$cpath" [[ "$cimage" != "$IMAGE" ]] && _info " ${cname}: image ${cimage} (current: ${IMAGE} — recreated on next run)" done <<< "$rows" fi @@ -446,18 +496,26 @@ cmd_status() { echo "Proxy: ${pstate}$( [[ "$pstate" == running ]] && echo " (http://..aibox.localhost$(_proxy_suffix))" )" if docker volume inspect "$VOLUME" >/dev/null 2>&1; then local size - size="$(docker run --rm -v "${VOLUME}:/v:ro" alpine du -sh /v 2>/dev/null | cut -f1 || echo '?')" + size="$(docker run --rm -v "${VOLUME}:/v:ro" "$HELPER_IMAGE" du -sh /v 2>/dev/null | cut -f1 || echo '?')" echo "Home volume: ${VOLUME} (${size:-?}) — sessions, login, claude binary. Protect with: aibox backup" fi } +# Writes one tar.gz of the volume; prints its path. Every step is explicitly +# error-checked: this runs inside command substitutions, where `set -e` is +# OFF — a silent failure here once meant restore wiped a volume against a +# safety backup that didn't exist. _do_backup() { local dir="$1" prefix="${2:-aibox-home}" - mkdir -p "$dir" - dir="$(cd "$dir" && pwd)" + case "$dir" in *:*) + echo "backup dir contains ':' — docker cannot mount it: ${dir}" >&2; return 1 ;; + esac + mkdir -p "$dir" || return 1 + dir="$(cd "$dir" && pwd)" || return 1 local name="${prefix}-${IMG_VER}-$(date -u +%Y%m%d-%H%M%S).tar.gz" - docker run --rm -v "${VOLUME}:/home/aibox:ro" -v "${dir}:/backup" alpine \ - tar czf "/backup/${name}" -C /home/aibox . >/dev/null + docker run --rm -v "${VOLUME}:/home/aibox:ro" -v "${dir}:/backup" "$HELPER_IMAGE" \ + tar czf "/backup/${name}" -C /home/aibox . >/dev/null || return 1 + [[ -s "${dir}/${name}" ]] || return 1 echo "${dir}/${name}" } @@ -466,7 +524,8 @@ cmd_backup() { docker volume inspect "$VOLUME" >/dev/null 2>&1 \ || _die "No ${VOLUME} volume yet — nothing to back up." local out - out="$(_do_backup "${1:-$BACKUP_DIR}")" + out="$(_do_backup "${1:-$BACKUP_DIR}")" \ + || _die "Backup FAILED — nothing was written. Check the destination directory and docker." _ok "Backup written: ${out} ($(du -h "$out" | cut -f1))" } @@ -475,9 +534,16 @@ cmd_restore() { local file="$1" [[ -f "$file" ]] || _die "Not found: ${file}" file="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" + [[ "$file" == *:* ]] && _die "Backup path contains ':' — docker cannot mount it. Move/rename the file first." _ensure_docker docker volume create "$VOLUME" >/dev/null 2>&1 || true + # Validate the archive BEFORE touching anything. The file is mounted at a + # fixed path — its name never reaches a shell string. + docker run --rm -v "${file}:/backup.tar.gz:ro" "$HELPER_IMAGE" \ + tar tzf /backup.tar.gz >/dev/null 2>&1 \ + || _die "Not a readable .tar.gz archive: ${file}" + echo "This replaces the contents of ${VOLUME} (sessions, login, claude binary)" echo "with: ${file}" echo "A safety backup of the current state is taken first." @@ -485,21 +551,30 @@ cmd_restore() { local running running="$(docker ps --filter "volume=${VOLUME}" --format '{{.Names}}')" - [[ -n "$running" ]] && { _info "Stopping: $(echo "$running" | tr '\n' ' ')"; echo "$running" | xargs docker stop >/dev/null; } + if [[ -n "$running" ]]; then + _info "Stopping: $(echo "$running" | tr '\n' ' ')" + echo "$running" | xargs docker stop >/dev/null + fi local has_data - has_data="$(docker run --rm -v "${VOLUME}:/v:ro" alpine sh -c 'ls -A /v 2>/dev/null | head -1')" + has_data="$(docker run --rm -v "${VOLUME}:/v:ro" "$HELPER_IMAGE" sh -c 'ls -A /v 2>/dev/null | head -1')" if [[ -n "$has_data" ]]; then local safety - safety="$(_do_backup "$BACKUP_DIR" "aibox-home-pre-restore")" + safety="$(_do_backup "$BACKUP_DIR" "aibox-home-pre-restore")" \ + || _die "Safety backup FAILED — aborting restore. The volume is untouched." _ok "Safety backup: ${safety}" fi - docker run --rm -v "${VOLUME}:/v" -v "$(dirname "$file"):/backup:ro" alpine \ - sh -c "find /v -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf '/backup/$(basename "$file")' -C /v" + docker run --rm -v "${VOLUME}:/v" -v "${file}:/backup.tar.gz:ro" "$HELPER_IMAGE" \ + sh -c 'find /v -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf /backup.tar.gz -C /v' \ + || _die "Restore failed mid-way. Recover with: aibox restore " _ok "Restored." - [[ -n "$running" ]] && { echo "$running" | xargs docker start >/dev/null; _ok "Restarted: $(echo "$running" | tr '\n' ' ')"; } + if [[ -n "$running" ]]; then + echo "$running" | xargs docker start >/dev/null + _ok "Restarted: $(echo "$running" | tr '\n' ' ')" + fi + return 0 } cmd_update() { @@ -508,18 +583,27 @@ cmd_update() { if [[ "$script_path" == */Cellar/* || "$script_path" == */homebrew/* ]]; then command -v brew >/dev/null 2>&1 || _die "Installed via Homebrew but brew not found. Run: brew upgrade aibox" _info "Updating via Homebrew..." - brew update && brew upgrade aibox + brew update || _die "brew update failed." + brew upgrade aibox || _die "brew upgrade aibox failed." elif command -v npm >/dev/null 2>&1 && npm list -g aibox-cli >/dev/null 2>&1; then _info "Updating via npm..." - npm update -g aibox-cli + # install@latest, not `npm update`: update won't cross major versions. + npm install -g aibox-cli@latest || _die "npm install -g aibox-cli@latest failed." else - _die "Could not detect install method. Update manually: brew upgrade aibox / npm update -g aibox-cli" + _die "Could not detect install method. Update manually: brew upgrade aibox / npm install -g aibox-cli@latest" fi _ok "Updated. The image rebuilds and containers recreate automatically on your next aibox run (sessions and login persist)." } cmd_version() { echo "aibox v${CLI_VERSION} (image ${IMAGE})" + if command -v docker >/dev/null 2>&1; then + local daemon="daemon not running" + docker info >/dev/null 2>&1 && daemon="daemon running" + echo "$(docker --version 2>/dev/null || echo docker) — ${daemon}" + else + echo "docker: not installed" + fi } cmd_help() { diff --git a/package.json b/package.json index fd03680..4545ff2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "files": [ "bin/aibox", + "scripts/migrate-to-v2.sh", "README.md", "LICENSE" ], diff --git a/scripts/migrate-to-v2.sh b/scripts/migrate-to-v2.sh index 0eee45f..19235ec 100755 --- a/scripts/migrate-to-v2.sh +++ b/scripts/migrate-to-v2.sh @@ -22,27 +22,34 @@ # (v1 --copy/--worktree used /workspace/...) to a real host path, e.g. # --map /workspace/myapp=/Users/me/code/myapp. Renames both the # projects/ directories and the .claude.json keys. -# (Path escaping is lossy — any non-alphanumeric becomes '-' — so the -# prefix match can theoretically over-match; only use --map for paths -# you recognize.) +# Matching respects path boundaries (/workspace/myapp does not match +# /workspace/myapp2), but escaping is lossy — /a/b, /a.b and /a-b all +# escape identically — so only use --map for paths you recognize. # -# Idempotent: run it twice and the second run copies nothing new. -# It ends by PRINTING the cleanup commands for old volumes/containers — -# it never runs them. Verify sessions in v2 first, then clean up manually. +# Idempotent: run it twice and the second run copies nothing new. Merges +# never overwrite an existing file, and nothing in the destination is +# deleted unless it is byte-identical to the copy at its mapped location. +# The script ends by PRINTING the cleanup commands for old +# volumes/containers — it never runs them. Verify sessions in v2 first. set -euo pipefail +unset CDPATH VOLUME="aibox-home" -HELPER_IMAGE="alpine" +HELPER_IMAGE="alpine:3.20" info() { echo "· $*"; } ok() { echo "✓ $*"; } die() { echo "✗ $*" >&2; exit 1; } +usage() { awk '/^# migrate/,/^[^#]/{if(/^#/) print}' "$0" | sed 's/^# \{0,1\}//'; } + command -v docker >/dev/null 2>&1 || die "docker not found" docker info >/dev/null 2>&1 || die "Docker daemon not running" command -v python3 >/dev/null 2>&1 || die "python3 not found (needed for the .claude.json merge)" +escape() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; } + # ── Args ───────────────────────────────────────────────────────── MAPS=() # OLD=NEW pairs BACKUP_DIRS=() @@ -50,15 +57,26 @@ while [[ $# -gt 0 ]]; do case "$1" in --map) [[ "${2:-}" == *=* ]] || die "--map needs OLD_PATH=NEW_PATH" + old="${2%%=*}"; new="${2#*=}" + [[ -n "$old" && -n "$new" ]] || die "--map: OLD and NEW must both be non-empty" + [[ "$(escape "$old")" == "$(escape "$new")" ]] \ + && die "--map ${2}: OLD and NEW escape to the same key ($(escape "$old")) — refusing (escaping turns every non-alphanumeric character into '-')" MAPS+=("$2"); shift 2 ;; -h|--help) - sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + usage; exit 0 ;; *) [[ -d "$1" ]] || die "Not a directory: $1" - BACKUP_DIRS+=("$(cd "$1" && pwd)"); shift ;; + dir_abs="$(cd "$1" && pwd)" + [[ "$dir_abs" == *:* ]] && die "Backup dir path contains ':' — docker cannot mount it: ${dir_abs}" + BACKUP_DIRS+=("$dir_abs"); shift ;; esac done +if docker ps -q --filter label=aibox.instance | grep -q .; then + info "WARNING: v1 aibox containers are RUNNING. For a consistent snapshot," + info " stop them first (aibox down / docker stop ). Continuing anyway." +fi + STAGING="$(mktemp -d)" trap 'rm -rf "$STAGING"' EXIT @@ -66,8 +84,6 @@ docker volume create "$VOLUME" >/dev/null 2>&1 || true docker run --rm -v "${VOLUME}:/dst" "$HELPER_IMAGE" mkdir -p /dst/.claude # ── Helpers ────────────────────────────────────────────────────── -escape() { printf '%s' "$1" | sed 's/[^a-zA-Z0-9]/-/g'; } - # --map pairs in escaped form ("old=new;old2=new2") so the copy step can # land files directly at their mapped path (keeps re-runs copy-free). ESC_MAPS="" @@ -76,29 +92,46 @@ for m in ${MAPS[@]+"${MAPS[@]}"}; do done # Copy everything except .claude.json from /src into /dst/.claude, -# file-by-file, never overwriting; paths under projects/ are rewritten -# through ESC_MAPS. Prints the number of files copied. +# entry-by-entry (files and symlinks), never overwriting; paths under +# projects/ are rewritten through ESC_MAPS with path-boundary matching +# (OLD exactly, OLD/, or OLD-). Copies go via a temp name +# so an interrupted run never leaves a truncated file that blocks re-runs. +# Prints the number of entries copied; failures and skipped +# newline-containing names go to stderr. COPY_SCRIPT='map_path() { case "$1" in projects/*) ;; *) printf "%s" "$1"; return;; esac rest="${1#projects/}" oldifs="$IFS"; IFS=";" for pair in $ESC_MAPS; do old="${pair%%=*}"; new="${pair#*=}" - case "$rest" in "$old"*) rest="${new}${rest#"$old"}"; break;; esac + case "$rest" in + "$old") rest="$new"; break;; + "$old"/*) rest="${new}${rest#"$old"}"; break;; + "$old"-*) rest="${new}${rest#"$old"}"; break;; + esac done IFS="$oldifs" printf "projects/%s" "$rest" } -cd /src && find . -type f ! -path "./.claude.json" | while IFS= read -r f; do +cd /src || exit 1 +lines=$(find . \( -type f -o -type l \) | wc -l) +true_count=$(find . \( -type f -o -type l \) -exec printf x \; | wc -c) +[ "$lines" -eq "$true_count" ] \ + || echo "WARNING: source has file names containing newlines - those files are NOT copied" >&2 +find . \( -type f -o -type l \) ! -path "./.claude.json" | while IFS= read -r f; do s="${f#./}" + [ -e "./$s" ] || [ -L "./$s" ] || continue d="$(map_path "$s")" - if [ ! -e "/dst/.claude/$d" ]; then - mkdir -p "/dst/.claude/$(dirname "$d")" - cp -p "$s" "/dst/.claude/$d" && echo x + if [ ! -e "/dst/.claude/$d" ] && [ ! -L "/dst/.claude/$d" ]; then + mkdir -p "/dst/.claude/$(dirname "$d")" \ + && cp -a "./$s" "/dst/.claude/$d.aibox-tmp" \ + && mv "/dst/.claude/$d.aibox-tmp" "/dst/.claude/$d" \ + && echo x \ + || echo "COPY FAILED: $s" >&2 fi done | wc -l' -# Save a source's .claude.json (if any) into staging as ..json +# Save one source .claude.json into staging as ..json JSON_N=0 stage_json() { # $1 = mtime, stdin = content local content @@ -109,33 +142,40 @@ stage_json() { # $1 = mtime, stdin = content } merge_from_volume() { # $1 = volume name - local vol="$1" copied meta + local vol="$1" copied meta content copied="$(docker run --rm -e ESC_MAPS="$ESC_MAPS" -v "${vol}:/src:ro" -v "${VOLUME}:/dst" "$HELPER_IMAGE" sh -c "$COPY_SCRIPT")" meta="$(docker run --rm -v "${vol}:/src:ro" "$HELPER_IMAGE" sh -c \ 'stat -c %Y /src/.claude.json 2>/dev/null || echo 0')" - docker run --rm -v "${vol}:/src:ro" "$HELPER_IMAGE" sh -c \ - 'cat /src/.claude.json 2>/dev/null || true' | stage_json "$meta" + content="$(docker run --rm -v "${vol}:/src:ro" "$HELPER_IMAGE" sh -c \ + 'cat /src/.claude.json 2>/dev/null || true')" + # Not a pipeline: stage_json must run in this shell so JSON_N increments + # (same-mtime sources would otherwise overwrite each other in staging). + stage_json "$meta" <<< "$content" ok "volume ${vol}: ${copied// /} new file(s)" } host_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; } merge_from_dir() { # $1 = backup dir - local dir="$1" src json="" + local dir="$1" src jsons=() j if [[ -d "${dir}/.claude" ]]; then src="${dir}/.claude" - [[ -f "${dir}/.claude.json" ]] && json="${dir}/.claude.json" - [[ -z "$json" && -f "${dir}/.claude/.claude.json" ]] && json="${dir}/.claude/.claude.json" + [[ -f "${dir}/.claude.json" ]] && jsons+=("${dir}/.claude.json") + [[ -f "${dir}/.claude/.claude.json" ]] && jsons+=("${dir}/.claude/.claude.json") + [[ -d "${dir}/projects" ]] \ + && info "note: ${dir} has BOTH .claude/ and a top-level projects/ — using .claude/, ignoring the top-level projects/" elif [[ -d "${dir}/projects" || -f "${dir}/.claude.json" ]]; then src="$dir" - [[ -f "${dir}/.claude.json" ]] && json="${dir}/.claude.json" + [[ -f "${dir}/.claude.json" ]] && jsons+=("${dir}/.claude.json") else info "skipping ${dir}: no Claude data recognized (expected .claude/ or projects/)" return 0 fi local copied copied="$(docker run --rm -e ESC_MAPS="$ESC_MAPS" -v "${src}:/src:ro" -v "${VOLUME}:/dst" "$HELPER_IMAGE" sh -c "$COPY_SCRIPT")" - [[ -n "$json" ]] && stage_json "$(host_mtime "$json")" < "$json" + for j in ${jsons[@]+"${jsons[@]}"}; do + stage_json "$(host_mtime "$j")" < "$j" + done ok "backup ${dir}: ${copied// /} new file(s)" } @@ -155,12 +195,15 @@ for dir in ${BACKUP_DIRS[@]+"${BACKUP_DIRS[@]}"}; do done # ── 3. Merge .claude.json ──────────────────────────────────────── -# Include the destination's current .claude.json as a source, so re-runs and -# incremental migrations stay stable. -docker run --rm -v "${VOLUME}:/src/.claude:ro" "$HELPER_IMAGE" sh -c \ - 'cat /src/.claude/.claude.json 2>/dev/null || true' \ - | stage_json "$(docker run --rm -v "${VOLUME}:/v:ro" "$HELPER_IMAGE" sh -c \ - 'stat -c %Y /v/.claude.json 2>/dev/null || echo 0')" +# Include the destination's CURRENT .claude.json as a source. In v2 the +# volume root is /home/aibox, so the live file sits at .claude/.claude.json +# relative to the volume root — without this, a re-run after using v2 would +# rebuild the file from old sources only and lose v2-era state. +DST_META="$(docker run --rm -v "${VOLUME}:/v:ro" "$HELPER_IMAGE" sh -c \ + 'stat -c %Y /v/.claude/.claude.json 2>/dev/null || echo 0')" +DST_CONTENT="$(docker run --rm -v "${VOLUME}:/v:ro" "$HELPER_IMAGE" sh -c \ + 'cat /v/.claude/.claude.json 2>/dev/null || true')" +stage_json "$DST_META" <<< "$DST_CONTENT" if ls "${STAGING}"/*.json >/dev/null 2>&1; then python3 - "$STAGING" ${MAPS[@]+"${MAPS[@]}"} <<'PY' @@ -169,23 +212,32 @@ staging = sys.argv[1] maps = [m.split("=", 1) for m in sys.argv[2:]] sources = [] for path in glob.glob(os.path.join(staging, "*.json")): - mtime = int(os.path.basename(path).split(".")[0]) + parts = os.path.basename(path).split(".") + order = (int(parts[0]), int(parts[1])) # (mtime, staging seq) — stable ties try: with open(path) as f: - sources.append((mtime, json.load(f))) + doc = json.load(f) except (json.JSONDecodeError, ValueError): print(f"· skipping unparseable {os.path.basename(path)}", file=sys.stderr) + continue + if not isinstance(doc, dict): + print(f"· skipping non-object .claude.json ({os.path.basename(path)})", file=sys.stderr) + continue + sources.append((order, doc)) sources.sort(key=lambda s: s[0]) if not sources: sys.exit(0) base = dict(sources[-1][1]) # newest copy wins for oauth etc. projects = {} for _, doc in sources: # oldest→newest: newest wins per key - projects.update(doc.get("projects", {}) or {}) + p = doc.get("projects", {}) + if isinstance(p, dict): + projects.update(p) for old, new in maps: + old = old.rstrip("/") for key in list(projects): - if key == old or key.startswith(old.rstrip("/") + "/"): - newkey = new + key[len(old.rstrip("/")):] if key != old else new + if key == old or key.startswith(old + "/"): + newkey = new + key[len(old):] if key != old else new projects.setdefault(newkey, projects.pop(key)) base["projects"] = projects with open(os.path.join(staging, "merged.out"), "w") as f: @@ -204,24 +256,40 @@ fi # ── 4. --map: rename session directories already in the volume ─── # (New copies are mapped at copy time; this handles data that landed in the # volume before the map was applied, e.g. an earlier migration run.) +# Safety rules: never touch a dir that IS the target or is itself a mapped +# target; move whole entries no-clobber; delete a leftover file only when it +# is byte-identical to the copy at the target; leave anything else in place +# with a warning. Nothing here can destroy data that exists nowhere else. for m in ${MAPS[@]+"${MAPS[@]}"}; do esc_old="$(escape "${m%%=*}")" esc_new="$(escape "${m#*=}")" docker run --rm -v "${VOLUME}:/dst" -e OLD="$esc_old" -e NEW="$esc_new" "$HELPER_IMAGE" sh -c ' - for d in /dst/.claude/projects/${OLD}*; do + base=/dst/.claude/projects + for d in "$base/$OLD" "$base/$OLD"-*; do [ -d "$d" ] || continue - rest="${d#/dst/.claude/projects/$OLD}" - tgt="/dst/.claude/projects/${NEW}${rest}" - if [ ! -e "$tgt" ]; then - mv "$d" "$tgt" && echo "· moved $(basename "$d") -> $(basename "$tgt")" - else - cd "$d" && find . -type f | while IFS= read -r f; do + case "$d" in "$base/$NEW"|"$base/$NEW"-*) continue;; esac # already a mapped target + rest="${d#"$base/$OLD"}" + tgt="$base/${NEW}${rest}" + [ "$d" = "$tgt" ] && continue + if [ ! -e "$tgt" ] && [ ! -L "$tgt" ]; then + mv "$d" "$tgt" && echo "moved $(basename "$d") -> $(basename "$tgt")" + continue + fi + ( cd "$d" || exit 1 + find . -mindepth 1 | sort | while IFS= read -r f; do f="${f#./}" - [ -e "$tgt/$f" ] || { mkdir -p "$tgt/$(dirname "$f")"; mv "$f" "$tgt/$f"; } - done - # Anything left in $d duplicates a file already at the target - cd / && rm -rf "$d" - echo "· merged $(basename "$d") into $(basename "$tgt")" + [ -e "./$f" ] || [ -L "./$f" ] || continue # parent already moved + if [ ! -e "$tgt/$f" ] && [ ! -L "$tgt/$f" ]; then + mkdir -p "$tgt/$(dirname "$f")" && mv "./$f" "$tgt/$f" + elif [ -f "./$f" ] && [ ! -L "./$f" ] && [ -f "$tgt/$f" ] && cmp -s "./$f" "$tgt/$f"; then + rm "./$f" # identical duplicate — safe to drop + fi + done ) + find "$d" -depth -type d -empty -exec rmdir {} \; 2>/dev/null || true + if [ -d "$d" ]; then + echo "WARNING: kept $(basename "$d") — it still holds entries that differ from $(basename "$tgt"); reconcile manually" >&2 + else + echo "merged $(basename "$d") into $(basename "$tgt")" fi done' done @@ -241,12 +309,16 @@ echo "" echo "Nothing was deleted. Once you've verified v2 sees your sessions, clean up" echo "the old v1 resources manually:" V1_CONTAINERS="$(docker ps -a --filter label=aibox.instance --format '{{.Names}}' || true)" +V1_PF="$(docker ps -a --filter label=aibox.pf.target --format '{{.Names}}' || true)" if [[ -n "$V1_CONTAINERS" ]]; then while IFS= read -r c; do echo " docker rm -f ${c}"; done <<< "$V1_CONTAINERS" fi +if [[ -n "$V1_PF" ]]; then + while IFS= read -r c; do echo " docker rm -f ${c}"; done <<< "$V1_PF" +fi if [[ -n "$AUTH_VOLS" ]]; then while IFS= read -r v; do echo " docker volume rm ${v}"; done <<< "$AUTH_VOLS" fi -if [[ -z "$V1_CONTAINERS" && -z "$AUTH_VOLS" ]]; then +if [[ -z "$V1_CONTAINERS" && -z "$V1_PF" && -z "$AUTH_VOLS" ]]; then echo " (none found)" fi From 547b970c0047a3c397192b16d067b8372e7a233c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 19:56:50 +0000 Subject: [PATCH 06/26] Polish round: fix dev-URL regression, simplify, document constraints Driven by three further audits (simplification, first-time-user UX walkthrough, cold-read maintainability), all verified against a live daemon. Bugs fixed: - Dev-server URLs were broken on modern Docker: docker inspect does not expand \t in --format (unlike docker ps), so _project_rows parsed zero rows, the Caddy map generated empty, and every dev URL returned 502 while status showed '-'. Space-separated format now; routing re-verified end-to-end (HTTP 200). - A busy proxy port left a dead Created-state container that the race recheck adopted as healthy: proxy 'running' with no host port bound and the 8080 fallback unreachable. The absent branch now discriminates by 'running' state, removes the corpse, and actually falls back. - The first interactive shell opened zsh-newuser-install; the entrypoint now seeds a minimal ~/.zshrc into the home volume. Simplifications (audit-verified safe): always pass --dangerously-skip-permissions (claude tolerates duplicates, drops the dedup scan); one in-container wait replaces the exec-per-second claude poll; one inspect fetches state+image together; hash fallback collapsed; one mkdir for CONFIG_DIR; unified is-running idiom; migration gains _copy_into_dst/_read_json helpers, halves .claude.json docker runs, and collapses the cleanup-print loops. The migration --map rename pass is deliberately kept: .claude.json keys are rekeyed unconditionally, so print-only handling would orphan pre-map sessions. UX (from the walkthrough): mental-model summary at the top of help; project names instead of container ids in stop/status; status table leads with PROJECT and de-jargons docker status; proxy start announced (was the one silent first-run gap); 'nothing was changed' on refused restores; real file size in the backup message; concise unknown-command error; actionable daemon-down hint; version prints 'dev' instead of the raw __CLI_VERSION__ placeholder; help documents shell's 1-arg vs N-arg semantics and AIBOX_URL_BASE. Maintainability: ~20 one-line comments for load-bearing constraints (container-name/proxy-label coupling, Caddy label indexing, bash-3.2 idioms, escape() must match Claude's own path escaping, timeout units, stderr-capture ordering), IMG_VER renamed VERSION_TAG, honest section headers, migration header renumbered to match its body. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- CONTRIBUTING.md | 2 + README.md | 4 +- bin/aibox | 261 +++++++++++++++++++++------------------ scripts/migrate-to-v2.sh | 86 +++++++------ 4 files changed, 196 insertions(+), 157 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dca9619..8df0eee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,8 @@ To test locally, symlink into your PATH: ln -sf "$(pwd)/bin/aibox" /usr/local/bin/aibox ``` +Dev-mode note: a checkout runs as version `dev`, so the image tag doesn't change between your edits — after modifying the embedded Dockerfile or entrypoint, force a rebuild with `docker rmi aibox:dev-node` (releases bump the tag, so users get rebuilds automatically). + ## Publishing Publishing is fully automated. Pushing a version tag triggers CI which creates a GitHub release, publishes to npm, and updates the Homebrew tap. diff --git a/README.md b/README.md index 1e041c7..6d230fa 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are | `aibox backup [dir]` | Snapshot the home volume to a tar.gz | | `aibox restore ` | Restore a backup (safety-backup of current state first) | | `aibox update` | Update the CLI; image rebuilds automatically on next run | -| `aibox version` / `help` | | +| `aibox version` / `help` | Versions + docker state / this table's long form | ## Config @@ -97,7 +97,7 @@ The image is `node:-bookworm` (Debian) plus a few basics (zsh, sudo, ri ## Prerequisites -Docker via [Colima](https://github.com/abiosoft/colima), [OrbStack](https://orbstack.dev), or [Docker Desktop](https://www.docker.com/products/docker-desktop/): +Built for macOS; works on Linux too (any running Docker daemon). On macOS, Docker via [Colima](https://github.com/abiosoft/colima), [OrbStack](https://orbstack.dev), or [Docker Desktop](https://www.docker.com/products/docker-desktop/): ```bash brew install colima docker && colima start diff --git a/bin/aibox b/bin/aibox index bca09b0..ba909b6 100755 --- a/bin/aibox +++ b/bin/aibox @@ -1,12 +1,18 @@ #!/usr/bin/env bash # aibox — persistent Docker sandboxes for Claude Code. # +# One container per project. One shared home volume (aibox-home) holds all +# Claude state. Nothing is ever deleted without asking. +# # Usage: # aibox [claude] [args...] Start/attach this project's container and run -# Claude Code (yolo). Extra args pass to claude -# verbatim (--resume, -p, --model, ...). +# Claude Code with no permission prompts — the +# container is the sandbox. Extra args pass to +# claude verbatim (--resume, -p, --model, ...). # `aibox --resume` also works. -# aibox shell [cmd...] zsh in the container, or run a one-off command +# aibox shell [cmd...] zsh in the container. One argument runs as a +# shell string (pipes/globs work); several run +# as a safely-quoted command. # aibox stop [--all] Stop this project's container # (--all: every aibox container + proxy). # Never deletes anything; next run re-attaches. @@ -24,7 +30,9 @@ # http://..aibox.localhost # (Chrome/Edge/Firefox out of the box; Safari needs macOS 26+. No ports to # publish, no restarts — the proxy reaches the container over the Docker -# network.) +# network.) Inside the container, $AIBOX_URL_BASE holds +# ".aibox.localhost[:port]", and exported ANTHROPIC_* host vars +# are forwarded. # # Config (~/.aibox/config, key=value, all optional): # node_version=24 # base image: node:-bookworm @@ -37,10 +45,14 @@ # All Claude state (sessions, login, the claude binary itself) lives in one # shared Docker volume: aibox-home. Containers are disposable; the volume is # the thing `aibox backup` protects. +# +# (This header is the output of `aibox help` — keep it accurate.) set -euo pipefail unset CDPATH +# Replaced by the release workflow; left as-is = running from a git +# checkout, which behaves as version "dev". CLI_VERSION="__CLI_VERSION__" CONFIG_DIR="${HOME}/.aibox" VOLUME="aibox-home" @@ -77,20 +89,21 @@ if [[ -f "${CONFIG_DIR}/config" ]]; then esac done < "${CONFIG_DIR}/config" fi +mkdir -p "$CONFIG_DIR" # ── Derived names ──────────────────────────────────────────────── PROJECT_DIR="$(pwd -P)" SLUG="$(basename "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g; s/_/-/g')" -if command -v sha256sum >/dev/null 2>&1; then - HASH6="$(printf '%s' "$PROJECT_DIR" | sha256sum | cut -c1-6)" -else - HASH6="$(printf '%s' "$PROJECT_DIR" | shasum -a 256 | cut -c1-6)" -fi +HASH6="$(printf '%s' "$PROJECT_DIR" | { sha256sum || shasum -a 256; } 2>/dev/null | cut -c1-6)" +# Name format aibox-- is load-bearing: _proxy_pairs extracts +# the trailing hash6 with ${name##*-} for slug-collision URLs. CONTAINER="aibox-${SLUG}-${HASH6}" -IMG_VER="$CLI_VERSION" -[[ "$IMG_VER" == "__CLI_VERSION__" ]] && IMG_VER="dev" -IMAGE="aibox:${IMG_VER}-node${NODE_VERSION}" +VERSION_TAG="$CLI_VERSION" +[[ "$VERSION_TAG" == "__CLI_VERSION__" ]] && VERSION_TAG="dev" +# VERSION_TAG also names backups (aibox-home-- && aibox claude --resume" echo "" echo "Nothing was deleted. Once you've verified v2 sees your sessions, clean up" echo "the old v1 resources manually:" +# v1 labels: aibox.instance = project containers, aibox.pf.target = socat +# port-forward helpers. Docker names contain no whitespace, so unquoted +# expansion into printf is safe. V1_CONTAINERS="$(docker ps -a --filter label=aibox.instance --format '{{.Names}}' || true)" -V1_PF="$(docker ps -a --filter label=aibox.pf.target --format '{{.Names}}' || true)" -if [[ -n "$V1_CONTAINERS" ]]; then - while IFS= read -r c; do echo " docker rm -f ${c}"; done <<< "$V1_CONTAINERS" -fi -if [[ -n "$V1_PF" ]]; then - while IFS= read -r c; do echo " docker rm -f ${c}"; done <<< "$V1_PF" -fi -if [[ -n "$AUTH_VOLS" ]]; then - while IFS= read -r v; do echo " docker volume rm ${v}"; done <<< "$AUTH_VOLS" -fi -if [[ -z "$V1_CONTAINERS" && -z "$V1_PF" && -z "$AUTH_VOLS" ]]; then - echo " (none found)" -fi +V1_PORTFWD="$(docker ps -a --filter label=aibox.pf.target --format '{{.Names}}' || true)" +# shellcheck disable=SC2086 +{ + [[ -n "$V1_CONTAINERS$V1_PORTFWD" ]] && printf ' docker rm -f %s\n' $V1_CONTAINERS $V1_PORTFWD + [[ -n "$AUTH_VOLS" ]] && printf ' docker volume rm %s\n' $AUTH_VOLS + [[ -z "$V1_CONTAINERS$V1_PORTFWD$AUTH_VOLS" ]] && echo " (none found)" + true +} From 6f9b89d835a69fc0094b2d527dd5caf565ef8f07 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 02:03:03 +0000 Subject: [PATCH 07/26] Fix .claude.json write under Colima (unshared /var/folders) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mktemp -d lands in /var/folders on macOS, which Colima does not share into its VM, so bind-mounting the staging dir produced an empty mount and the merged .claude.json write failed. Pipe the file over stdin instead — works regardless of the runtime's mount configuration. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- scripts/migrate-to-v2.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/migrate-to-v2.sh b/scripts/migrate-to-v2.sh index ed90ec9..4445615 100755 --- a/scripts/migrate-to-v2.sh +++ b/scripts/migrate-to-v2.sh @@ -259,8 +259,11 @@ with open(os.path.join(staging, "merged.out"), "w") as f: print(f"· merged .claude.json from {len(sources)} source(s), {len(projects)} project(s)") PY if [[ -f "${STAGING}/merged.out" ]]; then - docker run --rm -v "${STAGING}:/stage:ro" -v "${VOLUME}:/dst" "$HELPER_IMAGE" \ - sh -c 'cp /stage/merged.out /dst/.claude/.claude.json && chmod 600 /dst/.claude/.claude.json' + # Pipe via stdin, don't bind-mount: $STAGING is under macOS's /var/folders, + # which Colima doesn't share into its VM (the mount appears empty there). + docker run --rm -i -v "${VOLUME}:/dst" "$HELPER_IMAGE" \ + sh -c 'cat > /dst/.claude/.claude.json && chmod 600 /dst/.claude/.claude.json' \ + < "${STAGING}/merged.out" ok "wrote merged .claude.json" fi else From 1953cf4b19cfca8bcb006c50089e96a3e70a5967 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 10:02:34 +0000 Subject: [PATCH 08/26] Make permission prompts the default; --yolo opts into bypass aibox claude now passes --allow-dangerously-skip-permissions (prompts on, bypass selectable in-session). A new aibox --yolo flag swaps it for --dangerously-skip-permissions, restoring the old skip-everything behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- README.md | 12 ++++++------ REVAMP.md | 18 ++++++++++-------- bin/aibox | 23 +++++++++++++++++------ 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6d230fa..110f437 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@

-> *One command into a yolo-mode Claude Code sandbox. Nothing gets destroyed behind your back.* +> *One command into a sandboxed Claude Code. Nothing gets destroyed behind your back.* ```bash cd myproject && aibox ``` -aibox runs Claude Code with `--dangerously-skip-permissions` inside a Docker container, so the agent can run wild while your Mac stays clean. One container per project, all sharing a single persistent home volume — one login, one session history, everything survives. +aibox runs Claude Code inside a Docker container, so the agent can run wild while your Mac stays clean. Add `--yolo` to skip all permission prompts. One container per project, all sharing a single persistent home volume — one login, one session history, everything survives. ## Quickstart @@ -30,7 +30,7 @@ aibox # 3. run (builds the image on first use) - **Your project is bind-mounted at its real path.** Changes sync both ways, paths inside the container match your Mac. - **One shared home volume (`aibox-home`).** Claude login, every session, shell history, and the `claude` binary itself live in a Docker volume mounted at `/home/aibox` in every container. Log in once, resume any session from any project, forever. - **Nothing is destroyed implicitly.** Exiting Claude leaves the container running in the background (idle containers cost ~nothing) — the next `aibox` attaches instantly. `aibox stop` stops it; a stopped container keeps everything, including packages you apt-installed. Containers are only recreated when the image changes, and the home volume survives even that. -- **Always yolo.** The container *is* the sandbox. No permission prompts, full sudo inside. +- **The container is the sandbox.** Full sudo inside. Permission prompts are on by default, but bypass mode is always available in-session (aibox passes claude's `--allow-dangerously-skip-permissions`); run `aibox --yolo` to start with all prompts skipped (`--dangerously-skip-permissions`). ## Dev servers @@ -58,7 +58,7 @@ Backups are safe to take while sessions are running. ## Migrating from aibox v1 -v2 is a clean break: one always-yolo container per project, one shared home volume, and no destructive lifecycle. A standalone script merges all your v1 data — every per-image `aibox-auth-*` volume **and** any old backup folders — into the new volume: +v2 is a clean break: one container per project, one shared home volume, and no destructive lifecycle. A standalone script merges all your v1 data — every per-image `aibox-auth-*` volume **and** any old backup folders — into the new volume: ```bash # npm installs ship the script next to the CLI: @@ -74,7 +74,7 @@ Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are | Command | What it does | |---------|-------------| -| `aibox` / `aibox claude [args]` | Start/attach the project container, run Claude Code (yolo). Args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | +| `aibox` / `aibox claude [args]` | Start/attach the project container, run Claude Code. `--yolo` skips all permission prompts; other args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | | `aibox shell [cmd]` | zsh in the container, or run a one-off command | | `aibox stop [--all]` | Stop this project's container (`--all`: everything incl. proxy). Loses nothing | | `aibox status` | Containers, dev URLs, home volume size | @@ -105,7 +105,7 @@ brew install colima docker && colima start aibox auto-starts an installed-but-stopped runtime; it won't install one for you. -Note on the dev-server proxy port: the proxy asks Docker for `127.0.0.1:80` only, but some Colima versions ignore the loopback restriction and publish the port on your LAN. Everything behind it is your own yolo-mode dev traffic, but if that matters to you, keep Colima current (or use OrbStack/Docker Desktop). +Note on the dev-server proxy port: the proxy asks Docker for `127.0.0.1:80` only, but some Colima versions ignore the loopback restriction and publish the port on your LAN. Everything behind it is your own sandboxed dev traffic, but if that matters to you, keep Colima current (or use OrbStack/Docker Desktop). ## Contributing diff --git a/REVAMP.md b/REVAMP.md index 7fdf72e..fae4c34 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -1,7 +1,7 @@ # aibox v2 — Revamp Requirements Requirements for rewriting aibox around how it is actually used: one trusted -machine, one user, Claude Code in yolo mode, containers that are cheap to +machine, one user, Claude Code in a sandbox, containers that are cheap to enter and impossible to lose data in. This document is the spec for the rewrite. It records the decisions already @@ -60,7 +60,7 @@ These were decided explicitly; the rewrite must not relitigate them. | # | Decision | Choice | Rationale | |---|----------|--------|-----------| | D1 | Language / distribution | Single bash file, published to npm as `aibox-cli` (same as today) | New scope is ~500 lines, not 2,400; zero runtime deps beyond Docker | -| D2 | Security posture | Always yolo. No safe mode, no firewall, no restricted sudo | Isolation comes from the container boundary itself; the modes were never used | +| D2 | Security posture | Permission prompts on by default with bypass selectable in-session (`--allow-dangerously-skip-permissions`); `aibox --yolo` starts in bypass (`--dangerously-skip-permissions`). No firewall, no restricted sudo | Isolation comes from the container boundary itself; the prompt default is the only mode switch | | D3 | Container lifecycle | Container **keeps running in the background** when the last session exits. `aibox stop` stops it explicitly. It is only ever *removed* when the image changes (and then only recreated, never left absent) | Instant re-attach; idle containers cost ~nothing; kills the data-loss footgun | | D4 | Instances | Exactly one container per project directory; multiple terminals just `exec` into the same container. No named instances | Matches real usage | | D5 | Session/home storage | One **global** named volume `aibox-home` mounted at `/home/aibox`, shared by all project containers | One login, one session history, one thing to back up; survives image changes by construction | @@ -75,7 +75,7 @@ These were decided explicitly; the rewrite must not relitigate them. ``` aibox # same as `aibox claude` -aibox claude [args...] # ensure image/container/proxy, then run claude (yolo) inside; extra args pass through (--resume, -c, etc.) +aibox claude [args...] # ensure image/container/proxy, then run claude inside; --yolo skips all prompts, other args pass through (--resume, -c, etc.) aibox shell [cmd...] # zsh in the container, or run a one-off command aibox stop [--all] # stop this project's container (--all: every aibox container + proxy). Never deletes anything aibox status # all aibox containers: project, state, uptime, image; proxy URLs; volume size @@ -168,8 +168,9 @@ aibox claude/shell: docker exec -it ... ``` -- `claude` is invoked with `--dangerously-skip-permissions` plus any - passthrough args. +- `claude` is invoked with `--allow-dangerously-skip-permissions` (prompts + on, bypass selectable in-session) plus any passthrough args; `--yolo` + swaps that for `--dangerously-skip-permissions` (all prompts skipped). - **Nothing happens on session exit.** No idle-detection, no auto-stop, no down. The container idles at ~zero CPU until the next attach or an explicit `aibox stop`. @@ -275,7 +276,7 @@ no commands, no restarts, no sidecars, no tunnels. Colima/OrbStack emulate loopback-only publishing by binding `0.0.0.0` and rejecting non-loopback sources (old Colima versions ignored the loopback restriction entirely, exposing the port on the LAN — acceptable here - since everything behind it is already yolo-mode dev traffic, but worth a + since everything behind it is already sandboxed dev traffic, but worth a line in the README). - Non-goals: HTTPS (plain http on loopback is fine), public sharing (Cloudflare tunnels remain possible manually; a built-in `aibox share` is @@ -368,7 +369,8 @@ Removed entirely, with no deprecation shims — v2 is a clean break - Commands: `up`, `down`, `build`, `init`, `port-forward`, `volumes`, `disk`, `clean`, `nuke`, `doctor`. - Flags: `-n/--name`, `-r/--repo`, `-b/--branch`, `-c/--copy`, - `-w/--worktree`, `-y/--yolo` (now the only behavior), `-s/--safe`, + `-w/--worktree`, `-y/--yolo` (v2 keeps only the long `--yolo` spelling, + now meaning claude's bypass-permissions flag), `-s/--safe`, `-i/--image`, `--all`/`--clean` on `down`. - Mechanisms: compose orchestration (v1 pipes generated YAML into `docker compose -f -`) and the JetBrains-facing `compose.dev.yaml`, socat @@ -416,7 +418,7 @@ Recorded post-implementation; intentional: The rewrite is done when all of these hold: 1. `cd proj && aibox` on a fresh machine (Docker present): builds image, - creates volume/network/container/proxy, lands in a yolo Claude session. + creates volume/network/container/proxy, lands in a Claude session. 2. Exit Claude, run `aibox claude` again → re-attached in under a second; `apt install imagemagick` from a previous session is still installed. 3. Two terminal tabs, same project: both `aibox claude` concurrently → two diff --git a/bin/aibox b/bin/aibox index ba909b6..72c8c79 100755 --- a/bin/aibox +++ b/bin/aibox @@ -6,10 +6,11 @@ # # Usage: # aibox [claude] [args...] Start/attach this project's container and run -# Claude Code with no permission prompts — the -# container is the sandbox. Extra args pass to -# claude verbatim (--resume, -p, --model, ...). -# `aibox --resume` also works. +# Claude Code. Permission prompts are on by +# default, with bypass mode available in-session; +# pass --yolo to skip all prompts from the start. +# Other args pass to claude verbatim (--resume, +# -p, --model, ...). `aibox --resume` works too. # aibox shell [cmd...] zsh in the container. One argument runs as a # shell string (pipes/globs work); several run # as a safely-quoted command. @@ -453,8 +454,18 @@ _dexec() { cmd_claude() { _ensure_all _ensure_claude_bin - # Always passed; claude tolerates the flag appearing twice. - _dexec claude --dangerously-skip-permissions "$@" + # --yolo → bypass permissions from the start; without it, bypass is + # available in-session (claude's allow flag) but permissions default on. + local perm_flag=--allow-dangerously-skip-permissions + local args=() a + for a in "$@"; do + if [[ "$a" == "--yolo" ]]; then + perm_flag=--dangerously-skip-permissions + else + args+=("$a") + fi + done + _dexec claude "$perm_flag" ${args[@]+"${args[@]}"} } cmd_shell() { From e569c1fb4b17b61a6fe9fbd01a937fb81006b063 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 10:07:40 +0000 Subject: [PATCH 09/26] Add --copy: disposable snapshot container, no bind mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aibox --copy runs claude in a fresh container per run on a docker-cp'd snapshot of the project at the same absolute path — the real directory is never mounted, so the agent cannot touch it. The copy shares the aibox-home volume (login, sessions, claude binary), gets its own dev URLs under -copy, and is removed by an EXIT trap when the session ends; work is kept by committing/pushing from inside. Default behavior (bind mount, persistent container) is unchanged, and --copy composes with --yolo. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- README.md | 3 ++- REVAMP.md | 18 ++++++++++---- bin/aibox | 74 +++++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 110f437..420ad1b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ aibox # 3. run (builds the image on first use) - **One shared home volume (`aibox-home`).** Claude login, every session, shell history, and the `claude` binary itself live in a Docker volume mounted at `/home/aibox` in every container. Log in once, resume any session from any project, forever. - **Nothing is destroyed implicitly.** Exiting Claude leaves the container running in the background (idle containers cost ~nothing) — the next `aibox` attaches instantly. `aibox stop` stops it; a stopped container keeps everything, including packages you apt-installed. Containers are only recreated when the image changes, and the home volume survives even that. - **The container is the sandbox.** Full sudo inside. Permission prompts are on by default, but bypass mode is always available in-session (aibox passes claude's `--allow-dangerously-skip-permissions`); run `aibox --yolo` to start with all prompts skipped (`--dangerously-skip-permissions`). +- **Disposable copies on demand.** `aibox --copy` runs Claude in a fresh container on a *snapshot* of the project instead — nothing is bind-mounted, so the agent physically can't touch your real files. Same login and session history (shared home volume), own dev URLs (`.-copy.aibox.localhost`). The container is removed when the session exits; keep work by committing and pushing from inside. Each `--copy` run is its own independent sandbox. Combines with `--yolo`. ## Dev servers @@ -74,7 +75,7 @@ Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are | Command | What it does | |---------|-------------| -| `aibox` / `aibox claude [args]` | Start/attach the project container, run Claude Code. `--yolo` skips all permission prompts; other args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | +| `aibox` / `aibox claude [args]` | Start/attach the project container, run Claude Code. `--yolo` skips all permission prompts; `--copy` uses a disposable snapshot container (no bind mount, removed on exit); other args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | | `aibox shell [cmd]` | zsh in the container, or run a one-off command | | `aibox stop [--all]` | Stop this project's container (`--all`: everything incl. proxy). Loses nothing | | `aibox status` | Containers, dev URLs, home volume size | diff --git a/REVAMP.md b/REVAMP.md index fae4c34..3ba6e84 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -62,9 +62,9 @@ These were decided explicitly; the rewrite must not relitigate them. | D1 | Language / distribution | Single bash file, published to npm as `aibox-cli` (same as today) | New scope is ~500 lines, not 2,400; zero runtime deps beyond Docker | | D2 | Security posture | Permission prompts on by default with bypass selectable in-session (`--allow-dangerously-skip-permissions`); `aibox --yolo` starts in bypass (`--dangerously-skip-permissions`). No firewall, no restricted sudo | Isolation comes from the container boundary itself; the prompt default is the only mode switch | | D3 | Container lifecycle | Container **keeps running in the background** when the last session exits. `aibox stop` stops it explicitly. It is only ever *removed* when the image changes (and then only recreated, never left absent) | Instant re-attach; idle containers cost ~nothing; kills the data-loss footgun | -| D4 | Instances | Exactly one container per project directory; multiple terminals just `exec` into the same container. No named instances | Matches real usage | +| D4 | Instances | Exactly one persistent container per project directory; multiple terminals just `exec` into the same container. No named instances. `--copy` additionally spawns a disposable container per run (unique name, removed on exit) | Matches real usage; disposable runs must not fight over one name | | D5 | Session/home storage | One **global** named volume `aibox-home` mounted at `/home/aibox`, shared by all project containers | One login, one session history, one thing to back up; survives image changes by construction | -| D6 | Project mount | Bind-mount the project directory at **the same absolute path as on the host** (today's default-mode behavior, `bin/aibox:475`) | Claude session keys are derived from cwd — keeping the path keeps every existing session valid with zero migration | +| D6 | Project mount | Bind-mount the project directory at **the same absolute path as on the host** (today's default-mode behavior, `bin/aibox:475`). `--copy` skips the bind mount and `docker cp`s a snapshot to the same path instead | Claude session keys are derived from cwd — keeping the path keeps every existing session valid with zero migration (copy mode included, since the path matches) | | D7 | Backup | Built-in `aibox backup` / `aibox restore` — clean and simple, must actually work | Replaces the external script | | D8 | Migration of old data | A **separate standalone script** (not part of the CLI) that merges both live `aibox-auth-*` volumes **and** old backup folders into the new `aibox-home` volume | One-time operation; keeps the CLI clean | | D9 | Dev-server access | Host-side reverse proxy with wildcard subdomains: `http://..aibox.localhost` → container port. Replaces port-forward sidecars and ad-hoc Cloudflare tunnels for local use | `*.localhost` (multi-level included) resolves to loopback natively in Chrome/Edge and Firefox 84+ with zero setup, no sudo, no dnsmasq; Safari only gained this on macOS 26 Tahoe (WebKit bug 160504). CLI tools using the system resolver (curl) don't resolve it — documented workaround, not solved. `/etc/hosts` can't do wildcards, so there is no hosts-file step | @@ -75,7 +75,7 @@ These were decided explicitly; the rewrite must not relitigate them. ``` aibox # same as `aibox claude` -aibox claude [args...] # ensure image/container/proxy, then run claude inside; --yolo skips all prompts, other args pass through (--resume, -c, etc.) +aibox claude [args...] # ensure image/container/proxy, then run claude inside; --yolo skips all prompts, --copy uses a disposable snapshot container, other args pass through (--resume, -c, etc.) aibox shell [cmd...] # zsh in the container, or run a one-off command aibox stop [--all] # stop this project's container (--all: every aibox container + proxy). Never deletes anything aibox status # all aibox containers: project, state, uptime, image; proxy URLs; volume size @@ -171,6 +171,13 @@ aibox claude/shell: - `claude` is invoked with `--allow-dangerously-skip-permissions` (prompts on, bypass selectable in-session) plus any passthrough args; `--yolo` swaps that for `--dangerously-skip-permissions` (all prompts skipped). +- `--copy` runs claude in a disposable container instead: fresh container + per run (`-copy-`, no restart policy, labeled + `aibox.copy=1` with slug `-copy` for its own dev URLs), no bind + mount — the project is `docker cp`'d to the same absolute path and + chowned. It shares the home volume, so login and session history work, + and the container is removed on exit (EXIT trap; work is kept by + committing/pushing from inside). - **Nothing happens on session exit.** No idle-detection, no auto-stop, no down. The container idles at ~zero CPU until the next attach or an explicit `aibox stop`. @@ -369,8 +376,9 @@ Removed entirely, with no deprecation shims — v2 is a clean break - Commands: `up`, `down`, `build`, `init`, `port-forward`, `volumes`, `disk`, `clean`, `nuke`, `doctor`. - Flags: `-n/--name`, `-r/--repo`, `-b/--branch`, `-c/--copy`, - `-w/--worktree`, `-y/--yolo` (v2 keeps only the long `--yolo` spelling, - now meaning claude's bypass-permissions flag), `-s/--safe`, + `-w/--worktree`, `-y/--yolo` and `-c/--copy` (v2 keeps only the long + `--yolo` / `--copy` spellings: claude's bypass-permissions flag and the + disposable snapshot container), `-s/--safe`, `-i/--image`, `--all`/`--clean` on `down`. - Mechanisms: compose orchestration (v1 pipes generated YAML into `docker compose -f -`) and the JetBrains-facing `compose.dev.yaml`, socat diff --git a/bin/aibox b/bin/aibox index 72c8c79..ed73e80 100755 --- a/bin/aibox +++ b/bin/aibox @@ -9,6 +9,11 @@ # Claude Code. Permission prompts are on by # default, with bypass mode available in-session; # pass --yolo to skip all prompts from the start. +# Pass --copy for a disposable container on a +# snapshot of the project instead — no bind +# mount, the real directory can't be touched, +# and the container is removed on exit (commit & +# push from inside to keep work). # Other args pass to claude verbatim (--resume, # -p, --model, ...). `aibox --resume` works too. # aibox shell [cmd...] zsh in the container. One argument runs as a @@ -450,21 +455,74 @@ _dexec() { docker exec "${tty_args[@]}" -u aibox -w "$PROJECT_DIR" "${env_args[@]}" "$CONTAINER" "$@" } +# ── Disposable copy container (--copy) ─────────────────────────── +# A fresh container per run on a snapshot of the project — no bind mount, so +# the real directory cannot be touched from inside. Shares the home volume +# (login, sessions, claude binary) and gets its own dev URLs under +# -copy. Removed when the session exits; work is kept by committing +# and pushing from inside before exiting. + +_cleanup_copy() { + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + # Drop the copy's proxy route; only touch a proxy that is already running. + { [[ "$(_state "$PROXY")" == "running" ]] && _ensure_proxy >/dev/null 2>&1; } || true +} + +_ensure_copy_all() { + _ensure_docker + _ensure_image + _require_safe_dir + docker network create "$NETWORK" >/dev/null 2>&1 || true + docker volume create "$VOLUME" >/dev/null 2>&1 || true + # Unique name per run: concurrent --copy sessions are separate sandboxes. + # The trailing random segment doubles as the URL suffix if slugs collide + # (see _proxy_pairs). No --restart policy: disposable by design. + CONTAINER="${CONTAINER}-copy-$(printf '%04x%04x' "$RANDOM" "$RANDOM")" + trap _cleanup_copy EXIT + docker run -d --name "$CONTAINER" \ + --network "$NETWORK" \ + --init \ + --add-host host.docker.internal:host-gateway \ + -v "${VOLUME}:/home/aibox" \ + -w "$PROJECT_DIR" \ + -e CLAUDE_CONFIG_DIR=/home/aibox/.claude \ + -l "aibox.slug=${SLUG}-copy" \ + -l "aibox.path=${PROJECT_DIR}" \ + -l "aibox.copy=1" \ + "$IMAGE" >/dev/null + local i=0 + until [[ "$(docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null)" == "true" ]]; do + (( i++ >= 25 )) && _die "Copy container failed to start." # 5s + sleep 0.2 + done + _info "Snapshotting project into the container (the real ${PROJECT_DIR} is not mounted)..." + docker cp "${PROJECT_DIR}/." "${CONTAINER}:${PROJECT_DIR}" || _die "Project snapshot failed." + docker exec -u root "$CONTAINER" chown -R aibox:aibox "$PROJECT_DIR" + _ensure_proxy + _ok "Disposable copy ready — removed on exit; commit & push from inside to keep work. Dev servers: http://.$(_pub_label).aibox.localhost$(_proxy_suffix)" +} + # ── Commands ───────────────────────────────────────────────────── cmd_claude() { - _ensure_all - _ensure_claude_bin # --yolo → bypass permissions from the start; without it, bypass is # available in-session (claude's allow flag) but permissions default on. - local perm_flag=--allow-dangerously-skip-permissions + # --copy → disposable snapshot container instead of the persistent + # bind-mounted one. + local perm_flag=--allow-dangerously-skip-permissions copy=false local args=() a for a in "$@"; do - if [[ "$a" == "--yolo" ]]; then - perm_flag=--dangerously-skip-permissions - else - args+=("$a") - fi + case "$a" in + --yolo) perm_flag=--dangerously-skip-permissions ;; + --copy) copy=true ;; + *) args+=("$a") ;; + esac done + if [[ "$copy" == "true" ]]; then + _ensure_copy_all + else + _ensure_all + fi + _ensure_claude_bin _dexec claude "$perm_flag" ${args[@]+"${args[@]}"} } From 4662e3256a9847e630f5e0968d49b3431024a84a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 02:30:47 +0000 Subject: [PATCH 10/26] Add aibox run ; make claude a thin alias for it One generic launcher (cmd_run) now owns flag parsing, container choice, and exec: aibox / aibox claude dispatch to 'run claude'. --copy works for any program; --yolo stays claude-specific (rejected otherwise, since other harnesses have their own approval flags that pass through as normal args). cmd_claude is gone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- README.md | 5 +++-- REVAMP.md | 11 ++++++--- bin/aibox | 67 ++++++++++++++++++++++++++++++++++++------------------- 3 files changed, 55 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 420ad1b..93d28b1 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ aibox # 3. run (builds the image on first use) - **One shared home volume (`aibox-home`).** Claude login, every session, shell history, and the `claude` binary itself live in a Docker volume mounted at `/home/aibox` in every container. Log in once, resume any session from any project, forever. - **Nothing is destroyed implicitly.** Exiting Claude leaves the container running in the background (idle containers cost ~nothing) — the next `aibox` attaches instantly. `aibox stop` stops it; a stopped container keeps everything, including packages you apt-installed. Containers are only recreated when the image changes, and the home volume survives even that. - **The container is the sandbox.** Full sudo inside. Permission prompts are on by default, but bypass mode is always available in-session (aibox passes claude's `--allow-dangerously-skip-permissions`); run `aibox --yolo` to start with all prompts skipped (`--dangerously-skip-permissions`). -- **Disposable copies on demand.** `aibox --copy` runs Claude in a fresh container on a *snapshot* of the project instead — nothing is bind-mounted, so the agent physically can't touch your real files. Same login and session history (shared home volume), own dev URLs (`.-copy.aibox.localhost`). The container is removed when the session exits; keep work by committing and pushing from inside. Each `--copy` run is its own independent sandbox. Combines with `--yolo`. +- **Disposable copies on demand.** `aibox --copy` runs Claude in a fresh container on a *snapshot* of the project instead — nothing is bind-mounted, so the agent physically can't touch your real files. Same login and session history (shared home volume), own dev URLs (`.-copy.aibox.localhost`). The container is removed when the session exits; keep work by committing and pushing from inside. Each `--copy` run is its own independent sandbox. Combines with `--yolo`, and works for any program via `aibox run --copy`. ## Dev servers @@ -75,7 +75,8 @@ Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are | Command | What it does | |---------|-------------| -| `aibox` / `aibox claude [args]` | Start/attach the project container, run Claude Code. `--yolo` skips all permission prompts; `--copy` uses a disposable snapshot container (no bind mount, removed on exit); other args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | +| `aibox` / `aibox claude [args]` | Shorthand for `aibox run claude`. `--yolo` skips all permission prompts; `--copy` uses a disposable snapshot container (no bind mount, removed on exit); other args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | +| `aibox run [--copy] [args]` | Run any program in the sandbox (e.g. `aibox run codex`). `--copy` works the same as above; the program's own flags pass through | | `aibox shell [cmd]` | zsh in the container, or run a one-off command | | `aibox stop [--all]` | Stop this project's container (`--all`: everything incl. proxy). Loses nothing | | `aibox status` | Containers, dev URLs, home volume size | diff --git a/REVAMP.md b/REVAMP.md index 3ba6e84..5e81b2b 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -75,7 +75,8 @@ These were decided explicitly; the rewrite must not relitigate them. ``` aibox # same as `aibox claude` -aibox claude [args...] # ensure image/container/proxy, then run claude inside; --yolo skips all prompts, --copy uses a disposable snapshot container, other args pass through (--resume, -c, etc.) +aibox claude [args...] # shorthand for `aibox run claude`; --yolo skips all prompts, --copy uses a disposable snapshot container, other args pass through (--resume, -c, etc.) +aibox run [--copy] [args...] # ensure image/container/proxy, then run any program inside (e.g. aibox run codex) aibox shell [cmd...] # zsh in the container, or run a one-off command aibox stop [--all] # stop this project's container (--all: every aibox container + proxy). Never deletes anything aibox status # all aibox containers: project, state, uptime, image; proxy URLs; volume size @@ -168,9 +169,13 @@ aibox claude/shell: docker exec -it ... ``` -- `claude` is invoked with `--allow-dangerously-skip-permissions` (prompts +- One generic launcher (`cmd_run`) runs any program in the sandbox; + `aibox`/`aibox claude` dispatch to `run claude`. When the program is + claude, it is invoked with `--allow-dangerously-skip-permissions` (prompts on, bypass selectable in-session) plus any passthrough args; `--yolo` - swaps that for `--dangerously-skip-permissions` (all prompts skipped). + swaps that for `--dangerously-skip-permissions` (all prompts skipped) and + is rejected for other programs (their approval flags pass through as + normal args). - `--copy` runs claude in a disposable container instead: fresh container per run (`-copy-`, no restart policy, labeled `aibox.copy=1` with slug `-copy` for its own dev URLs), no bind diff --git a/bin/aibox b/bin/aibox index ed73e80..b48efb0 100755 --- a/bin/aibox +++ b/bin/aibox @@ -5,17 +5,21 @@ # Claude state. Nothing is ever deleted without asking. # # Usage: -# aibox [claude] [args...] Start/attach this project's container and run -# Claude Code. Permission prompts are on by -# default, with bypass mode available in-session; -# pass --yolo to skip all prompts from the start. -# Pass --copy for a disposable container on a -# snapshot of the project instead — no bind -# mount, the real directory can't be touched, -# and the container is removed on exit (commit & -# push from inside to keep work). -# Other args pass to claude verbatim (--resume, -# -p, --model, ...). `aibox --resume` works too. +# aibox [claude] [args...] Shorthand for `aibox run claude`: start/attach +# this project's container and run Claude Code. +# Permission prompts are on by default, with +# bypass mode available in-session; pass --yolo +# to skip all prompts from the start. Other args +# pass to claude verbatim (--resume, -p, +# --model, ...). `aibox --resume` works too. +# aibox run [--copy] [args...] +# Run any program in the container (e.g. +# aibox run codex). With --copy (also on plain +# aibox/claude), a disposable container on a +# snapshot of the project is used instead — no +# bind mount, the real directory can't be +# touched, and the container is removed on exit +# (commit & push from inside to keep work). # aibox shell [cmd...] zsh in the container. One argument runs as a # shell string (pipes/globs work); several run # as a safely-quoted command. @@ -503,27 +507,43 @@ _ensure_copy_all() { } # ── Commands ───────────────────────────────────────────────────── -cmd_claude() { - # --yolo → bypass permissions from the start; without it, bypass is - # available in-session (claude's allow flag) but permissions default on. - # --copy → disposable snapshot container instead of the persistent - # bind-mounted one. - local perm_flag=--allow-dangerously-skip-permissions copy=false - local args=() a +# Run any program in the sandbox. `aibox` / `aibox claude` is just +# `aibox run claude` (see dispatch). aibox's own flags are consumed wherever +# they appear in the argv; everything else passes through verbatim: +# --copy disposable snapshot container instead of the persistent +# bind-mounted one (any program) +# --yolo claude only: start in bypass-permissions mode; without it, +# bypass is available in-session (claude's allow flag) but +# permission prompts default on +cmd_run() { + local copy=false yolo=false args=() a for a in "$@"; do case "$a" in - --yolo) perm_flag=--dangerously-skip-permissions ;; --copy) copy=true ;; + --yolo) yolo=true ;; *) args+=("$a") ;; esac done + [[ ${#args[@]} -ge 1 ]] || _die "Usage: aibox run [--copy] [args...]" + if [[ "$yolo" == "true" && "${args[0]}" != "claude" ]]; then + _die "--yolo maps to claude's permission flags — pass ${args[0]}'s own approval flags as normal args." + fi + if [[ "$copy" == "true" ]]; then _ensure_copy_all else _ensure_all fi - _ensure_claude_bin - _dexec claude "$perm_flag" ${args[@]+"${args[@]}"} + + if [[ "${args[0]}" == "claude" ]]; then + _ensure_claude_bin + local perm_flag=--allow-dangerously-skip-permissions + [[ "$yolo" == "true" ]] && perm_flag=--dangerously-skip-permissions + set -- "${args[@]}"; shift # program args without the leading "claude" + _dexec claude "$perm_flag" "$@" + else + _dexec "${args[@]}" + fi } cmd_shell() { @@ -734,7 +754,8 @@ _check_for_updates CMD="${1:-claude}" [[ $# -gt 0 ]] && shift case "$CMD" in - claude) cmd_claude "$@" ;; + claude) cmd_run claude "$@" ;; + run) cmd_run "$@" ;; shell) cmd_shell "$@" ;; stop) cmd_stop "$@" ;; status) cmd_status ;; @@ -743,6 +764,6 @@ case "$CMD" in update) cmd_update ;; version|-v|--version) cmd_version ;; help|-h|--help) cmd_help ;; - -*) cmd_claude "$CMD" "$@" ;; + -*) cmd_run claude "$CMD" "$@" ;; *) _die "Unknown command: ${CMD}. Run 'aibox help' for usage." ;; esac From 9344dffdc2673dfdd5cfa797f890cd00a31830bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 03:05:22 +0000 Subject: [PATCH 11/26] Apply simplicity-audit findings: dedupe container plumbing, faster snapshot Four-angle review (reuse/simplification/efficiency/altitude) of the recent flag work. Extract shared helpers (_docker_run run-spec, _wait_running, _ensure_net_vol, _dev_url) so persistent and copy containers can't drift; snapshot now streams a tar as the container user instead of docker cp + full-tree chown (one traversal instead of two); proxy setup moved before the snapshot so its latency hides under the copy; cmd_run gets a standard -- sentinel so a literal --copy/--yolo can reach the target program. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- REVAMP.md | 7 ++-- bin/aibox | 111 ++++++++++++++++++++++++++++++------------------------ 2 files changed, 65 insertions(+), 53 deletions(-) diff --git a/REVAMP.md b/REVAMP.md index 5e81b2b..4154112 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -64,7 +64,7 @@ These were decided explicitly; the rewrite must not relitigate them. | D3 | Container lifecycle | Container **keeps running in the background** when the last session exits. `aibox stop` stops it explicitly. It is only ever *removed* when the image changes (and then only recreated, never left absent) | Instant re-attach; idle containers cost ~nothing; kills the data-loss footgun | | D4 | Instances | Exactly one persistent container per project directory; multiple terminals just `exec` into the same container. No named instances. `--copy` additionally spawns a disposable container per run (unique name, removed on exit) | Matches real usage; disposable runs must not fight over one name | | D5 | Session/home storage | One **global** named volume `aibox-home` mounted at `/home/aibox`, shared by all project containers | One login, one session history, one thing to back up; survives image changes by construction | -| D6 | Project mount | Bind-mount the project directory at **the same absolute path as on the host** (today's default-mode behavior, `bin/aibox:475`). `--copy` skips the bind mount and `docker cp`s a snapshot to the same path instead | Claude session keys are derived from cwd — keeping the path keeps every existing session valid with zero migration (copy mode included, since the path matches) | +| D6 | Project mount | Bind-mount the project directory at **the same absolute path as on the host** (today's default-mode behavior, `bin/aibox:475`). `--copy` skips the bind mount and streams a tar snapshot to the same path instead | Claude session keys are derived from cwd — keeping the path keeps every existing session valid with zero migration (copy mode included, since the path matches) | | D7 | Backup | Built-in `aibox backup` / `aibox restore` — clean and simple, must actually work | Replaces the external script | | D8 | Migration of old data | A **separate standalone script** (not part of the CLI) that merges both live `aibox-auth-*` volumes **and** old backup folders into the new `aibox-home` volume | One-time operation; keeps the CLI clean | | D9 | Dev-server access | Host-side reverse proxy with wildcard subdomains: `http://..aibox.localhost` → container port. Replaces port-forward sidecars and ad-hoc Cloudflare tunnels for local use | `*.localhost` (multi-level included) resolves to loopback natively in Chrome/Edge and Firefox 84+ with zero setup, no sudo, no dnsmasq; Safari only gained this on macOS 26 Tahoe (WebKit bug 160504). CLI tools using the system resolver (curl) don't resolve it — documented workaround, not solved. `/etc/hosts` can't do wildcards, so there is no hosts-file step | @@ -179,8 +179,9 @@ aibox claude/shell: - `--copy` runs claude in a disposable container instead: fresh container per run (`-copy-`, no restart policy, labeled `aibox.copy=1` with slug `-copy` for its own dev URLs), no bind - mount — the project is `docker cp`'d to the same absolute path and - chowned. It shares the home volume, so login and session history work, + mount — the project is streamed in as a tar snapshot to the same absolute + path, extracted as the container user (so ownership is right without a + full-tree chown). It shares the home volume, so login and session history work, and the container is removed on exit (EXIT trap; work is kept by committing/pushing from inside). - **Nothing happens on session exit.** No idle-detection, no auto-stop, no diff --git a/bin/aibox b/bin/aibox index b48efb0..edd3e86 100755 --- a/bin/aibox +++ b/bin/aibox @@ -20,6 +20,8 @@ # bind mount, the real directory can't be # touched, and the container is removed on exit # (commit & push from inside to keep work). +# A `--` ends aibox's own flag parsing, so a +# literal --copy/--yolo can reach the program. # aibox shell [cmd...] zsh in the container. One argument runs as a # shell string (pipes/globs work); several run # as a safely-quoted command. @@ -288,6 +290,9 @@ _proxy_suffix() { [[ -n "$p" && "$p" != "80" ]] && echo ":${p}" || true } +# The user-facing dev-server URL pattern for $CONTAINER. +_dev_url() { echo "http://.$(_pub_label).aibox.localhost$(_proxy_suffix)"; } + _start_proxy_on() { # $1 = host port; returns docker run's status docker run -d --name "$PROXY" --network "$NETWORK" --restart unless-stopped \ -p "127.0.0.1:${1}:80" \ @@ -340,9 +345,38 @@ _ensure_proxy() { # ── Container ──────────────────────────────────────────────────── CREATED=false # set by _ensure_container, read by _ensure_all's first-run banner -_ensure_container() { +_ensure_net_vol() { docker network create "$NETWORK" >/dev/null 2>&1 || true docker volume create "$VOLUME" >/dev/null 2>&1 || true +} + +# Shared run-spec for project containers. Per-mode flags (restart policy, +# bind mount, labels) come as args and land before "$IMAGE" — docker flag +# order before the image is irrelevant. +_docker_run() { + docker run -d --name "$CONTAINER" \ + --network "$NETWORK" \ + --init \ + --add-host host.docker.internal:host-gateway \ + -v "${VOLUME}:/home/aibox" \ + -w "$PROJECT_DIR" \ + -e CLAUDE_CONFIG_DIR=/home/aibox/.claude \ + -l "aibox.path=${PROJECT_DIR}" \ + "$@" "$IMAGE" +} + +# Poll until $CONTAINER runs (kicking a stopped one); die with $1 after 5s. +_wait_running() { + local i=0 + until [[ "$(docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null)" == "true" ]]; do + (( i++ >= 25 )) && _die "$1" + docker start "$CONTAINER" >/dev/null 2>&1 || true + sleep 0.2 + done +} + +_ensure_container() { + _ensure_net_vol # Serialize create/recreate across concurrent invocations (mkdir is # atomic): without this, two new-version invocations could both decide to @@ -380,20 +414,13 @@ _ensure_container() { if [[ "$state" == "absent" ]]; then _require_safe_dir local run_err="" - # 2>&1 >/dev/null captures stderr only (order matters) — shown by the - # retry loop below if the container never comes up. - run_err="$(docker run -d --name "$CONTAINER" \ - --network "$NETWORK" \ + # 2>&1 >/dev/null captures stderr only (order matters) — shown by + # _wait_running if the container never comes up. + run_err="$(_docker_run \ --restart unless-stopped \ - --init \ - --add-host host.docker.internal:host-gateway \ - -v "${VOLUME}:/home/aibox" \ -v "${PROJECT_DIR}:${PROJECT_DIR}" \ - -w "$PROJECT_DIR" \ - -e CLAUDE_CONFIG_DIR=/home/aibox/.claude \ -l "aibox.slug=${SLUG}" \ - -l "aibox.path=${PROJECT_DIR}" \ - "$IMAGE" 2>&1 >/dev/null)" || true # || true: parallel tab may have created it + 2>&1 >/dev/null)" || true # || true: parallel tab may have created it CREATED=true elif [[ "$state" != "running" ]]; then docker start "$CONTAINER" >/dev/null 2>&1 || true @@ -402,12 +429,7 @@ _ensure_container() { rmdir "$lock" 2>/dev/null || true trap - EXIT - local i=0 - until [[ "$(docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null)" == "true" ]]; do - (( i++ >= 25 )) && _die "Container failed to start.${run_err:+ ${run_err}}" # 5s - docker start "$CONTAINER" >/dev/null 2>&1 || true - sleep 0.2 - done + _wait_running "Container failed to start.${run_err:+ ${run_err}}" } _ensure_claude_bin() { @@ -436,7 +458,7 @@ _ensure_all() { _ensure_container _ensure_proxy # after _ensure_container: the Caddyfile must include the new container if [[ "$CREATED" == "true" ]]; then - _ok "Container ready. Dev servers: http://.$(_pub_label).aibox.localhost$(_proxy_suffix)" + _ok "Container ready. Dev servers: $(_dev_url)" fi } @@ -469,58 +491,47 @@ _dexec() { _cleanup_copy() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true # Drop the copy's proxy route; only touch a proxy that is already running. - { [[ "$(_state "$PROXY")" == "running" ]] && _ensure_proxy >/dev/null 2>&1; } || true + [[ "$(_state "$PROXY")" == "running" ]] && _ensure_proxy >/dev/null 2>&1 || true } _ensure_copy_all() { _ensure_docker _ensure_image _require_safe_dir - docker network create "$NETWORK" >/dev/null 2>&1 || true - docker volume create "$VOLUME" >/dev/null 2>&1 || true + _ensure_net_vol # Unique name per run: concurrent --copy sessions are separate sandboxes. # The trailing random segment doubles as the URL suffix if slugs collide # (see _proxy_pairs). No --restart policy: disposable by design. CONTAINER="${CONTAINER}-copy-$(printf '%04x%04x' "$RANDOM" "$RANDOM")" trap _cleanup_copy EXIT - docker run -d --name "$CONTAINER" \ - --network "$NETWORK" \ - --init \ - --add-host host.docker.internal:host-gateway \ - -v "${VOLUME}:/home/aibox" \ - -w "$PROJECT_DIR" \ - -e CLAUDE_CONFIG_DIR=/home/aibox/.claude \ - -l "aibox.slug=${SLUG}-copy" \ - -l "aibox.path=${PROJECT_DIR}" \ - -l "aibox.copy=1" \ - "$IMAGE" >/dev/null - local i=0 - until [[ "$(docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null)" == "true" ]]; do - (( i++ >= 25 )) && _die "Copy container failed to start." # 5s - sleep 0.2 - done + _docker_run -l "aibox.slug=${SLUG}-copy" -l "aibox.copy=1" >/dev/null + _wait_running "Copy container failed to start." + _ensure_proxy # independent of the snapshot — do it before the slow part _info "Snapshotting project into the container (the real ${PROJECT_DIR} is not mounted)..." - docker cp "${PROJECT_DIR}/." "${CONTAINER}:${PROJECT_DIR}" || _die "Project snapshot failed." - docker exec -u root "$CONTAINER" chown -R aibox:aibox "$PROJECT_DIR" - _ensure_proxy - _ok "Disposable copy ready — removed on exit; commit & push from inside to keep work. Dev servers: http://.$(_pub_label).aibox.localhost$(_proxy_suffix)" + # Stream the tree and extract as the container user, so files are born + # owned by aibox — no second full-tree chown pass. Only the workdir itself + # (auto-created root-owned by docker run -w) needs its owner fixed first. + # COPYFILE_DISABLE stops macOS tar from adding AppleDouble ._* entries. + docker exec -u root "$CONTAINER" chown aibox:aibox "$PROJECT_DIR" + COPYFILE_DISABLE=1 tar -cf - -C "$PROJECT_DIR" . \ + | docker exec -i -u aibox "$CONTAINER" tar -xpf - -C "$PROJECT_DIR" \ + || _die "Project snapshot failed." + _ok "Disposable copy ready — removed on exit; commit & push from inside to keep work. Dev servers: $(_dev_url)" } # ── Commands ───────────────────────────────────────────────────── # Run any program in the sandbox. `aibox` / `aibox claude` is just -# `aibox run claude` (see dispatch). aibox's own flags are consumed wherever -# they appear in the argv; everything else passes through verbatim: -# --copy disposable snapshot container instead of the persistent -# bind-mounted one (any program) -# --yolo claude only: start in bypass-permissions mode; without it, -# bypass is available in-session (claude's allow flag) but -# permission prompts default on +# `aibox run claude` (see dispatch). aibox's own flags (--copy, --yolo — +# semantics in the usage header) are consumed wherever they appear; the +# first `--` ends that, everything after it passes through verbatim. cmd_run() { - local copy=false yolo=false args=() a + local copy=false yolo=false passthru=false args=() a for a in "$@"; do + if [[ "$passthru" == "true" ]]; then args+=("$a"); continue; fi case "$a" in --copy) copy=true ;; --yolo) yolo=true ;; + --) passthru=true ;; *) args+=("$a") ;; esac done From de83f4711586016feb5006bf69ec97182a698726 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:18:20 +0000 Subject: [PATCH 12/26] Per-project isolation: sliced home volume with auto-migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aibox-home volume is now mounted in volume-subpath slices (Engine 26+, checked): shared slices carry the Claude login/settings and binary; each project gets a private home and its own transcripts. Other projects' data is simply not mounted — the only boundary that holds against full sudo. todos/file-history/shell-snapshots stay shared by design. Old flat volumes are detected by a layout marker and migrated in place on the next run: safety backup first (abort if it fails), only recognized files move, transcript dirs are matched to projects via the real paths in .claude.json, root dotfiles are copied into each existing project's home, and the migration is idempotent. Containers predating the layout are recreated via the existing image-change path (aibox.layout label); restore now removes containers instead of restarting them so restored data always pairs with fresh mounts. Riders: --hostname (readable remote-control session names), cleanupPeriodDays=3650 seeded into shared settings.json (transcripts no longer auto-delete after 30 idle days), and CLAUDE.md notes teaching any session to grep past sessions and revive one via --resume --remote-control. Migration body is testable outside docker (AIBOX_MIG_ROOT); covered by dummy-volume tests (shape, matching, orphans, idempotency, edge cases) plus stub-docker tests for the mount table and backup-before-migrate ordering. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- README.md | 4 +- REVAMP.md | 2 +- bin/aibox | 171 +++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 93d28b1..389e1bc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ aibox # 3. run (builds the image on first use) - **One container per project directory.** `aibox` in a project creates (or re-attaches to) that project's container. Open more terminal tabs and run `aibox` again — they attach to the same container. - **Your project is bind-mounted at its real path.** Changes sync both ways, paths inside the container match your Mac. -- **One shared home volume (`aibox-home`).** Claude login, every session, shell history, and the `claude` binary itself live in a Docker volume mounted at `/home/aibox` in every container. Log in once, resume any session from any project, forever. +- **One home volume, sliced per project.** Claude login, settings, and the `claude` binary live in shared slices of the `aibox-home` volume — log in once, forever. Everything else in a project's home (ssh keys, shell history, caches, and that project's sessions) is a private slice only its own containers mount, so one project's agent can't read another project's files or history. Volumes from older aibox versions migrate to this layout automatically on first run, with a safety backup taken first. - **Nothing is destroyed implicitly.** Exiting Claude leaves the container running in the background (idle containers cost ~nothing) — the next `aibox` attaches instantly. `aibox stop` stops it; a stopped container keeps everything, including packages you apt-installed. Containers are only recreated when the image changes, and the home volume survives even that. - **The container is the sandbox.** Full sudo inside. Permission prompts are on by default, but bypass mode is always available in-session (aibox passes claude's `--allow-dangerously-skip-permissions`); run `aibox --yolo` to start with all prompts skipped (`--dangerously-skip-permissions`). - **Disposable copies on demand.** `aibox --copy` runs Claude in a fresh container on a *snapshot* of the project instead — nothing is bind-mounted, so the agent physically can't touch your real files. Same login and session history (shared home volume), own dev URLs (`.-copy.aibox.localhost`). The container is removed when the session exits; keep work by committing and pushing from inside. Each `--copy` run is its own independent sandbox. Combines with `--yolo`, and works for any program via `aibox run --copy`. @@ -99,7 +99,7 @@ The image is `node:-bookworm` (Debian) plus a few basics (zsh, sudo, ri ## Prerequisites -Built for macOS; works on Linux too (any running Docker daemon). On macOS, Docker via [Colima](https://github.com/abiosoft/colima), [OrbStack](https://orbstack.dev), or [Docker Desktop](https://www.docker.com/products/docker-desktop/): +Built for macOS; works on Linux too. Needs Docker Engine 26+ (any 2024-or-later runtime; aibox checks and tells you if not). On macOS, Docker via [Colima](https://github.com/abiosoft/colima), [OrbStack](https://orbstack.dev), or [Docker Desktop](https://www.docker.com/products/docker-desktop/): ```bash brew install colima docker && colima start diff --git a/REVAMP.md b/REVAMP.md index 4154112..ef8e7a8 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -63,7 +63,7 @@ These were decided explicitly; the rewrite must not relitigate them. | D2 | Security posture | Permission prompts on by default with bypass selectable in-session (`--allow-dangerously-skip-permissions`); `aibox --yolo` starts in bypass (`--dangerously-skip-permissions`). No firewall, no restricted sudo | Isolation comes from the container boundary itself; the prompt default is the only mode switch | | D3 | Container lifecycle | Container **keeps running in the background** when the last session exits. `aibox stop` stops it explicitly. It is only ever *removed* when the image changes (and then only recreated, never left absent) | Instant re-attach; idle containers cost ~nothing; kills the data-loss footgun | | D4 | Instances | Exactly one persistent container per project directory; multiple terminals just `exec` into the same container. No named instances. `--copy` additionally spawns a disposable container per run (unique name, removed on exit) | Matches real usage; disposable runs must not fight over one name | -| D5 | Session/home storage | One **global** named volume `aibox-home` mounted at `/home/aibox`, shared by all project containers | One login, one session history, one thing to back up; survives image changes by construction | +| D5 | Session/home storage | One **global** named volume `aibox-home`, mounted in **slices** (`volume-subpath`, Engine 26+): shared slices for the Claude login/settings and binary, a private per-project slice for each project's home and transcripts. Old flat volumes migrate in place automatically (safety backup first; unrecognized files never moved) | One login and one backup tar stay; project-vs-project isolation is enforced at the mount level — the only level that means anything with full sudo inside | | D6 | Project mount | Bind-mount the project directory at **the same absolute path as on the host** (today's default-mode behavior, `bin/aibox:475`). `--copy` skips the bind mount and streams a tar snapshot to the same path instead | Claude session keys are derived from cwd — keeping the path keeps every existing session valid with zero migration (copy mode included, since the path matches) | | D7 | Backup | Built-in `aibox backup` / `aibox restore` — clean and simple, must actually work | Replaces the external script | | D8 | Migration of old data | A **separate standalone script** (not part of the CLI) that merges both live `aibox-auth-*` volumes **and** old backup folders into the new `aibox-home` volume | One-time operation; keeps the CLI clean | diff --git a/bin/aibox b/bin/aibox index edd3e86..cc2d4fe 100755 --- a/bin/aibox +++ b/bin/aibox @@ -54,9 +54,12 @@ # Dockerfile (survives image rebuilds; plain apt installs survive stop/start # but reset when the image changes). # -# All Claude state (sessions, login, the claude binary itself) lives in one -# shared Docker volume: aibox-home. Containers are disposable; the volume is -# the thing `aibox backup` protects. +# All Claude state lives in one Docker volume (aibox-home), sliced per +# project: login, settings, and the claude binary are shared; each project +# gets a private home (ssh keys, shell history, its own sessions) that other +# projects' containers never mount. Needs Docker Engine 26+. Volumes from +# older aibox versions are migrated automatically (safety backup first). +# Containers are disposable; the volume is what `aibox backup` protects. # # (This header is the output of `aibox help` — keep it accurate.) @@ -205,6 +208,27 @@ if [[ ! -e /home/aibox/.zshrc ]]; then printf 'autoload -Uz compinit && compinit\nPROMPT="%%F{cyan}%%1~%%f %%# "\n' > /home/aibox/.zshrc chown aibox:aibox /home/aibox/.zshrc fi +# Claude Code deletes transcripts idle >30 days by default; raise that once +# so session history is permanent. Never touches an existing value, skips +# an unparseable file. +node -e ' +const fs=require("fs"),p="/home/aibox/.claude/settings.json"; +let s={}; +if(fs.existsSync(p)){try{s=JSON.parse(fs.readFileSync(p,"utf8"))}catch(e){process.exit(0)}} +if(s.cleanupPeriodDays==null){s.cleanupPeriodDays=3650;fs.writeFileSync(p,JSON.stringify(s,null,2));} +' 2>/dev/null || true +chown aibox:aibox /home/aibox/.claude/settings.json 2>/dev/null || true +# Teach every session how to find and revive this project's past sessions. +CM=/home/aibox/.claude/CLAUDE.md +if ! grep -q 'aibox:sessions' "$CM" 2>/dev/null; then + cat >> "$CM" <<'NOTES' + + +- Past Claude sessions of this project are JSONL files under `~/.claude/projects/*/` — grep them to find one; the filename (minus .jsonl) is the session id. +- To make a past session controllable from claude.ai / the Claude phone app: `nohup script -qfc "claude --resume --remote-control" /dev/null >/dev/null 2>&1 &` — it appears in the claude.ai session list within seconds. +NOTES + chown aibox:aibox "$CM" 2>/dev/null || true +fi # First start with an empty home volume: install Claude Code into the volume # so the binary and its self-updates persist across container recreation. # (Anything baked into the image's home dir would be shadowed by the mount.) @@ -353,15 +377,28 @@ _ensure_net_vol() { # Shared run-spec for project containers. Per-mode flags (restart policy, # bind mount, labels) come as args and land before "$IMAGE" — docker flag # order before the image is irrelevant. +# +# The home volume is mounted in slices (volume-subpath, Engine 26+): a +# private per-project home, with the shared Claude login/settings, binary, +# and this project's own transcripts mounted over it at their normal paths. +# Other projects' homes and transcripts are simply not mounted — that is the +# isolation boundary (in-volume permissions are meaningless with full sudo). +# todos/file-history/shell-snapshots stay inside the shared .claude mount. _docker_run() { docker run -d --name "$CONTAINER" \ --network "$NETWORK" \ + --hostname "$SLUG" \ --init \ --add-host host.docker.internal:host-gateway \ - -v "${VOLUME}:/home/aibox" \ + --mount "type=volume,src=${VOLUME},dst=/home/aibox,volume-subpath=projects/${HASH6}/home" \ + --mount "type=volume,src=${VOLUME},dst=/home/aibox/.claude,volume-subpath=shared/claude-cfg" \ + --mount "type=volume,src=${VOLUME},dst=/home/aibox/.claude/projects,volume-subpath=projects/${HASH6}/claude-projects" \ + --mount "type=volume,src=${VOLUME},dst=/home/aibox/.local/bin,volume-subpath=shared/local-bin" \ + --mount "type=volume,src=${VOLUME},dst=/home/aibox/.local/share/claude,volume-subpath=shared/claude-app" \ -w "$PROJECT_DIR" \ -e CLAUDE_CONFIG_DIR=/home/aibox/.claude \ -l "aibox.path=${PROJECT_DIR}" \ + -l "aibox.layout=2" \ "$@" "$IMAGE" } @@ -375,6 +412,99 @@ _wait_running() { done } +# ── Volume layout (v2: per-project isolation) ──────────────────── +# aibox-home layout: shared/{claude-cfg,local-bin,claude-app} for the Claude +# login/settings and binary, projects//{home,claude-projects} per +# project. Marker file .aibox-layout at the volume root says the shape is +# current; a volume with old flat data and no marker is migrated in place +# (safety backup first). Unrecognized files are never moved or deleted. + +# The migration body, shared verbatim between the docker run below and the +# test harness (which runs it against a plain directory via AIBOX_MIG_ROOT). +# POSIX sh — it runs under busybox in the helper image. $1 = hash6 of the +# project being started, so its slice exists even if it's new. +# Everything is driven by the absolute-path keys in .claude.json: the +# transcript dir names are lossy encodings (all punctuation → "-"), so the +# real paths are the only way to compute each project's hash. +_MIGRATE_SH=' +set -e +cd "${AIBOX_MIG_ROOT:-/v}" +[ -f .aibox-layout ] && exit 0 +mkdir -p shared/claude-cfg projects +if [ -d .claude ]; then + for f in .claude/* .claude/.[!.]*; do + [ -e "$f" ] || continue + case "$(basename "$f")" in projects) continue ;; esac + mv "$f" shared/claude-cfg/ + done +fi +if [ -d .local/bin ] && [ ! -e shared/local-bin ]; then mv .local/bin shared/local-bin; fi +if [ -d .local/share/claude ] && [ ! -e shared/claude-app ]; then mv .local/share/claude shared/claude-app; fi +mkdir -p shared/local-bin shared/claude-app +CJ=shared/claude-cfg/.claude.json +if [ -f "$CJ" ]; then + grep -o "\"/[^\"]*\"" "$CJ" | tr -d "\"" | sort -u | while read -r p; do + enc="$(printf %s "$p" | sed "s/[^A-Za-z0-9]/-/g")" + [ -d ".claude/projects/$enc" ] || continue + h="$(printf %s "$p" | sha256sum | cut -c1-6)" + mkdir -p "projects/$h/claude-projects" "projects/$h/home" + [ -e "projects/$h/claude-projects/$enc" ] || mv ".claude/projects/$enc" "projects/$h/claude-projects/$enc" + for d in .ssh .config .gitconfig .zshrc .zsh_history .zshenv .zprofile .netrc; do + if [ -e "$d" ] && [ ! -e "projects/$h/home/$d" ]; then cp -a "$d" "projects/$h/home/$d"; fi + done + done +fi +mkdir -p "projects/$1/home" "projects/$1/claude-projects" +chown -R 1000:1000 shared projects 2>/dev/null || true +echo 2 > .aibox-layout +' + +_migrate_layout() { + local running + running="$(docker ps -q --filter label=aibox.slug | head -1)" + [[ -n "$running" ]] && _die "The home volume needs a one-time migration to the per-project layout, but aibox sessions are running. Exit them, run: aibox stop --all, then retry." + # Serialize concurrent invocations (same mkdir-lock pattern as containers). + local lock="${CONFIG_DIR}/.lock-migrate" waited=0 + until mkdir "$lock" 2>/dev/null; do + (( waited++ >= 300 )) && { rmdir "$lock" 2>/dev/null || true; waited=0; } + sleep 0.1 + done + _info "Migrating ${VOLUME} to the per-project layout (one-time; old files stay in the volume untouched)..." + local safety + safety="$(_do_backup "$BACKUP_DIR" "aibox-home-pre-migrate")" \ + || { rmdir "$lock" 2>/dev/null || true; _die "Safety backup failed — migration aborted, the volume is untouched."; } + _ok "Safety backup: ${safety}" + if ! printf '%s' "$_MIGRATE_SH" | docker run -i --rm -v "${VOLUME}:/v" "$HELPER_IMAGE" sh -s "$HASH6"; then + rmdir "$lock" 2>/dev/null || true + _die "Migration failed — restore the safety backup above with: aibox restore " + fi + rmdir "$lock" 2>/dev/null || true + _ok "Migrated. Each project now has a private home; login, settings, and the claude binary stay shared." +} + +# Detect the volume's shape, create/migrate as needed, and make sure this +# project's slices exist. Called right before every container create. +_ensure_layout() { + local ver maj + ver="$(docker version --format '{{.Server.Version}}' 2>/dev/null)" + maj="${ver%%.*}" + if [[ "$maj" =~ ^[0-9]+$ ]] && (( maj < 26 )); then + _die "aibox needs Docker Engine 26+ (volume subpath mounts); found ${ver}. Update your runtime (brew upgrade colima / update OrbStack or Docker Desktop)." + fi + local state + state="$(docker run --rm -v "${VOLUME}:/v" "$HELPER_IMAGE" sh -c ' + if [ -f /v/.aibox-layout ]; then echo current + elif [ -e /v/.claude ] || [ -e /v/.local ]; then echo legacy + else + mkdir -p /v/shared/claude-cfg /v/shared/local-bin /v/shared/claude-app /v/projects + echo 2 > /v/.aibox-layout + echo current + fi' 2>/dev/null | tail -1)" + [[ "$state" == "legacy" ]] && _migrate_layout + docker run --rm -v "${VOLUME}:/v" "$HELPER_IMAGE" sh -c \ + "mkdir -p /v/projects/${HASH6}/home /v/projects/${HASH6}/claude-projects && chown -R 1000:1000 /v/projects/${HASH6}" >/dev/null 2>&1 || true +} + _ensure_container() { _ensure_net_vol @@ -389,23 +519,24 @@ _ensure_container() { done trap 'rmdir "$lock" 2>/dev/null || true' EXIT - local state cur_img - read -r state cur_img < <(docker inspect "$CONTAINER" \ - --format '{{.State.Status}} {{.Config.Image}}' 2>/dev/null) || true + local state cur_img cur_layout + read -r state cur_img cur_layout < <(docker inspect "$CONTAINER" \ + --format '{{.State.Status}} {{.Config.Image}} {{index .Config.Labels "aibox.layout"}}' 2>/dev/null) || true state="${state:-absent}" - # Recreate only when the image changed (new aibox version or node_version). + # Recreate when the image changed (new aibox version or node_version) or + # the container predates the current volume layout (its mounts are stale). # The home volume and project bind survive by construction. - if [[ "$state" != "absent" && -n "${cur_img:-}" && "$cur_img" != "$IMAGE" ]]; then + if [[ "$state" != "absent" && -n "${cur_img:-}" && ( "$cur_img" != "$IMAGE" || "${cur_layout:-}" != "2" ) ]]; then local active=0 if [[ "$state" == "running" ]]; then active="$(docker top "$CONTAINER" -o pid,args 2>/dev/null | tail -n +2 | grep -vc 'sleep infinity' || true)" active="${active//[^0-9]/}" # arithmetic-safe fi if [[ "${active:-0}" -gt 0 ]]; then - _warn "Image updated (${cur_img} → ${IMAGE}) but ${active} process(es) still running in the old container — keeping it for now. To update: exit sessions / stop dev servers, then run: aibox stop && aibox" + _warn "Container needs recreating (image or layout updated) but ${active} process(es) still running in the old one — keeping it for now. To update: exit sessions / stop dev servers, then run: aibox stop && aibox" else - _info "Image changed (${cur_img} → ${IMAGE}). Recreating container — sessions/login/project files persist; apt-installed packages reset (use ~/.aibox/Dockerfile.extra to keep them)." + _info "Recreating container (image or layout updated) — sessions/login/project files persist; apt-installed packages reset (use ~/.aibox/Dockerfile.extra to keep them)." docker rm -f "$CONTAINER" >/dev/null 2>&1 || true state=absent fi @@ -413,6 +544,7 @@ _ensure_container() { if [[ "$state" == "absent" ]]; then _require_safe_dir + _ensure_layout local run_err="" # 2>&1 >/dev/null captures stderr only (order matters) — shown by # _wait_running if the container never comes up. @@ -499,6 +631,7 @@ _ensure_copy_all() { _ensure_image _require_safe_dir _ensure_net_vol + _ensure_layout # Unique name per run: concurrent --copy sessions are separate sandboxes. # The trailing random segment doubles as the URL suffix if slugs collide # (see _proxy_pairs). No --restart policy: disposable by design. @@ -676,8 +809,9 @@ cmd_restore() { echo "This replaces the contents of ${VOLUME} (sessions, login, claude binary)" echo "with: ${file}" - echo "A safety backup is taken first; running aibox containers are stopped" - echo "and restarted around the restore." + echo "A safety backup is taken first. aibox containers are removed so the" + echo "next run recreates them against the restored data (project files are" + echo "untouched; apt-installed packages reset)." _confirm "Restore?" || { echo "Aborted — nothing was changed."; return 0; } local running @@ -701,9 +835,14 @@ cmd_restore() { || _die "Restore failed mid-way. Recover with: aibox restore " _ok "Restored ${VOLUME} from $(basename "$file")." - if [[ -n "$running" ]]; then - echo "$running" | xargs docker start >/dev/null - _ok "Restarted: $(echo "$running" | tr '\n' ' ')" + # Containers must be recreated, not restarted: their volume-subpath mounts + # may not match the restored data (a pre-migration backup has none of the + # slices; the next aibox run re-runs layout detection/migration). + local stale + stale="$(docker ps -aq --filter label=aibox.slug)" + if [[ -n "$stale" ]]; then + echo "$stale" | xargs docker rm -f >/dev/null 2>&1 || true + _ok "Removed old containers — the next aibox run in each project recreates them." fi } From c6c7369062067fdd2e958be2b95a1ab8beac29b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:23:07 +0000 Subject: [PATCH 13/26] Add aibox serve: detached remote-control server + sessions web UI aibox serve keeps 'claude remote-control' running in the project container under a small supervisor (pty via script(1), restart loop, gives up after 3 quick exits with a pointer to the log) so phone/claude.ai sessions survive closed terminals. Bare restarts within Anthropic's ~4h window re-serve previous sessions automatically. Extra args pass through (--spawn, --capacity, ...); aibox serve stop kills the supervisor, server, UI, and revived sessions. Alongside it, a single-file Node sessions UI (written into the container, port 45789, reachable at http://45789..aibox.localhost): lists this project's transcripts with title/age/message count, Resume spawns a detached 'claude --resume --remote-control' (strict id validation, argv-only spawn), live sessions link to https://claude.ai/code/. Tested: UI served real transcript fixtures (titles from string and array content, sorting, live detection), resume spawn argv verified via stubbed script(1), hostile ids all 400 with zero spawns; stub-docker covers the supervisor/UI start calls, URL surfacing, stop path, and the full command regression matrix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- README.md | 9 +++ REVAMP.md | 1 + bin/aibox | 227 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 235 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 389e1bc..9e5cdd6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,14 @@ Claude starts `vite` on 5173 in project `myapp` → open `http://5173.myapp.aibo Works out of the box in Chrome, Edge, and Firefox (`*.localhost` resolves to loopback natively). Safari needs macOS 26+. CLI tools like `curl` need `--resolve` (the system resolver doesn't do `*.localhost`). +## Phone & browser sessions + +```bash +aibox serve +``` + +keeps a [Claude Code Remote Control](https://code.claude.com/docs/en/remote-control) server running inside the project's container — detached, so closing the terminal changes nothing — and sessions are driven from claude.ai/code or the Claude phone app. It's outbound-only HTTPS: no ports, nothing exposed. Alongside it, a small sessions UI at `http://45789..aibox.localhost` lists the project's past sessions; clicking **Resume** brings one back as a live remote session (it appears in the app's session list within seconds, and any Claude session can do the same trick on request — the shared CLAUDE.md explains it to them). `aibox serve stop` ends everything. On a remote Linux box, reach the UI with `ssh -L 8080:127.0.0.1:80 host` and open the same URL with `:8080`; the phone side needs no tunnel at all. + ## Backup & restore Everything worth keeping is in one volume, so backup is one file: @@ -77,6 +85,7 @@ Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are |---------|-------------| | `aibox` / `aibox claude [args]` | Shorthand for `aibox run claude`. `--yolo` skips all permission prompts; `--copy` uses a disposable snapshot container (no bind mount, removed on exit); other args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | | `aibox run [--copy] [args]` | Run any program in the sandbox (e.g. `aibox run codex`). `--copy` works the same as above; the program's own flags pass through | +| `aibox serve [args]` | Keep a `claude remote-control` server + sessions UI running in the container — drive sessions from your phone/claude.ai, revive past sessions with a click. `aibox serve stop` ends it | | `aibox shell [cmd]` | zsh in the container, or run a one-off command | | `aibox stop [--all]` | Stop this project's container (`--all`: everything incl. proxy). Loses nothing | | `aibox status` | Containers, dev URLs, home volume size | diff --git a/REVAMP.md b/REVAMP.md index ef8e7a8..ab3c89f 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -77,6 +77,7 @@ These were decided explicitly; the rewrite must not relitigate them. aibox # same as `aibox claude` aibox claude [args...] # shorthand for `aibox run claude`; --yolo skips all prompts, --copy uses a disposable snapshot container, other args pass through (--resume, -c, etc.) aibox run [--copy] [args...] # ensure image/container/proxy, then run any program inside (e.g. aibox run codex) +aibox serve [args...] # detached claude remote-control server + sessions UI (port 45789) in the container; serve stop ends it aibox shell [cmd...] # zsh in the container, or run a one-off command aibox stop [--all] # stop this project's container (--all: every aibox container + proxy). Never deletes anything aibox status # all aibox containers: project, state, uptime, image; proxy URLs; volume size diff --git a/bin/aibox b/bin/aibox index cc2d4fe..3baf58f 100755 --- a/bin/aibox +++ b/bin/aibox @@ -22,6 +22,15 @@ # (commit & push from inside to keep work). # A `--` ends aibox's own flag parsing, so a # literal --copy/--yolo can reach the program. +# aibox serve [args...] Keep a claude remote-control server running in +# the container — it survives closing the +# terminal, and its sessions are driven from +# claude.ai or the Claude phone app. Also serves +# a sessions UI on port 45789 (dev-proxy URL) to +# browse this project's past sessions and +# revive one with a click. Extra args pass to +# `claude remote-control` (--spawn, --capacity, +# ...). `aibox serve stop` ends it. # aibox shell [cmd...] zsh in the container. One argument runs as a # shell string (pipes/globs work); several run # as a safely-quoted command. @@ -314,8 +323,9 @@ _proxy_suffix() { [[ -n "$p" && "$p" != "80" ]] && echo ":${p}" || true } -# The user-facing dev-server URL pattern for $CONTAINER. -_dev_url() { echo "http://.$(_pub_label).aibox.localhost$(_proxy_suffix)"; } +# The user-facing dev-server URL pattern for $CONTAINER ($1: concrete port, +# default the "" placeholder). +_dev_url() { echo "http://${1:-}.$(_pub_label).aibox.localhost$(_proxy_suffix)"; } _start_proxy_on() { # $1 = host port; returns docker run's status docker run -d --name "$PROXY" --network "$NETWORK" --restart unless-stopped \ @@ -701,6 +711,214 @@ cmd_shell() { fi } +# ── Serve: phone/browser sessions (claude remote-control) ──────── +# Keeps a claude remote-control server running detached in the project +# container — outbound HTTPS only, so no ports or proxy work — plus a small +# sessions UI on port 45789 (reachable through the dev proxy). Both survive +# closed terminals; sessions are driven from claude.ai / the Claude app. +# The supervisor restarts the server if it exits (it gives up after ~10min +# of network refusals) and stops after 3 quick failures in a row, which +# means "not logged in / not eligible" — the log says so. +cmd_serve() { + if [[ "${1:-}" == "stop" ]]; then + _ensure_docker + if [[ "$(_state "$CONTAINER")" == "running" ]]; then + # Supervisor first (or it would just restart what we kill next). + docker exec -u aibox "$CONTAINER" sh -c \ + 'pkill -f aibox-serve-rc; pkill -f "claude remote-control"; pkill -f "aibox-serve-ui"; pkill -f -- "--resume.*--remote-control"; true' >/dev/null 2>&1 || true + _ok "Stopped the remote-control server, sessions UI, and revived sessions for ${SLUG}." + else + _info "Not running: ${SLUG}" + fi + return 0 + fi + + _ensure_all + _ensure_claude_bin + + if docker exec -u aibox "$CONTAINER" pgrep -f aibox-serve-rc >/dev/null 2>&1; then + _info "Serve already running for ${SLUG}." + else + # Extra args pass through to `claude remote-control` verbatim. + local rc_cmd="claude remote-control --remote-control-session-name-prefix aibox-${SLUG}" + [[ $# -gt 0 ]] && rc_cmd+="$(printf ' %q' "$@")" + # script(1) gives claude the pty it expects; the env var dodges quoting. + docker exec -d -u aibox -w "$PROJECT_DIR" -e "AIBOX_RC_CMD=${rc_cmd}" "$CONTAINER" bash -c ' + # aibox-serve-rc + mkdir -p "$HOME/.aibox-serve" + exec >>"$HOME/.aibox-serve/rc.log" 2>&1 + fails=0 + while :; do + start=$(date +%s) + script -qfc "$AIBOX_RC_CMD" /dev/null + (( $(date +%s) - start < 60 )) && fails=$((fails+1)) || fails=0 + if (( fails >= 3 )); then + echo "aibox-serve: giving up after 3 quick exits — likely not logged in or Remote Control unavailable. Fix, then rerun: aibox serve" + exit 1 + fi + sleep 10 + done' + fi + + # Sessions UI (idempotent: rewrite the file, start only if not running). + docker exec -i -u aibox "$CONTAINER" sh -c 'mkdir -p "$HOME/.aibox-serve" && cat > "$HOME/.aibox-serve/ui.js"' <<'UIJS' +// aibox sessions UI — generated by aibox (rewritten on every `aibox serve`). +// Lists this project's transcripts; a click revives one as a remote session. +const http = require("http"), fs = require("fs"), path = require("path"), cp = require("child_process"); +const PORT = 45789; +const HOME = process.env.HOME || "/home/aibox"; +const DIR = path.join(HOME, ".claude", "projects", process.cwd().replace(/[^A-Za-z0-9]/g, "-")); +const PROJECT = path.basename(process.cwd()); + +function firstUserText(file) { + try { + const fd = fs.openSync(file, "r"), buf = Buffer.alloc(262144); + const n = fs.readSync(fd, buf, 0, buf.length, 0); + fs.closeSync(fd); + for (const line of buf.slice(0, n).toString("utf8").split("\n")) { + if (!line.trim()) continue; + let j; try { j = JSON.parse(line); } catch (e) { continue; } + if (j.type === "user" && j.message) { + const c = j.message.content; + let t = typeof c === "string" ? c : Array.isArray(c) ? c.map(x => x.text || "").join(" ") : ""; + t = t.replace(/\s+/g, " ").trim(); + if (t) return t.slice(0, 90); + } + } + } catch (e) {} + return "Untitled session"; +} +function messageCount(file) { + try { return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).length; } catch (e) { return 0; } +} +function liveIds(cb) { + cp.exec("pgrep -fa -- --remote-control || true", (e, out) => { + const ids = new Set(); + String(out || "").split("\n").forEach(l => { + const m = l.match(/--resume[= ]([0-9a-f-]{36})/); + if (m) ids.add(m[1]); + }); + cb(ids); + }); +} +function sessions(cb) { + let files = []; + try { files = fs.readdirSync(DIR).filter(f => f.endsWith(".jsonl")); } catch (e) {} + liveIds(live => { + cb(files.map(f => { + const p = path.join(DIR, f), st = fs.statSync(p), id = f.slice(0, -6); + return { id, mtime: st.mtimeMs, title: firstUserText(p), messages: messageCount(p), live: live.has(id) }; + }).sort((a, b) => b.mtime - a.mtime)); + }); +} +const PAGE = ` + +${PROJECT} — aibox + +
+

aibox

${PROJECT}

+

Loading…

+
Loading…
+

Resuming brings a session back within a few seconds — it also appears in the Claude app on your phone.

+
`; +http.createServer((req, res) => { + const u = new URL(req.url, "http://x"); + if (req.method === "POST" && u.pathname === "/api/resume") { + const id = String(u.searchParams.get("id") || ""); + if (!/^[0-9a-f-]{36}$/.test(id)) { res.writeHead(400); return res.end("bad id"); } + cp.spawn("script", ["-qfc", "claude --resume " + id + " --remote-control", "/dev/null"], + { detached: true, stdio: "ignore", cwd: process.cwd() }).unref(); + res.writeHead(200, { "content-type": "application/json" }); + return res.end("{\"ok\":true}"); + } + if (u.pathname === "/api/sessions") { + return sessions(list => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(list)); + }); + } + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(PAGE); +}).listen(PORT, "0.0.0.0"); +UIJS + if ! docker exec -u aibox "$CONTAINER" pgrep -f aibox-serve-ui >/dev/null 2>&1; then + docker exec -d -u aibox -w "$PROJECT_DIR" "$CONTAINER" bash -c ' + # aibox-serve-ui + exec node "$HOME/.aibox-serve/ui.js" >>"$HOME/.aibox-serve/ui.log" 2>&1' + fi + + _ok "Sessions UI: $(_dev_url 45789)" + local url="" i=0 + while (( i++ < 30 )); do + url="$(docker exec -u aibox "$CONTAINER" sh -c 'grep -oE "https://claude\.ai/[a-zA-Z0-9/_.-]+" "$HOME/.aibox-serve/rc.log" 2>/dev/null | tail -1')" || true + [[ -n "$url" ]] && break + sleep 1 + done + if [[ -n "$url" ]]; then + _ok "Remote session: ${url} (also in the Claude app's session list)" + else + _warn "No session URL yet — check the Claude app in a minute, or: aibox shell 'tail ~/.aibox-serve/rc.log'" + fi + _info "Runs until 'aibox serve stop' — closing this terminal is fine." +} + cmd_stop() { _ensure_docker if [[ "${1:-}" == "--all" ]]; then @@ -757,6 +975,10 @@ cmd_status() { else echo "Proxy: stopped (starts on next aibox run)" fi + if [[ "$(_state "$CONTAINER")" == "running" ]] \ + && docker exec -u aibox "$CONTAINER" pgrep -f aibox-serve-rc >/dev/null 2>&1; then + echo "Serve (${SLUG}): running — sessions UI $(_dev_url 45789)" + fi if docker volume inspect "$VOLUME" >/dev/null 2>&1; then local size size="$(docker run --rm -v "${VOLUME}:/v:ro" "$HELPER_IMAGE" du -sh /v 2>/dev/null | cut -f1 || echo '?')" @@ -906,6 +1128,7 @@ CMD="${1:-claude}" case "$CMD" in claude) cmd_run claude "$@" ;; run) cmd_run "$@" ;; + serve) cmd_serve "$@" ;; shell) cmd_shell "$@" ;; stop) cmd_stop "$@" ;; status) cmd_status ;; From 8b576e606f85e34f20458db72bb53cf39e09daa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:31:55 +0000 Subject: [PATCH 14/26] Status: live memory and disk per container, Docker disk totals aibox status now shows a MEM column (one docker stats sample across all running aibox containers), a DISK column (rw-layer size from docker ps -s), and a daemon-wide 'Docker disk' summary line (images/containers/volumes with reclaimable amounts). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- README.md | 2 +- bin/aibox | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9e5cdd6..1bf1672 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are | `aibox serve [args]` | Keep a `claude remote-control` server + sessions UI running in the container — drive sessions from your phone/claude.ai, revive past sessions with a click. `aibox serve stop` ends it | | `aibox shell [cmd]` | zsh in the container, or run a one-off command | | `aibox stop [--all]` | Stop this project's container (`--all`: everything incl. proxy). Loses nothing | -| `aibox status` | Containers, dev URLs, home volume size | +| `aibox status` | Containers with live memory + disk use, dev URLs, Docker disk totals, home volume size | | `aibox backup [dir]` | Snapshot the home volume to a tar.gz | | `aibox restore ` | Restore a backup (safety-backup of current state first) | | `aibox update` | Update the CLI; image rebuilds automatically on next run | diff --git a/bin/aibox b/bin/aibox index 3baf58f..a61a51b 100755 --- a/bin/aibox +++ b/bin/aibox @@ -37,7 +37,9 @@ # aibox stop [--all] Stop this project's container # (--all: every aibox container + proxy). # Never deletes anything; next run re-attaches. -# aibox status Containers, dev-server URLs, home volume size +# aibox status Containers with live memory + disk use, +# dev-server URLs, Docker disk totals, home +# volume size # aibox backup [dir] Snapshot the aibox-home volume to a tar.gz # (default dir: ~/aibox-backups) # aibox restore Restore a backup into aibox-home @@ -953,20 +955,37 @@ cmd_status() { if [[ -z "$rows" ]]; then echo "No aibox containers. Run aibox in a project directory to start one." else + # One stats sample for all running containers (docker stats needs ~2s) + # and rw-layer sizes for all; joined per row below by container name. + local running_names stats sizes + running_names="$(docker ps --filter label=aibox.slug --format '{{.Names}}' | tr '\n' ' ')" + stats="" + if [[ -n "${running_names// /}" ]]; then + # shellcheck disable=SC2086 + stats="$(docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' $running_names 2>/dev/null || true)" + fi + sizes="$(docker ps -as --filter label=aibox.slug --format '{{.Names}} {{.Size}}' 2>/dev/null || true)" local suffix key name suffix="$(_proxy_suffix)" - printf "%-18s %-22s %-30s %s\n" "PROJECT" "STATUS" "DEV URL" "PATH" + printf "%-18s %-20s %-10s %-10s %-30s %s\n" "PROJECT" "STATUS" "MEM" "DISK" "DEV URL" "PATH" while IFS=$'\t' read -r cslug cstatus cimage cpath cname; do - local url="-" + local url="-" mem disk while read -r key name; do [[ "$name" == "$cname" ]] && url=".${key}.aibox.localhost${suffix}" done < <(_proxy_pairs) + mem="$(awk -v n="$cname" '$1==n{print $2}' <<< "$stats")" + disk="$(awk -v n="$cname" '$1==n{print $2}' <<< "$sizes")" # De-jargon docker's status ("Up 2 hours", "Exited (143) 3 days ago") cstatus="$(echo "$cstatus" | sed 's/^Up /running /; s/^Exited ([0-9]*) /stopped /; s/^Created$/created/')" - printf "%-18s %-22s %-30s %s\n" "$cslug" "$cstatus" "$url" "$cpath" + printf "%-18s %-20s %-10s %-10s %-30s %s\n" "$cslug" "$cstatus" "${mem:--}" "${disk:--}" "$url" "$cpath" [[ "$cimage" != "$IMAGE" ]] && _info " ${cslug}: image ${cimage} (current: ${IMAGE} — recreated on next run)" done <<< "$rows" fi + # Daemon-wide disk picture (images/containers/volumes + reclaimable). + local df + df="$(docker system df --format '{{.Type}} {{.Size}} ({{.Reclaimable}} reclaimable)' 2>/dev/null \ + | sed 's/^Local Volumes/Volumes/; s/^Build Cache/Build-cache/' | tr '\n' '\t')" || true + [[ -n "$df" ]] && { echo ""; echo "Docker disk: $(echo "$df" | sed 's/\t$//; s/\t/ · /g')"; } echo "" local pstate pstate="$(_state "$PROXY")" From ffc6cd13078174912f0dce6ff0b627eb7033f1f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:39:05 +0000 Subject: [PATCH 15/26] Unify serve: every session is one process, UI creates and stops them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server mode is gone. aibox serve now just runs the sessions UI; every live session — brand new or revived — is one detached 'claude [--session-id |--resume ] --remote-control' process. New session spawns with an explicit --session-id so live detection and the claude.ai URL work uniformly; live rows gain a Stop action; sessions with no transcript yet still get a row. serve stop kills the UI and every session. The supervisor, flap guard, rc.log tailing, and passthrough args are deleted; the 4-hour resume-window concern disappears with them — recovery is always Resume-from-transcript. CLAUDE.md notes teach the --session-id variant so any live chat can start a fresh session on request. Tested: /api/new spawn argv and returned id, /api/stop by id, hostile ids 400 with zero spawns, page plumbing, stub-docker serve start/stop, arg rejection, 9/9 command regression. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- README.md | 4 +- REVAMP.md | 2 +- bin/aibox | 126 +++++++++++++++++++++++++++--------------------------- 3 files changed, 67 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 1bf1672..27de039 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Works out of the box in Chrome, Edge, and Firefox (`*.localhost` resolves to loo aibox serve ``` -keeps a [Claude Code Remote Control](https://code.claude.com/docs/en/remote-control) server running inside the project's container — detached, so closing the terminal changes nothing — and sessions are driven from claude.ai/code or the Claude phone app. It's outbound-only HTTPS: no ports, nothing exposed. Alongside it, a small sessions UI at `http://45789..aibox.localhost` lists the project's past sessions; clicking **Resume** brings one back as a live remote session (it appears in the app's session list within seconds, and any Claude session can do the same trick on request — the shared CLAUDE.md explains it to them). `aibox serve stop` ends everything. On a remote Linux box, reach the UI with `ssh -L 8080:127.0.0.1:80 host` and open the same URL with `:8080`; the phone side needs no tunnel at all. +runs a small sessions UI at `http://45789..aibox.localhost`. **New session** starts a fresh session you drive from claude.ai/code or the Claude phone app ([Remote Control](https://code.claude.com/docs/en/remote-control)); **Resume** brings any past session back the same way; live sessions show as such and can be stopped. Every session is one detached claude process inside the project's container — closing your terminal changes nothing, and registration is outbound-only HTTPS (no ports, nothing exposed). Any Claude session can pull the same tricks on request — the shared CLAUDE.md teaches it the commands, so you can also say "start me a new session" from your phone in any live chat. `aibox serve stop` ends the UI and every live session. On a remote Linux box, reach the UI with `ssh -L 8080:127.0.0.1:80 host` and open the same URL with `:8080`; the phone side needs no tunnel at all. ## Backup & restore @@ -85,7 +85,7 @@ Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are |---------|-------------| | `aibox` / `aibox claude [args]` | Shorthand for `aibox run claude`. `--yolo` skips all permission prompts; `--copy` uses a disposable snapshot container (no bind mount, removed on exit); other args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | | `aibox run [--copy] [args]` | Run any program in the sandbox (e.g. `aibox run codex`). `--copy` works the same as above; the program's own flags pass through | -| `aibox serve [args]` | Keep a `claude remote-control` server + sessions UI running in the container — drive sessions from your phone/claude.ai, revive past sessions with a click. `aibox serve stop` ends it | +| `aibox serve` | Sessions UI in the container: start new phone/claude.ai-drivable sessions, resume past ones, stop live ones. `aibox serve stop` ends the UI and every live session | | `aibox shell [cmd]` | zsh in the container, or run a one-off command | | `aibox stop [--all]` | Stop this project's container (`--all`: everything incl. proxy). Loses nothing | | `aibox status` | Containers with live memory + disk use, dev URLs, Docker disk totals, home volume size | diff --git a/REVAMP.md b/REVAMP.md index ab3c89f..6f74089 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -77,7 +77,7 @@ These were decided explicitly; the rewrite must not relitigate them. aibox # same as `aibox claude` aibox claude [args...] # shorthand for `aibox run claude`; --yolo skips all prompts, --copy uses a disposable snapshot container, other args pass through (--resume, -c, etc.) aibox run [--copy] [args...] # ensure image/container/proxy, then run any program inside (e.g. aibox run codex) -aibox serve [args...] # detached claude remote-control server + sessions UI (port 45789) in the container; serve stop ends it +aibox serve # sessions UI (port 45789) in the container: new/resume/stop phone-drivable sessions, each a detached claude --remote-control process; serve stop ends it all aibox shell [cmd...] # zsh in the container, or run a one-off command aibox stop [--all] # stop this project's container (--all: every aibox container + proxy). Never deletes anything aibox status # all aibox containers: project, state, uptime, image; proxy URLs; volume size diff --git a/bin/aibox b/bin/aibox index a61a51b..86bbc24 100755 --- a/bin/aibox +++ b/bin/aibox @@ -22,15 +22,14 @@ # (commit & push from inside to keep work). # A `--` ends aibox's own flag parsing, so a # literal --copy/--yolo can reach the program. -# aibox serve [args...] Keep a claude remote-control server running in -# the container — it survives closing the -# terminal, and its sessions are driven from -# claude.ai or the Claude phone app. Also serves -# a sessions UI on port 45789 (dev-proxy URL) to -# browse this project's past sessions and -# revive one with a click. Extra args pass to -# `claude remote-control` (--spawn, --capacity, -# ...). `aibox serve stop` ends it. +# aibox serve Run the sessions UI on port 45789 (dev-proxy +# URL): start new phone/browser-drivable +# sessions, resume past ones, see and stop live +# ones. Each session is a detached claude +# process in the container — it survives closing +# the terminal and shows up at claude.ai / the +# Claude app. `aibox serve stop` ends the UI and +# every live session. # aibox shell [cmd...] zsh in the container. One argument runs as a # shell string (pipes/globs work); several run # as a safely-quoted command. @@ -237,6 +236,7 @@ if ! grep -q 'aibox:sessions' "$CM" 2>/dev/null; then - Past Claude sessions of this project are JSONL files under `~/.claude/projects/*/` — grep them to find one; the filename (minus .jsonl) is the session id. - To make a past session controllable from claude.ai / the Claude phone app: `nohup script -qfc "claude --resume --remote-control" /dev/null >/dev/null 2>&1 &` — it appears in the claude.ai session list within seconds. +- To start a brand-new session there instead: same command with `--session-id $(cat /proc/sys/kernel/random/uuid)` in place of `--resume `. NOTES chown aibox:aibox "$CM" 2>/dev/null || true fi @@ -713,55 +713,31 @@ cmd_shell() { fi } -# ── Serve: phone/browser sessions (claude remote-control) ──────── -# Keeps a claude remote-control server running detached in the project -# container — outbound HTTPS only, so no ports or proxy work — plus a small -# sessions UI on port 45789 (reachable through the dev proxy). Both survive -# closed terminals; sessions are driven from claude.ai / the Claude app. -# The supervisor restarts the server if it exits (it gives up after ~10min -# of network refusals) and stops after 3 quick failures in a row, which -# means "not logged in / not eligible" — the log says so. +# ── Serve: phone/browser sessions (remote control) ─────────────── +# One model: every live session — brand new or revived — is one detached +# `claude ... --remote-control` process in the project container, spawned +# from the sessions UI (port 45789, via the dev proxy) or by any Claude +# session (the shared CLAUDE.md teaches the command). No server mode and no +# resume windows: a dead process's conversation is just a transcript on +# disk, and Resume is the recovery. Registration is outbound HTTPS only. +# `aibox serve` runs the UI; `aibox serve stop` also kills every session. cmd_serve() { if [[ "${1:-}" == "stop" ]]; then _ensure_docker if [[ "$(_state "$CONTAINER")" == "running" ]]; then - # Supervisor first (or it would just restart what we kill next). docker exec -u aibox "$CONTAINER" sh -c \ - 'pkill -f aibox-serve-rc; pkill -f "claude remote-control"; pkill -f "aibox-serve-ui"; pkill -f -- "--resume.*--remote-control"; true' >/dev/null 2>&1 || true - _ok "Stopped the remote-control server, sessions UI, and revived sessions for ${SLUG}." + 'pkill -f aibox-serve-ui; pkill -f -- "--remote-control"; true' >/dev/null 2>&1 || true + _ok "Stopped the sessions UI and all remote-control sessions for ${SLUG}." else _info "Not running: ${SLUG}" fi return 0 fi + [[ $# -eq 0 ]] || _die "Usage: aibox serve [stop]" _ensure_all _ensure_claude_bin - if docker exec -u aibox "$CONTAINER" pgrep -f aibox-serve-rc >/dev/null 2>&1; then - _info "Serve already running for ${SLUG}." - else - # Extra args pass through to `claude remote-control` verbatim. - local rc_cmd="claude remote-control --remote-control-session-name-prefix aibox-${SLUG}" - [[ $# -gt 0 ]] && rc_cmd+="$(printf ' %q' "$@")" - # script(1) gives claude the pty it expects; the env var dodges quoting. - docker exec -d -u aibox -w "$PROJECT_DIR" -e "AIBOX_RC_CMD=${rc_cmd}" "$CONTAINER" bash -c ' - # aibox-serve-rc - mkdir -p "$HOME/.aibox-serve" - exec >>"$HOME/.aibox-serve/rc.log" 2>&1 - fails=0 - while :; do - start=$(date +%s) - script -qfc "$AIBOX_RC_CMD" /dev/null - (( $(date +%s) - start < 60 )) && fails=$((fails+1)) || fails=0 - if (( fails >= 3 )); then - echo "aibox-serve: giving up after 3 quick exits — likely not logged in or Remote Control unavailable. Fix, then rerun: aibox serve" - exit 1 - fi - sleep 10 - done' - fi - # Sessions UI (idempotent: rewrite the file, start only if not running). docker exec -i -u aibox "$CONTAINER" sh -c 'mkdir -p "$HOME/.aibox-serve" && cat > "$HOME/.aibox-serve/ui.js"' <<'UIJS' // aibox sessions UI — generated by aibox (rewritten on every `aibox serve`). @@ -797,7 +773,7 @@ function liveIds(cb) { cp.exec("pgrep -fa -- --remote-control || true", (e, out) => { const ids = new Set(); String(out || "").split("\n").forEach(l => { - const m = l.match(/--resume[= ]([0-9a-f-]{36})/); + const m = l.match(/(?:--resume|--session-id)[= ]([0-9a-f-]{36})/); if (m) ids.add(m[1]); }); cb(ids); @@ -807,10 +783,17 @@ function sessions(cb) { let files = []; try { files = fs.readdirSync(DIR).filter(f => f.endsWith(".jsonl")); } catch (e) {} liveIds(live => { - cb(files.map(f => { + const list = files.map(f => { const p = path.join(DIR, f), st = fs.statSync(p), id = f.slice(0, -6); return { id, mtime: st.mtimeMs, title: firstUserText(p), messages: messageCount(p), live: live.has(id) }; - }).sort((a, b) => b.mtime - a.mtime)); + }).sort((a, b) => b.mtime - a.mtime); + // Live processes whose transcript has not appeared yet (a session + // spawned moments ago) still deserve a row. + const known = new Set(list.map(s => s.id)); + live.forEach(id => { + if (!known.has(id)) list.unshift({ id, mtime: Date.now(), title: "New session — say something to it", messages: 0, live: true }); + }); + cb(list); }); } const PAGE = ` @@ -840,10 +823,16 @@ a.open{color:var(--accent-ink);background:var(--accent)} button.res{color:var(--accent);background:var(--accent-soft)} button.res:hover{background:var(--accent);color:var(--accent-ink)} button.res[disabled]{opacity:.6;cursor:wait} +.hrow{display:flex;align-items:center;justify-content:space-between;gap:16px} +button.newb{font-family:inherit;font-size:13.5px;font-weight:600;border:none;border-radius:999px;padding:8px 18px;background:var(--accent);color:var(--accent-ink);cursor:pointer} +button.newb[disabled]{opacity:.6;cursor:wait} +button.stopb{font-family:inherit;font-size:13px;font-weight:600;border:none;background:none;color:var(--muted);cursor:pointer;padding:7px 6px} +button.stopb:hover{color:var(--ink)} .empty{padding:36px 22px;color:var(--muted);text-align:center} .hint{margin:18px 6px 0;font-size:13px;color:var(--muted)}
-

aibox

${PROJECT}

+

aibox

+

${PROJECT}

Loading…

Loading…

Resuming brings a session back within a few seconds — it also appears in the Claude app on your phone.

@@ -868,6 +857,9 @@ function render(list){ if(s.live){ const tag=document.createElement("span");tag.className="tag";tag.textContent="Live";row.appendChild(tag); const a=document.createElement("a");a.className="open";a.href=url;a.target="_blank";a.textContent="Open";row.appendChild(a); + const st=document.createElement("button");st.className="stopb";st.textContent="Stop"; + st.onclick=()=>{st.disabled=true;fetch("/api/stop?id="+s.id,{method:"POST"}).then(()=>setTimeout(load,1200))}; + row.appendChild(st); }else{ const b=document.createElement("button");b.className="res";b.textContent="Resume"; b.onclick=()=>{b.disabled=true;b.textContent="Starting…"; @@ -878,10 +870,31 @@ function render(list){ }); } function load(){fetch("/api/sessions").then(r=>r.json()).then(render)} +document.getElementById("new").onclick=()=>{ + const b=document.getElementById("new");b.disabled=true;b.textContent="Starting…"; + fetch("/api/new",{method:"POST"}).then(()=>setTimeout(()=>{b.disabled=false;b.textContent="New session";load()},2000)); +}; load();setInterval(load,15000); `; http.createServer((req, res) => { const u = new URL(req.url, "http://x"); + if (req.method === "POST" && u.pathname === "/api/new") { + const id = require("crypto").randomUUID(); + cp.spawn("script", ["-qfc", "claude --session-id " + id + " --remote-control", "/dev/null"], + { detached: true, stdio: "ignore", cwd: process.cwd() }).unref(); + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ ok: true, id: id })); + } + if (req.method === "POST" && u.pathname === "/api/stop") { + const id = String(u.searchParams.get("id") || ""); + if (!/^[0-9a-f-]{36}$/.test(id)) { res.writeHead(400); return res.end("bad id"); } + // id is regex-vetted above, so interpolating it into the shell is safe + cp.exec("pkill -f -- \"(--resume|--session-id)[= ]" + id + "\" || true", () => { + res.writeHead(200, { "content-type": "application/json" }); + res.end("{\"ok\":true}"); + }); + return; + } if (req.method === "POST" && u.pathname === "/api/resume") { const id = String(u.searchParams.get("id") || ""); if (!/^[0-9a-f-]{36}$/.test(id)) { res.writeHead(400); return res.end("bad id"); } @@ -906,19 +919,8 @@ UIJS exec node "$HOME/.aibox-serve/ui.js" >>"$HOME/.aibox-serve/ui.log" 2>&1' fi - _ok "Sessions UI: $(_dev_url 45789)" - local url="" i=0 - while (( i++ < 30 )); do - url="$(docker exec -u aibox "$CONTAINER" sh -c 'grep -oE "https://claude\.ai/[a-zA-Z0-9/_.-]+" "$HOME/.aibox-serve/rc.log" 2>/dev/null | tail -1')" || true - [[ -n "$url" ]] && break - sleep 1 - done - if [[ -n "$url" ]]; then - _ok "Remote session: ${url} (also in the Claude app's session list)" - else - _warn "No session URL yet — check the Claude app in a minute, or: aibox shell 'tail ~/.aibox-serve/rc.log'" - fi - _info "Runs until 'aibox serve stop' — closing this terminal is fine." + _ok "Sessions UI: $(_dev_url 45789) — new session, resume, live list." + _info "Sessions started there register with claude.ai (Claude app included) and keep running after this terminal closes. End everything with: aibox serve stop" } cmd_stop() { @@ -995,7 +997,7 @@ cmd_status() { echo "Proxy: stopped (starts on next aibox run)" fi if [[ "$(_state "$CONTAINER")" == "running" ]] \ - && docker exec -u aibox "$CONTAINER" pgrep -f aibox-serve-rc >/dev/null 2>&1; then + && docker exec -u aibox "$CONTAINER" pgrep -f aibox-serve-ui >/dev/null 2>&1; then echo "Serve (${SLUG}): running — sessions UI $(_dev_url 45789)" fi if docker volume inspect "$VOLUME" >/dev/null 2>&1; then From b3f531a11b29fae4d180af4fc0b6391b9f174581 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 06:03:04 +0000 Subject: [PATCH 16/26] Fix all confirmed findings from four-agent review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve/UI (three reviewers converged, repro'd empirically): - serve stop was a total no-op reported as success: the sh -c wrapper's own cmdline carried both pkill patterns, so the first pkill killed its parent shell. Each pkill now runs as direct exec argv. - The '# aibox-serve-ui' tag was erased by exec, so the start guard, stop, and status never matched — every serve spawned a duplicate UI and a stale server could outlive rewrites. The tag now rides as an ignored node argv, visible to pgrep/pkill. - Two UI crash-the-server paths fixed: statSync on a transcript deleted mid-poll (per-file try/catch) and 'GET //' throwing in new URL() (parse guarded, 400). - /api/resume now 409s when the session is already live (double-resume attached two claudes to one transcript); spawns get error listeners; message counts only count user/assistant lines and are cached by size+mtime; meta/caveat lines no longer become titles; project name HTML-escaped; container-wide bind trade-off documented. Migration data-safety (adversary agent, all repro'd on synthetic volumes): - Only JSON KEYS in .claude.json claim transcript dirs — a value-position path (MCP args) could steal a project's transcripts into a slice nothing mounts. - Dotfile seeding is decoupled from transcript matching and staged atomically (tmp+rename), so a crash mid-copy is healed by the re-run instead of blessed as complete; the project that triggers migration gets dotfiles too. - Unclaimed transcript dirs are announced loudly instead of stranded silently (covers >200-char and non-ASCII path encodings). - Fresh-volume detection now means EMPTY volume; anything else without a marker migrates (with backup) instead of being walled in. - The running-container guard filters by volume, not label — the oldest aibox containers carry no labels. - Path-hash collisions between projects are caught by a .aibox-path sentinel per slice instead of silently merging homes. Robustness (bash reviewer, repro'd on live docker): - A failed docker run left a wedged Created container that nothing ever recreated; Created state is now recreate-eligible. - Layout detection fails closed on docker errors instead of proceeding to guaranteed-broken container creates; slice preparation is fatal on failure with a real message. - Migration lock steal raised to 1h (a safety backup can take minutes), with a stale-lock hint, and the marker is re-checked after acquiring; backup/restore refuse to run mid-migration. - --hostname truncated to 63 chars (kernel limit; >63-char project dirs previously failed container creation). - Migration move loop no longer skips dangling symlinks. External-facts audit confirmed: volume-subpath semantics and the Engine-26 gate, Claude Code's exact transcript-dir encoding and state paths, --session-id/--resume/--remote-control flag parsing (any hex uuid accepted), cleanupPeriodDays (binary itself suggests 3650), docker stats/ps/df format parsing, /proc uuid format, and that the entrypoint chown is load-bearing for root-created mountpoint dirs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- REVAMP.md | 1 + bin/aibox | 191 +++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 146 insertions(+), 46 deletions(-) diff --git a/REVAMP.md b/REVAMP.md index 6f74089..4c21171 100644 --- a/REVAMP.md +++ b/REVAMP.md @@ -78,6 +78,7 @@ aibox # same as `aibox claude` aibox claude [args...] # shorthand for `aibox run claude`; --yolo skips all prompts, --copy uses a disposable snapshot container, other args pass through (--resume, -c, etc.) aibox run [--copy] [args...] # ensure image/container/proxy, then run any program inside (e.g. aibox run codex) aibox serve # sessions UI (port 45789) in the container: new/resume/stop phone-drivable sessions, each a detached claude --remote-control process; serve stop ends it all + # (known trade-off: the UI binds container-wide for the proxy, so other containers on the aibox network can reach it; its endpoints only spawn/stop THIS project's sessions) aibox shell [cmd...] # zsh in the container, or run a one-off command aibox stop [--all] # stop this project's container (--all: every aibox container + proxy). Never deletes anything aibox status # all aibox containers: project, state, uptime, image; proxy URLs; volume size diff --git a/bin/aibox b/bin/aibox index 86bbc24..f354ff2 100755 --- a/bin/aibox +++ b/bin/aibox @@ -399,7 +399,7 @@ _ensure_net_vol() { _docker_run() { docker run -d --name "$CONTAINER" \ --network "$NETWORK" \ - --hostname "$SLUG" \ + --hostname "${SLUG:0:63}" \ --init \ --add-host host.docker.internal:host-gateway \ --mount "type=volume,src=${VOLUME},dst=/home/aibox,volume-subpath=projects/${HASH6}/home" \ @@ -445,7 +445,7 @@ cd "${AIBOX_MIG_ROOT:-/v}" mkdir -p shared/claude-cfg projects if [ -d .claude ]; then for f in .claude/* .claude/.[!.]*; do - [ -e "$f" ] || continue + [ -e "$f" ] || [ -L "$f" ] || continue case "$(basename "$f")" in projects) continue ;; esac mv "$f" shared/claude-cfg/ done @@ -453,34 +453,64 @@ fi if [ -d .local/bin ] && [ ! -e shared/local-bin ]; then mv .local/bin shared/local-bin; fi if [ -d .local/share/claude ] && [ ! -e shared/claude-app ]; then mv .local/share/claude shared/claude-app; fi mkdir -p shared/local-bin shared/claude-app +# copy_home : seed a project home from the old root dotfiles. +# Copies are staged then renamed, so a crash mid-copy leaves only a temp +# dir that the re-run redoes — never a half .ssh that looks complete. +copy_home() { + mkdir -p "projects/$1/home" "projects/$1/claude-projects" + for d in .ssh .config .gitconfig .zshrc .zsh_history .zshenv .zprofile .netrc; do + [ -e "$d" ] || continue + [ -e "projects/$1/home/$d" ] && continue + rm -rf "projects/$1/home/.aibox-mig-tmp" + cp -a "$d" "projects/$1/home/.aibox-mig-tmp" + mv "projects/$1/home/.aibox-mig-tmp" "projects/$1/home/$d" + done +} CJ=shared/claude-cfg/.claude.json if [ -f "$CJ" ]; then - grep -o "\"/[^\"]*\"" "$CJ" | tr -d "\"" | sort -u | while read -r p; do - enc="$(printf %s "$p" | sed "s/[^A-Za-z0-9]/-/g")" - [ -d ".claude/projects/$enc" ] || continue + # Project paths appear as JSON KEYS ("/abs/path": ...). Value-position + # path strings (MCP args, history entries) must never claim a transcript + # dir — a stray match would strand sessions in a slice nothing mounts. + grep -o "\"/[^\"]*\":" "$CJ" | sed "s/\":\$//; s/^\"//" | sort -u | while read -r p; do h="$(printf %s "$p" | sha256sum | cut -c1-6)" - mkdir -p "projects/$h/claude-projects" "projects/$h/home" - [ -e "projects/$h/claude-projects/$enc" ] || mv ".claude/projects/$enc" "projects/$h/claude-projects/$enc" - for d in .ssh .config .gitconfig .zshrc .zsh_history .zshenv .zprofile .netrc; do - if [ -e "$d" ] && [ ! -e "projects/$h/home/$d" ]; then cp -a "$d" "projects/$h/home/$d"; fi - done + copy_home "$h" + enc="$(printf %s "$p" | sed "s/[^A-Za-z0-9]/-/g")" + if [ -d ".claude/projects/$enc" ] && [ ! -e "projects/$h/claude-projects/$enc" ]; then + mv ".claude/projects/$enc" "projects/$h/claude-projects/$enc" + fi done fi -mkdir -p "projects/$1/home" "projects/$1/claude-projects" +copy_home "$1" +# Anything path-matching could not claim stays put (non-ASCII or >200-char +# encodings, dirs with no .claude.json key) — but say so loudly. +if [ -d .claude/projects ] && [ -n "$(ls -A .claude/projects 2>/dev/null)" ]; then + echo "aibox migrate: transcript dirs left unclaimed (kept in the volume at .claude/projects/):" >&2 + ls .claude/projects >&2 +fi chown -R 1000:1000 shared projects 2>/dev/null || true echo 2 > .aibox-layout ' _migrate_layout() { + # Anything with the volume mounted (labels or not — the oldest containers + # carry none) would race the file moves under a live claude. local running - running="$(docker ps -q --filter label=aibox.slug | head -1)" - [[ -n "$running" ]] && _die "The home volume needs a one-time migration to the per-project layout, but aibox sessions are running. Exit them, run: aibox stop --all, then retry." - # Serialize concurrent invocations (same mkdir-lock pattern as containers). + running="$(docker ps -q --filter "volume=${VOLUME}" | head -1)" + [[ -n "$running" ]] && _die "The home volume needs a one-time migration to the per-project layout, but containers are still using it. Exit sessions, run: aibox stop --all, then retry." + # Serialize concurrent invocations. The steal threshold must exceed the + # safety backup of a large volume (minutes), so it is deliberately huge; + # a genuinely stale lock (Ctrl-C at the wrong moment) gets a hint instead. local lock="${CONFIG_DIR}/.lock-migrate" waited=0 until mkdir "$lock" 2>/dev/null; do - (( waited++ >= 300 )) && { rmdir "$lock" 2>/dev/null || true; waited=0; } + (( waited % 600 == 599 )) && _info "Waiting for another aibox migration to finish... (if none is running, remove the stale lock: rmdir ${lock})" + (( waited++ >= 36000 )) && { rmdir "$lock" 2>/dev/null || true; waited=0; } # 1h sleep 0.1 done + # Another invocation may have finished the migration while we waited. + if [[ "$(docker run --rm -v "${VOLUME}:/v" "$HELPER_IMAGE" sh -c '[ -f /v/.aibox-layout ] && echo done || true')" == "done" ]]; then + rmdir "$lock" 2>/dev/null || true + return 0 + fi _info "Migrating ${VOLUME} to the per-project layout (one-time; old files stay in the volume untouched)..." local safety safety="$(_do_backup "$BACKUP_DIR" "aibox-home-pre-migrate")" \ @@ -503,18 +533,42 @@ _ensure_layout() { if [[ "$maj" =~ ^[0-9]+$ ]] && (( maj < 26 )); then _die "aibox needs Docker Engine 26+ (volume subpath mounts); found ${ver}. Update your runtime (brew upgrade colima / update OrbStack or Docker Desktop)." fi + # Fresh means EMPTY: any other marker-less content (even just an .ssh dir + # from an odd v1 life) goes through migration, which backs up first and + # only moves what it recognizes. Detection failing (offline first pull, + # daemon hiccup) must fail closed — creating containers against wrong + # slices produces far worse errors than stopping here. local state state="$(docker run --rm -v "${VOLUME}:/v" "$HELPER_IMAGE" sh -c ' if [ -f /v/.aibox-layout ]; then echo current - elif [ -e /v/.claude ] || [ -e /v/.local ]; then echo legacy - else + elif [ -z "$(ls -A /v 2>/dev/null)" ]; then mkdir -p /v/shared/claude-cfg /v/shared/local-bin /v/shared/claude-app /v/projects echo 2 > /v/.aibox-layout echo current - fi' 2>/dev/null | tail -1)" - [[ "$state" == "legacy" ]] && _migrate_layout - docker run --rm -v "${VOLUME}:/v" "$HELPER_IMAGE" sh -c \ - "mkdir -p /v/projects/${HASH6}/home /v/projects/${HASH6}/claude-projects && chown -R 1000:1000 /v/projects/${HASH6}" >/dev/null 2>&1 || true + else echo legacy + fi' | tail -1)" + case "$state" in + current) ;; + legacy) _migrate_layout ;; + *) _die "Could not inspect the ${VOLUME} volume layout (docker error above). Retry once docker/network is healthy." ;; + esac + # This project's slices. The .aibox-path sentinel catches the (rare) + # 6-hex-digit path-hash collision between two projects, which would + # otherwise silently merge their private homes. + local slice + slice="$(docker run --rm -e "P=${PROJECT_DIR}" -v "${VOLUME}:/v" "$HELPER_IMAGE" sh -c ' + d="/v/projects/'"${HASH6}"'" + mkdir -p "$d/home" "$d/claude-projects" + if [ -f "$d/.aibox-path" ]; then + [ "$(cat "$d/.aibox-path")" = "$P" ] || { echo COLLISION; exit 0; } + else printf %s "$P" > "$d/.aibox-path"; fi + chown -R 1000:1000 "$d" + echo OK' | tail -1)" + case "$slice" in + OK) ;; + COLLISION) _die "Path-hash collision: another project already owns slice projects/${HASH6}. Rename this project directory to resolve it." ;; + *) _die "Could not prepare the volume slices for ${SLUG} (docker error above)." ;; + esac } _ensure_container() { @@ -536,10 +590,12 @@ _ensure_container() { --format '{{.State.Status}} {{.Config.Image}} {{index .Config.Labels "aibox.layout"}}' 2>/dev/null) || true state="${state:-absent}" - # Recreate when the image changed (new aibox version or node_version) or - # the container predates the current volume layout (its mounts are stale). + # Recreate when the image changed (new aibox version or node_version), the + # container predates the current volume layout (its mounts are stale), or + # it never got past Created (a failed docker run leaves the name wedged — + # docker start can never succeed, and there are no processes to protect). # The home volume and project bind survive by construction. - if [[ "$state" != "absent" && -n "${cur_img:-}" && ( "$cur_img" != "$IMAGE" || "${cur_layout:-}" != "2" ) ]]; then + if [[ "$state" != "absent" && -n "${cur_img:-}" && ( "$cur_img" != "$IMAGE" || "${cur_layout:-}" != "2" || "$state" == "created" ) ]]; then local active=0 if [[ "$state" == "running" ]]; then active="$(docker top "$CONTAINER" -o pid,args 2>/dev/null | tail -n +2 | grep -vc 'sleep infinity' || true)" @@ -725,8 +781,11 @@ cmd_serve() { if [[ "${1:-}" == "stop" ]]; then _ensure_docker if [[ "$(_state "$CONTAINER")" == "running" ]]; then - docker exec -u aibox "$CONTAINER" sh -c \ - 'pkill -f aibox-serve-ui; pkill -f -- "--remote-control"; true' >/dev/null 2>&1 || true + # Each pkill as direct exec argv: a sh -c wrapper would carry the + # pattern in its own cmdline and be pkill's first victim, aborting + # the rest. pkill always excludes itself. + docker exec -u aibox "$CONTAINER" pkill -f aibox-serve-ui >/dev/null 2>&1 || true + docker exec -u aibox "$CONTAINER" pkill -f -- --remote-control >/dev/null 2>&1 || true _ok "Stopped the sessions UI and all remote-control sessions for ${SLUG}." else _info "Not running: ${SLUG}" @@ -746,7 +805,8 @@ const http = require("http"), fs = require("fs"), path = require("path"), cp = r const PORT = 45789; const HOME = process.env.HOME || "/home/aibox"; const DIR = path.join(HOME, ".claude", "projects", process.cwd().replace(/[^A-Za-z0-9]/g, "-")); -const PROJECT = path.basename(process.cwd()); +const esc = s => s.replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c])); +const PROJECT = esc(path.basename(process.cwd())); function firstUserText(file) { try { @@ -756,18 +816,31 @@ function firstUserText(file) { for (const line of buf.slice(0, n).toString("utf8").split("\n")) { if (!line.trim()) continue; let j; try { j = JSON.parse(line); } catch (e) { continue; } - if (j.type === "user" && j.message) { + if (j.type === "user" && j.message && !j.isMeta) { const c = j.message.content; let t = typeof c === "string" ? c : Array.isArray(c) ? c.map(x => x.text || "").join(" ") : ""; t = t.replace(/\s+/g, " ").trim(); - if (t) return t.slice(0, 90); + if (!t || t.startsWith("Caveat:") || t.startsWith(" { @@ -783,10 +856,16 @@ function sessions(cb) { let files = []; try { files = fs.readdirSync(DIR).filter(f => f.endsWith(".jsonl")); } catch (e) {} liveIds(live => { - const list = files.map(f => { - const p = path.join(DIR, f), st = fs.statSync(p), id = f.slice(0, -6); - return { id, mtime: st.mtimeMs, title: firstUserText(p), messages: messageCount(p), live: live.has(id) }; - }).sort((a, b) => b.mtime - a.mtime); + const list = []; + files.forEach(f => { + // Per-file guard: a transcript can vanish between readdir and stat + // (Claude Code cleanup, manual rm) — that must not kill the server. + try { + const p = path.join(DIR, f), st = fs.statSync(p), id = f.slice(0, -6); + list.push({ id, mtime: st.mtimeMs, title: firstUserText(p), messages: messageCount(p, st), live: live.has(id) }); + } catch (e) {} + }); + list.sort((a, b) => b.mtime - a.mtime); // Live processes whose transcript has not appeared yet (a session // spawned moments ago) still deserve a row. const known = new Set(list.map(s => s.id)); @@ -876,12 +955,20 @@ document.getElementById("new").onclick=()=>{ }; load();setInterval(load,15000); `; +function spawnSession(args) { + const c = cp.spawn("script", ["-qfc", "claude " + args + " --remote-control", "/dev/null"], + { detached: true, stdio: "ignore", cwd: process.cwd() }); + c.on("error", function () {}); + c.unref(); +} http.createServer((req, res) => { - const u = new URL(req.url, "http://x"); + let u; + // Request-targets like "//" make new URL() throw — a crash here would + // kill the whole UI. + try { u = new URL(req.url, "http://x"); } catch (e) { res.writeHead(400); return res.end("bad request"); } if (req.method === "POST" && u.pathname === "/api/new") { const id = require("crypto").randomUUID(); - cp.spawn("script", ["-qfc", "claude --session-id " + id + " --remote-control", "/dev/null"], - { detached: true, stdio: "ignore", cwd: process.cwd() }).unref(); + spawnSession("--session-id " + id); res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ ok: true, id: id })); } @@ -898,10 +985,14 @@ http.createServer((req, res) => { if (req.method === "POST" && u.pathname === "/api/resume") { const id = String(u.searchParams.get("id") || ""); if (!/^[0-9a-f-]{36}$/.test(id)) { res.writeHead(400); return res.end("bad id"); } - cp.spawn("script", ["-qfc", "claude --resume " + id + " --remote-control", "/dev/null"], - { detached: true, stdio: "ignore", cwd: process.cwd() }).unref(); - res.writeHead(200, { "content-type": "application/json" }); - return res.end("{\"ok\":true}"); + return liveIds(live => { + // Double-resume would attach two claude processes to one transcript + // (clients on several devices share this page). + if (live.has(id)) { res.writeHead(409); return res.end("already live"); } + spawnSession("--resume " + id); + res.writeHead(200, { "content-type": "application/json" }); + res.end("{\"ok\":true}"); + }); } if (u.pathname === "/api/sessions") { return sessions(list => { @@ -911,12 +1002,16 @@ http.createServer((req, res) => { } res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(PAGE); + // Container-wide bind is what lets the dev proxy reach us; the accepted + // trade-off is that other containers on the aibox network can reach it + // too. Endpoints only spawn/stop sessions of THIS project. }).listen(PORT, "0.0.0.0"); UIJS if ! docker exec -u aibox "$CONTAINER" pgrep -f aibox-serve-ui >/dev/null 2>&1; then - docker exec -d -u aibox -w "$PROJECT_DIR" "$CONTAINER" bash -c ' - # aibox-serve-ui - exec node "$HOME/.aibox-serve/ui.js" >>"$HOME/.aibox-serve/ui.log" 2>&1' + # The tag rides as an (ignored) node argv so it survives exec and is + # visible to pgrep/pkill — a comment in bash -c would be erased by exec. + docker exec -d -u aibox -w "$PROJECT_DIR" "$CONTAINER" bash -c \ + 'exec node "$HOME/.aibox-serve/ui.js" aibox-serve-ui >>"$HOME/.aibox-serve/ui.log" 2>&1' fi _ok "Sessions UI: $(_dev_url 45789) — new session, resume, live list." @@ -985,7 +1080,7 @@ cmd_status() { fi # Daemon-wide disk picture (images/containers/volumes + reclaimable). local df - df="$(docker system df --format '{{.Type}} {{.Size}} ({{.Reclaimable}} reclaimable)' 2>/dev/null \ + df="$(docker system df --format '{{.Type}}: {{.Size}}, reclaimable {{.Reclaimable}}' 2>/dev/null \ | sed 's/^Local Volumes/Volumes/; s/^Build Cache/Build-cache/' | tr '\n' '\t')" || true [[ -n "$df" ]] && { echo ""; echo "Docker disk: $(echo "$df" | sed 's/\t$//; s/\t/ · /g')"; } echo "" @@ -1027,6 +1122,8 @@ _do_backup() { cmd_backup() { _ensure_docker + [[ -d "${CONFIG_DIR}/.lock-migrate" ]] \ + && _die "A volume-layout migration is in progress — retry once it finishes." docker volume inspect "$VOLUME" >/dev/null 2>&1 \ || _die "No ${VOLUME} volume yet — nothing to back up." local out @@ -1042,6 +1139,8 @@ cmd_restore() { file="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" [[ "$file" == *:* ]] && _die "Backup path contains ':' — docker cannot mount it. Move/rename the file first." _ensure_docker + [[ -d "${CONFIG_DIR}/.lock-migrate" ]] \ + && _die "A volume-layout migration is in progress — retry once it finishes." docker volume create "$VOLUME" >/dev/null 2>&1 || true # Validate the archive BEFORE touching anything. The file is mounted at a From fbefb6fc5a9a2e4e444ba853f550cd23ec2bcaf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:06:46 +0000 Subject: [PATCH 17/26] Add aibox sessions: host-side all-projects page, and rc-resume plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aibox sessions runs a small Node server ON THE HOST (node is guaranteed — aibox installs via npm), loopback-only, foreground: it opens the browser on start and stops on Ctrl-C or the page's Close button. No daemon, no lifecycle machinery. Listing comes from a throwaway container with the volume mounted read-only (per-project slices, .aibox-path, titles from first user message). Each session offers three actions: Terminal (opens Ghostty via 'open -na', Terminal.app via osascript as fallback; macOS only), Copy (resume command to clipboard, client-side), and Phone (runs the new 'aibox rc-resume ' in the project dir — ensure container, spawn detached claude --resume --remote-control). Action requests are vetted against the last listing, so only real (path, id) pairs reach a shell. Being host-side and loopback-bound, this page has none of the sibling-container reachability of the in-container serve UI. Tested: lister against fixture volumes, server end-to-end with stubbed docker/aibox (page, listing, unknown-pair 400, macOS-only fallback message on Linux, rc action invoked in the right cwd, Close exits the process), rc-resume validation + exec argv via stub docker, 10/10 command regression. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- README.md | 1 + bin/aibox | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) diff --git a/README.md b/README.md index 27de039..1c10e82 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ Sessions merge file-by-file (nothing is ever overwritten or deleted; sources are | `aibox` / `aibox claude [args]` | Shorthand for `aibox run claude`. `--yolo` skips all permission prompts; `--copy` uses a disposable snapshot container (no bind mount, removed on exit); other args pass through verbatim (`--resume`, `-p`, ...). `aibox --resume` works too | | `aibox run [--copy] [args]` | Run any program in the sandbox (e.g. `aibox run codex`). `--copy` works the same as above; the program's own flags pass through | | `aibox serve` | Sessions UI in the container: start new phone/claude.ai-drivable sessions, resume past ones, stop live ones. `aibox serve stop` ends the UI and every live session | +| `aibox sessions` | All projects' sessions on one local page (host-side, loopback-only, foreground). Buttons per session: open in Ghostty/Terminal, copy the resume command, or send to your phone | | `aibox shell [cmd]` | zsh in the container, or run a one-off command | | `aibox stop [--all]` | Stop this project's container (`--all`: everything incl. proxy). Loses nothing | | `aibox status` | Containers with live memory + disk use, dev URLs, Docker disk totals, home volume size | diff --git a/bin/aibox b/bin/aibox index f354ff2..efbf854 100755 --- a/bin/aibox +++ b/bin/aibox @@ -30,6 +30,13 @@ # the terminal and shows up at claude.ai / the # Claude app. `aibox serve stop` ends the UI and # every live session. +# aibox sessions All projects' past sessions on one local page +# (opens your browser; loopback-only, runs in +# the foreground until Ctrl-C or its Close +# button). Per session: open in a terminal, copy +# the resume command, or make it phone-drivable. +# aibox rc-resume Make one past session of this project +# drivable from claude.ai / the Claude app. # aibox shell [cmd...] zsh in the container. One argument runs as a # shell string (pipes/globs work); several run # as a safely-quoted command. @@ -1018,6 +1025,227 @@ UIJS _info "Sessions started there register with claude.ai (Claude app included) and keep running after this terminal closes. End everything with: aibox serve stop" } +# Plumbing for the host-side sessions page (and handy on its own): make one +# past session of THIS project drivable from claude.ai / the Claude app. +cmd_rc_resume() { + [[ "${1:-}" =~ ^[0-9a-f-]{36}$ ]] || _die "Usage: aibox rc-resume " + _ensure_all + _ensure_claude_bin + docker exec -d -u aibox -w "$PROJECT_DIR" "$CONTAINER" \ + script -qfc "claude --resume $1 --remote-control" /dev/null + _ok "Session registering — it appears in the claude.ai / Claude app list shortly." +} + +# ── Sessions page (host-side, foreground, all projects) ────────── +# `aibox sessions` runs a small Node server ON THE HOST (node is present by +# construction — aibox installs via npm), loopback-only, and opens the +# browser. Not a daemon: Ctrl-C or the page's Close button ends it. Listing +# is read via a throwaway container with the volume mounted read-only; the +# Terminal/Phone actions shell back into aibox itself. +cmd_sessions() { + _ensure_docker + _ensure_image + command -v node >/dev/null 2>&1 \ + || _die "aibox sessions needs node on the host (it comes with the npm install of aibox)." + + cat > "${CONFIG_DIR}/sessions-lister.js" <<'LISTER' +// Runs inside a container with the aibox-home volume mounted read-only at +// /v (AIBOX_LIST_ROOT overrides for tests). Prints all projects' sessions +// as JSON on stdout. +const fs = require("fs"), path = require("path"); +const ROOT = process.env.AIBOX_LIST_ROOT || "/v"; +function title(file) { + try { + const fd = fs.openSync(file, "r"), buf = Buffer.alloc(262144); + const n = fs.readSync(fd, buf, 0, buf.length, 0); + fs.closeSync(fd); + for (const line of buf.slice(0, n).toString("utf8").split("\n")) { + if (!line.trim()) continue; + let j; try { j = JSON.parse(line); } catch (e) { continue; } + if (j.type === "user" && j.message && !j.isMeta) { + const c = j.message.content; + let t = typeof c === "string" ? c : Array.isArray(c) ? c.map(x => x.text || "").join(" ") : ""; + t = t.replace(/\s+/g, " ").trim(); + if (!t || t.startsWith("Caveat:") || t.startsWith(" b.mtime - a.mtime); +console.log(JSON.stringify(out)); +LISTER + + cat > "${CONFIG_DIR}/sessions-ui.js" <<'SESSJS' +// aibox sessions — host-side page over all projects' sessions. Generated by +// aibox (rewritten on every run). Foreground; Close button or Ctrl-C ends it. +const http = require("http"), fs = require("fs"), cp = require("child_process"); +const PORT = 45790, URLBASE = "http://127.0.0.1:" + PORT; +const IMAGE = process.env.AIBOX_IMAGE, VOLUME = process.env.AIBOX_VOLUME, CFG = process.env.AIBOX_CFG; +let known = new Set(); // "path\nid" pairs from the last listing — action guards +function list(cb) { + cp.execFile("docker", ["run", "--rm", + "-v", VOLUME + ":/v:ro", + "-v", CFG + "/sessions-lister.js:/lister.js:ro", + IMAGE, "node", "/lister.js"], { maxBuffer: 32 * 1024 * 1024 }, (e, out) => { + let l = []; + try { l = JSON.parse(out); } catch (err) {} + known = new Set(l.map(s => s.project + "\n" + s.id)); + cb(l); + }); +} +function vetted(u) { + const p = String(u.searchParams.get("path") || ""), id = String(u.searchParams.get("id") || ""); + return known.has(p + "\n" + id) ? { p, id } : null; +} +const RESUME_SH = 'cd "$1" && exec aibox --resume "$2"'; +function openTerminal(p, id, cb) { + if (process.platform !== "darwin") return cb("Terminal open is macOS-only here — use Copy instead."); + if (fs.existsSync("/Applications/Ghostty.app")) { + cp.execFile("open", ["-na", "Ghostty", "--args", "-e", "bash", "-lc", RESUME_SH, "aibox", p, id], + e => cb(e ? "Could not open Ghostty: " + e.message : null)); + } else { + const cmd = "cd " + JSON.stringify(p) + " && aibox --resume " + id; + cp.execFile("osascript", + ["-e", "tell application \"Terminal\" to do script " + JSON.stringify(cmd), + "-e", "tell application \"Terminal\" to activate"], + e => cb(e ? "Could not open Terminal: " + e.message : null)); + } +} +const PAGE = ` + +aibox sessions + +
+

aibox

+

Sessions

+
+

Loading…

+
Loading…
+
`; +http.createServer((req, res) => { + let u; + try { u = new URL(req.url, URLBASE); } catch (e) { res.writeHead(400); return res.end("bad request"); } + const json = o => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(o)); }; + if (req.method === "POST" && u.pathname === "/api/close") { + json({ ok: true }); + console.log("Closed from the page. Bye."); + return setTimeout(() => process.exit(0), 100); + } + if (req.method === "POST" && (u.pathname === "/api/terminal" || u.pathname === "/api/rc")) { + const v = vetted(u); + if (!v) { res.writeHead(400); return res.end("unknown session"); } + if (u.pathname === "/api/terminal") return openTerminal(v.p, v.id, err => json(err ? { error: err } : { ok: true })); + cp.execFile("bash", ["-lc", 'cd "$1" && exec aibox rc-resume "$2"', "aibox-sessions", v.p, v.id], + (e, out, serr) => { + if (e) console.error(String(serr || e.message).trim()); + else console.log(String(out).trim()); + json(e ? { error: "rc-resume failed — see the aibox sessions terminal." } : { ok: true }); + }); + return; + } + if (u.pathname === "/api/sessions") return list(l => json(l)); + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(PAGE); +}).listen(PORT, "127.0.0.1", () => { + console.log("aibox sessions: " + URLBASE + " (Ctrl-C or the Close button to stop)"); + const opener = process.platform === "darwin" ? "open" : "xdg-open"; + cp.execFile(opener, [URLBASE], () => {}); +}); +SESSJS + + AIBOX_IMAGE="$IMAGE" AIBOX_VOLUME="$VOLUME" AIBOX_CFG="$CONFIG_DIR" \ + node "${CONFIG_DIR}/sessions-ui.js" +} + cmd_stop() { _ensure_docker if [[ "${1:-}" == "--all" ]]; then @@ -1249,6 +1477,8 @@ case "$CMD" in claude) cmd_run claude "$@" ;; run) cmd_run "$@" ;; serve) cmd_serve "$@" ;; + sessions) cmd_sessions ;; + rc-resume) cmd_rc_resume "$@" ;; shell) cmd_shell "$@" ;; stop) cmd_stop "$@" ;; status) cmd_status ;; From 1db7b72d9295e7f0dd79ca2f59a3670d59e4d7ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:10:31 +0000 Subject: [PATCH 18/26] Fix sessions lister hang: bypass the image entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker run of the aibox image ran the container entrypoint first, which tried to install claude into the empty image-local home before the lister executed — minutes of 'Loading...' on every page load. --entrypoint node skips it. Empty state now explains the not-yet-migrated-volume case with the exact commands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- bin/aibox | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bin/aibox b/bin/aibox index efbf854..7f5d738 100755 --- a/bin/aibox +++ b/bin/aibox @@ -1105,10 +1105,13 @@ const PORT = 45790, URLBASE = "http://127.0.0.1:" + PORT; const IMAGE = process.env.AIBOX_IMAGE, VOLUME = process.env.AIBOX_VOLUME, CFG = process.env.AIBOX_CFG; let known = new Set(); // "path\nid" pairs from the last listing — action guards function list(cb) { - cp.execFile("docker", ["run", "--rm", + // --entrypoint bypasses the aibox image entrypoint, which would otherwise + // try to install claude into the (empty) image-local home before running + // the lister — minutes of hang on every listing. + cp.execFile("docker", ["run", "--rm", "--entrypoint", "node", "-v", VOLUME + ":/v:ro", "-v", CFG + "/sessions-lister.js:/lister.js:ro", - IMAGE, "node", "/lister.js"], { maxBuffer: 32 * 1024 * 1024 }, (e, out) => { + IMAGE, "/lister.js"], { maxBuffer: 32 * 1024 * 1024 }, (e, out) => { let l = []; try { l = JSON.parse(out); } catch (err) {} known = new Set(l.map(s => s.project + "\n" + s.id)); @@ -1182,7 +1185,7 @@ function act(path,id,ep,okMsg){ function render(list){ const el=document.getElementById("list"); document.getElementById("sub").textContent=list.length+" sessions across "+new Set(list.map(s=>s.project)).size+" projects"; - if(!list.length){el.innerHTML="
No sessions found in the aibox-home volume.
";return} + if(!list.length){el.innerHTML="
No sessions found. If you just updated aibox, the volume has not migrated yet — exit sessions, run aibox stop --all, then run aibox in any project once, and Refresh here.
";return} el.innerHTML=""; list.forEach(s=>{ const row=document.createElement("div");row.className="row"; From 49dab9e284842536fad2597046bda251f91902e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:13:52 +0000 Subject: [PATCH 19/26] Sessions page works pre-migration: lister reads both layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy flat layout (and post-migration leftovers) are listed too, with real paths recovered from .claude.json keys using the same rule as the migration; dirs with unrecoverable paths still show, copy-only. So aibox sessions is useful the moment it's installed — including while old-layout sessions are still running — instead of being gated on migrating first. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- bin/aibox | 64 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/bin/aibox b/bin/aibox index 7f5d738..ad639ba 100755 --- a/bin/aibox +++ b/bin/aibox @@ -1074,6 +1074,17 @@ function title(file) { return "Untitled session"; } const out = []; +function addDir(dir, ppath, label) { + let files = []; try { files = fs.readdirSync(dir); } catch (e) { return; } + for (const f of files) { + if (!f.endsWith(".jsonl")) continue; + try { + const p = path.join(dir, f), st = fs.statSync(p); + out.push({ project: ppath, label: label, id: f.slice(0, -6), mtime: st.mtimeMs, title: title(p) }); + } catch (e) {} + } +} +// New layout: per-project slices. let hashes = []; try { hashes = fs.readdirSync(path.join(ROOT, "projects")); } catch (e) {} for (const h of hashes) { @@ -1082,16 +1093,21 @@ for (const h of hashes) { try { ppath = fs.readFileSync(path.join(base, ".aibox-path"), "utf8"); } catch (e) { continue; } const cp = path.join(base, "claude-projects"); let encs = []; try { encs = fs.readdirSync(cp); } catch (e) {} - for (const enc of encs) { - let files = []; try { files = fs.readdirSync(path.join(cp, enc)); } catch (e) { continue; } - for (const f of files) { - if (!f.endsWith(".jsonl")) continue; - try { - const p = path.join(cp, enc, f), st = fs.statSync(p); - out.push({ project: ppath, id: f.slice(0, -6), mtime: st.mtimeMs, title: title(p) }); - } catch (e) {} - } - } + for (const enc of encs) addDir(path.join(cp, enc), ppath, ppath); +} +// Legacy / not-yet-migrated layout (and post-migration leftovers). Dir +// names are lossy encodings; real paths come from .claude.json KEYS with +// the same rule the migration uses. Unmatched dirs are listed anyway with +// no project path — the page then offers copy-only. +const legacyRoot = path.join(ROOT, ".claude", "projects"); +let legacy = []; try { legacy = fs.readdirSync(legacyRoot); } catch (e) {} +if (legacy.length) { + const map = {}; + try { + const cj = fs.readFileSync(path.join(ROOT, ".claude", ".claude.json"), "utf8"); + for (const m of cj.matchAll(/"(\/[^"]*)":/g)) map[m[1].replace(/[^a-zA-Z0-9]/g, "-")] = m[1]; + } catch (e) {} + for (const enc of legacy) addDir(path.join(legacyRoot, enc), map[enc] || "", map[enc] || enc); } out.sort((a, b) => b.mtime - a.mtime); console.log(JSON.stringify(out)); @@ -1184,7 +1200,7 @@ function act(path,id,ep,okMsg){ } function render(list){ const el=document.getElementById("list"); - document.getElementById("sub").textContent=list.length+" sessions across "+new Set(list.map(s=>s.project)).size+" projects"; + document.getElementById("sub").textContent=list.length+" sessions across "+new Set(list.map(s=>s.label)).size+" projects"; if(!list.length){el.innerHTML="
No sessions found. If you just updated aibox, the volume has not migrated yet — exit sessions, run aibox stop --all, then run aibox in any project once, and Refresh here.
";return} el.innerHTML=""; list.forEach(s=>{ @@ -1193,18 +1209,26 @@ function render(list){ const t=document.createElement("p");t.className="t";t.textContent=s.title; const m=document.createElement("p");m.className="m"; const chip=document.createElement("span");chip.className="chip"; - chip.textContent=s.project.split("/").filter(Boolean).pop()||s.project; - m.appendChild(chip);m.appendChild(document.createTextNode(ago(s.mtime)+" · "+s.project)); + chip.textContent=s.label.split("/").filter(Boolean).pop()||s.label; + m.appendChild(chip);m.appendChild(document.createTextNode(ago(s.mtime)+" · "+s.label)); main.appendChild(t);main.appendChild(m);row.appendChild(main); const acts=document.createElement("div");acts.className="acts"; - const bT=document.createElement("button");bT.textContent="Terminal"; - bT.onclick=()=>act(s.project,s.id,"terminal","Opening a terminal…"); + if(s.project){ + const bT=document.createElement("button");bT.textContent="Terminal"; + bT.onclick=()=>act(s.project,s.id,"terminal","Opening a terminal…"); + acts.appendChild(bT); + } const bC=document.createElement("button");bC.textContent="Copy"; - bC.onclick=()=>{navigator.clipboard.writeText("cd "+JSON.stringify(s.project)+" && aibox --resume "+s.id) - .then(()=>say("Command copied — paste it into any tab."))}; - const bP=document.createElement("button");bP.textContent="Phone"; - bP.onclick=()=>act(s.project,s.id,"rc","Registering — watch the Claude app list."); - acts.appendChild(bT);acts.appendChild(bC);acts.appendChild(bP);row.appendChild(acts); + bC.onclick=()=>{ + const cmd=s.project?("cd "+JSON.stringify(s.project)+" && aibox --resume "+s.id):("aibox --resume "+s.id); + navigator.clipboard.writeText(cmd).then(()=>say(s.project?"Command copied — paste it into any tab.":"Copied — run it inside that project directory (path unknown for this one)."))}; + acts.appendChild(bC); + if(s.project){ + const bP=document.createElement("button");bP.textContent="Phone"; + bP.onclick=()=>act(s.project,s.id,"rc","Registering — watch the Claude app list."); + acts.appendChild(bP); + } + row.appendChild(acts); el.appendChild(row); }); } From a9c825482ccb5568e2943ae8a57c92dc87639ae5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:14:29 +0000 Subject: [PATCH 20/26] Sessions page: refuse shell actions for pathless legacy sessions server-side Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- bin/aibox | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/aibox b/bin/aibox index ad639ba..881d707 100755 --- a/bin/aibox +++ b/bin/aibox @@ -1136,7 +1136,8 @@ function list(cb) { } function vetted(u) { const p = String(u.searchParams.get("path") || ""), id = String(u.searchParams.get("id") || ""); - return known.has(p + "\n" + id) ? { p, id } : null; + // Pathless (legacy-orphan) sessions are copy-only — no shell actions. + return p && known.has(p + "\n" + id) ? { p, id } : null; } const RESUME_SH = 'cd "$1" && exec aibox --resume "$2"'; function openTerminal(p, id, cb) { From 6a99daa66f5459c4e572ff094c2e957bc805f0b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:26:42 +0000 Subject: [PATCH 21/26] Sessions page: summary titles and last-response preview per session Row titles now prefer Claude Code's own summary records (the same text its resume picker shows), falling back to the first user message; each row adds an italic one-line preview of the most recent assistant response. The lister reads a head and a tail window per transcript (not the whole file), so multi-MB sessions stay cheap and tail-only summaries are still found. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- bin/aibox | 64 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/bin/aibox b/bin/aibox index 881d707..275d7bf 100755 --- a/bin/aibox +++ b/bin/aibox @@ -1054,24 +1054,49 @@ cmd_sessions() { // as JSON on stdout. const fs = require("fs"), path = require("path"); const ROOT = process.env.AIBOX_LIST_ROOT || "/v"; -function title(file) { +function msgText(m) { + const c = m.content; + const t = typeof c === "string" ? c : Array.isArray(c) ? c.map(x => x.text || "").join(" ") : ""; + return t.replace(/\s+/g, " ").trim(); +} +function parseLine(l) { try { return JSON.parse(l); } catch (e) { return null; } } +// Title prefers Claude Code's own summary records (what its resume picker +// shows); falls back to the first real user message. `last` is the tail of +// the conversation — the most recent assistant text. Reads only a head and +// a tail window, transcripts can be many MB. +function info(file, size) { + let head = "", tail = ""; try { - const fd = fs.openSync(file, "r"), buf = Buffer.alloc(262144); - const n = fs.readSync(fd, buf, 0, buf.length, 0); + const fd = fs.openSync(file, "r"); + const rd = (pos, len) => { + const b = Buffer.alloc(len), n = fs.readSync(fd, b, 0, len, pos); + return b.slice(0, n).toString("utf8"); + }; + head = rd(0, 262144); + if (size > 262144) tail = rd(Math.max(0, size - 131072), 131072); fs.closeSync(fd); - for (const line of buf.slice(0, n).toString("utf8").split("\n")) { - if (!line.trim()) continue; - let j; try { j = JSON.parse(line); } catch (e) { continue; } - if (j.type === "user" && j.message && !j.isMeta) { - const c = j.message.content; - let t = typeof c === "string" ? c : Array.isArray(c) ? c.map(x => x.text || "").join(" ") : ""; - t = t.replace(/\s+/g, " ").trim(); - if (!t || t.startsWith("Caveat:") || t.startsWith("= 0; i--) { + const j = parseLine(tl[i]); if (!j) continue; + if (!tailSummary && j.type === "summary" && j.summary) tailSummary = j.summary; + if (!last && j.type === "assistant" && j.message) { + const t = msgText(j.message); + if (t) last = t.slice(0, 160); + } + if (last && tailSummary) break; + } + return { title: (tailSummary || summary || firstUser || "Untitled session").slice(0, 90), last: last }; } const out = []; function addDir(dir, ppath, label) { @@ -1079,8 +1104,8 @@ function addDir(dir, ppath, label) { for (const f of files) { if (!f.endsWith(".jsonl")) continue; try { - const p = path.join(dir, f), st = fs.statSync(p); - out.push({ project: ppath, label: label, id: f.slice(0, -6), mtime: st.mtimeMs, title: title(p) }); + const p = path.join(dir, f), st = fs.statSync(p), inf = info(p, st.size); + out.push({ project: ppath, label: label, id: f.slice(0, -6), mtime: st.mtimeMs, title: inf.title, last: inf.last }); } catch (e) {} } } @@ -1174,6 +1199,7 @@ h1{margin:0;font-size:28px;font-weight:700;letter-spacing:-.02em} .row+.row{border-top:1px solid var(--line)} .rm{flex:1;min-width:0} .t{margin:0 0 2px;font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.last{margin:0 0 3px;font-size:12.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-style:italic} .m{margin:0;font-size:12.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .chip{font-size:11.5px;font-weight:600;color:var(--accent);background:var(--accent-soft);border-radius:6px;padding:2px 8px;margin-right:8px} .acts{display:flex;gap:6px;flex:none} @@ -1208,11 +1234,13 @@ function render(list){ const row=document.createElement("div");row.className="row"; const main=document.createElement("div");main.className="rm"; const t=document.createElement("p");t.className="t";t.textContent=s.title; + main.appendChild(t); + if(s.last){const le=document.createElement("p");le.className="last";le.textContent="↳ "+s.last;main.appendChild(le)} const m=document.createElement("p");m.className="m"; const chip=document.createElement("span");chip.className="chip"; chip.textContent=s.label.split("/").filter(Boolean).pop()||s.label; m.appendChild(chip);m.appendChild(document.createTextNode(ago(s.mtime)+" · "+s.label)); - main.appendChild(t);main.appendChild(m);row.appendChild(main); + main.appendChild(m);row.appendChild(main); const acts=document.createElement("div");acts.className="acts"; if(s.project){ const bT=document.createElement("button");bT.textContent="Terminal"; From 0ed263e9748313489d5574088123243fc039315c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:33:29 +0000 Subject: [PATCH 22/26] Sessions page: rebuild layout, fix injection, guard live sessions, harden server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI: rows were a cramped horizontal flex where three action buttons crushed the title/preview to nothing on a narrow window. Rebuilt as a vertical stack — title and a two-line last-reply preview get full width and wrap; actions sit on their own wrapping row with real labels. Live-session guard (the user's exact scenario — 10 sessions running while browsing): sessions with mtime within ~2 min show an 'active now' badge, and Open-in-terminal / Send-to-phone on them require a confirm, since resuming starts a second claude on a transcript being written right now. Security + robustness (from the two-agent review): - osascript Terminal branch built a shell command with JSON.stringify, which doesn't neutralize $()/backticks — a project path or a volume-planted .aibox-path could execute arbitrary host commands. Both path and id are now POSIX single-quoted; id is also regex-vetted in vetted(); the Copy command is quoted the same way. - Host-header check rejects non-loopback Host values (blocks other sites and DNS-rebinding from driving the server); action 400s return JSON so the client stops mis-reporting 'Server stopped'. - EADDRINUSE (a stale server from the earlier buggy run holds 45790) now prints a friendly hint instead of a raw stack trace. - Terminal/phone actions use the aibox binary resolved on the host (command -v / realpath $0), passed positionally to the Ghostty shell — so PATH gaps in a launchd-spawned Ghostty login shell don't cause 'aibox: command not found'; the Ghostty window is held open on failure. - Lister/docker errors are surfaced as a page banner instead of the misleading 'volume not migrated' empty-state (which had told the user to stop their live sessions). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- bin/aibox | 173 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 115 insertions(+), 58 deletions(-) diff --git a/bin/aibox b/bin/aibox index 275d7bf..ee8b946 100755 --- a/bin/aibox +++ b/bin/aibox @@ -1152,26 +1152,40 @@ function list(cb) { cp.execFile("docker", ["run", "--rm", "--entrypoint", "node", "-v", VOLUME + ":/v:ro", "-v", CFG + "/sessions-lister.js:/lister.js:ro", - IMAGE, "/lister.js"], { maxBuffer: 32 * 1024 * 1024 }, (e, out) => { + IMAGE, "/lister.js"], { maxBuffer: 64 * 1024 * 1024 }, (e, out) => { + if (e) { known = new Set(); return cb(null, "Could not read sessions (is docker running?). Details in the aibox sessions terminal."), console.error(e.message); } let l = []; - try { l = JSON.parse(out); } catch (err) {} + try { l = JSON.parse(out); } catch (err) { known = new Set(); return cb(null, "Could not parse the session list."); } known = new Set(l.map(s => s.project + "\n" + s.id)); - cb(l); + cb(l, null); }); } function vetted(u) { const p = String(u.searchParams.get("path") || ""), id = String(u.searchParams.get("id") || ""); // Pathless (legacy-orphan) sessions are copy-only — no shell actions. - return p && known.has(p + "\n" + id) ? { p, id } : null; + // id is also charset-checked as defense in depth; every branch that + // interpolates these into a command still shell-quotes them. + if (!p || !/^[0-9a-f-]{36}$/.test(id)) return null; + return known.has(p + "\n" + id) ? { p, id } : null; } -const RESUME_SH = 'cd "$1" && exec aibox --resume "$2"'; +// POSIX single-quote a string for embedding in a shell command. Paths and +// ids reaching a shell come from volume contents (.aibox-path, .claude.json +// keys, transcript filenames) — treat them as hostile. +function shq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; } +const AIBOX = process.env.AIBOX_BIN || "aibox"; +// argv: $1=project dir, $2=aibox binary, $3=session id (env doesn't reliably +// survive `open` → LaunchServices, so pass the resolved binary positionally). +// On failure hold the window open — a bare -e window closes instantly. +const RESUME_SH = 'cd "$1" && "$2" --resume "$3" || { echo; echo "[exited $?] press Enter to close"; read -r _; }'; function openTerminal(p, id, cb) { - if (process.platform !== "darwin") return cb("Terminal open is macOS-only here — use Copy instead."); + if (process.platform !== "darwin") return cb("Opening a terminal is macOS-only — use Copy instead."); if (fs.existsSync("/Applications/Ghostty.app")) { - cp.execFile("open", ["-na", "Ghostty", "--args", "-e", "bash", "-lc", RESUME_SH, "aibox", p, id], + cp.execFile("open", ["-na", "Ghostty", "--args", "-e", "bash", "-lc", RESUME_SH, "aibox", p, AIBOX, id], e => cb(e ? "Could not open Ghostty: " + e.message : null)); } else { - const cmd = "cd " + JSON.stringify(p) + " && aibox --resume " + id; + // do script runs cmd through a shell, so shq() every component first; + // JSON.stringify then makes cmd a valid AppleScript string literal. + const cmd = "cd " + shq(p) + " && " + shq(AIBOX) + " --resume " + shq(id); cp.execFile("osascript", ["-e", "tell application \"Terminal\" to do script " + JSON.stringify(cmd), "-e", "tell application \"Terminal\" to activate"], @@ -1185,28 +1199,37 @@ const PAGE = `
@@ -1214,63 +1237,84 @@ h1{margin:0;font-size:28px;font-weight:700;letter-spacing:-.02em}

Sessions

Loading…

+
Loading…
`; -http.createServer((req, res) => { +const server = http.createServer((req, res) => { + const json = (o, code) => { res.writeHead(code || 200, { "content-type": "application/json" }); res.end(JSON.stringify(o)); }; + // Only same-origin loopback callers: blocks other sites (and DNS-rebinding + // pages) from driving this server via the browser. + const host = req.headers.host || ""; + if (host !== "127.0.0.1:" + PORT && host !== "localhost:" + PORT) { res.writeHead(403); return res.end("forbidden"); } let u; - try { u = new URL(req.url, URLBASE); } catch (e) { res.writeHead(400); return res.end("bad request"); } - const json = o => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(o)); }; + try { u = new URL(req.url, URLBASE); } catch (e) { return json({ error: "bad request" }, 400); } if (req.method === "POST" && u.pathname === "/api/close") { json({ ok: true }); console.log("Closed from the page. Bye."); @@ -1278,27 +1322,40 @@ http.createServer((req, res) => { } if (req.method === "POST" && (u.pathname === "/api/terminal" || u.pathname === "/api/rc")) { const v = vetted(u); - if (!v) { res.writeHead(400); return res.end("unknown session"); } + if (!v) return json({ error: "Unknown session — Refresh the page and try again." }, 400); if (u.pathname === "/api/terminal") return openTerminal(v.p, v.id, err => json(err ? { error: err } : { ok: true })); - cp.execFile("bash", ["-lc", 'cd "$1" && exec aibox rc-resume "$2"', "aibox-sessions", v.p, v.id], + // id is regex-vetted in vetted(); path is a positional param (no shell + // reinterpretation). AIBOX is the resolved binary, so PATH gaps in the + // spawned shell don't matter. + cp.execFile("bash", ["-lc", 'cd "$1" && exec "$AIBOX" rc-resume "$2"', "aibox-sessions", v.p, v.id], + { env: Object.assign({}, process.env, { AIBOX: AIBOX }) }, (e, out, serr) => { if (e) console.error(String(serr || e.message).trim()); else console.log(String(out).trim()); - json(e ? { error: "rc-resume failed — see the aibox sessions terminal." } : { ok: true }); + json(e ? { error: "Send to phone failed — see the aibox sessions terminal." } : { ok: true }); }); return; } - if (u.pathname === "/api/sessions") return list(l => json(l)); + if (u.pathname === "/api/sessions") return list((l, err) => json({ sessions: l || [], error: err || null })); res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(PAGE); -}).listen(PORT, "127.0.0.1", () => { +}); +server.on("error", e => { + console.error(e.code === "EADDRINUSE" + ? "aibox sessions is already running (or something holds port " + PORT + "). Open " + URLBASE + ", or stop the other one first." + : "aibox sessions server error: " + e.message); + process.exit(1); +}); +server.listen(PORT, "127.0.0.1", () => { console.log("aibox sessions: " + URLBASE + " (Ctrl-C or the Close button to stop)"); const opener = process.platform === "darwin" ? "open" : "xdg-open"; cp.execFile(opener, [URLBASE], () => {}); }); SESSJS - AIBOX_IMAGE="$IMAGE" AIBOX_VOLUME="$VOLUME" AIBOX_CFG="$CONFIG_DIR" \ + local aibox_bin + aibox_bin="$(command -v aibox 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")" + AIBOX_IMAGE="$IMAGE" AIBOX_VOLUME="$VOLUME" AIBOX_CFG="$CONFIG_DIR" AIBOX_BIN="$aibox_bin" \ node "${CONFIG_DIR}/sessions-ui.js" } From d9a4069ccf2552f2793ea96becf1f3a284287681 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:36:40 +0000 Subject: [PATCH 23/26] Sessions page: click a row to expand summary, your last message, full last reply The list alone could not tell you what a session was. Rows are now expandable: the collapsed row keeps title + two-line preview + meta, and clicking it opens Summary (Claude Code's own summary record when the transcript has one), Your last message, and the full Last reply (up to 2000 chars, scrollable). Lister emits the extra fields from the same head/tail windows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- bin/aibox | 51 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/bin/aibox b/bin/aibox index ee8b946..000b591 100755 --- a/bin/aibox +++ b/bin/aibox @@ -1086,17 +1086,27 @@ function info(file, size) { } } const tl = (tail || head).split("\n"); - let tailSummary = ""; + let tailSummary = "", lastUser = ""; for (let i = tl.length - 1; i >= 0; i--) { const j = parseLine(tl[i]); if (!j) continue; if (!tailSummary && j.type === "summary" && j.summary) tailSummary = j.summary; if (!last && j.type === "assistant" && j.message) { const t = msgText(j.message); - if (t) last = t.slice(0, 160); + if (t) last = t.slice(0, 2000); } - if (last && tailSummary) break; + if (!lastUser && j.type === "user" && j.message && !j.isMeta) { + const t = msgText(j.message); + if (t && !t.startsWith("Caveat:") && !t.startsWith("{ const active=isActive(s); const row=el("div","row"); - row.appendChild(el("p","t",s.title)); - if(s.last){const le=el("p","last");le.innerHTML="Last reply: ";le.appendChild(document.createTextNode(s.last));row.appendChild(le)} + // Clickable header: expands the row to the summary + full last reply. + const head=el("div","head"); + head.appendChild(el("span","chev","▶")); + const hmain=el("div","hmain"); + hmain.appendChild(el("p","t",s.title)); + if(s.last)hmain.appendChild(el("p","last",s.last)); const meta=el("div","meta"); meta.appendChild(el("span","chip",s.label.split("/").filter(Boolean).pop()||s.label)); if(active)meta.appendChild(el("span","live","● active now")); meta.appendChild(el("span",null,ago(s.mtime))); meta.appendChild(el("span","path","· "+s.label)); - row.appendChild(meta); + hmain.appendChild(meta); + head.appendChild(hmain); + row.appendChild(head); + const detail=el("div","detail"); + const section=(label,text)=>{const d=el("div");d.appendChild(el("p","dl",label));d.appendChild(el("p","dt",text));detail.appendChild(d)}; + if(s.summary)section("Summary",s.summary); + if(s.lastUser)section("Your last message",s.lastUser); + if(s.last)section("Last reply",s.last); + if(!s.summary&&!s.last&&!s.lastUser)section("Details","Nothing readable in this transcript yet."); + row.appendChild(detail); + head.onclick=()=>row.classList.toggle("open"); const acts=el("div","acts"); const resume=(fn,warnMsg)=>()=>{ if(active && !confirm(warnMsg)) return; fn(); }; if(s.project){ From 1857cc7a4a72bd5c9e7d98183e799420eb5976a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:42:52 +0000 Subject: [PATCH 24/26] =?UTF-8?q?Sessions=20page:=20search=20=E2=80=94=20i?= =?UTF-8?q?nstant=20filter=20plus=20Enter=20for=20full-transcript=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search box filters the list as you type over titles, summaries, previews, paths, and ids. Pressing Enter runs a deep search: the lister re-runs with AIBOX_SEARCH (env through argv, no shell) and scans entire transcript files chunk-wise, case-insensitive, with chunk-boundary overlap — so a phrase you remember from mid-conversation finds the session even when no preview shows it. Clearing the box restores the full list. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XoXWywkXvRMxamQ7RjNAYq --- bin/aibox | 73 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/bin/aibox b/bin/aibox index 000b591..0cdbf4b 100755 --- a/bin/aibox +++ b/bin/aibox @@ -1054,6 +1054,26 @@ cmd_sessions() { // as JSON on stdout. const fs = require("fs"), path = require("path"); const ROOT = process.env.AIBOX_LIST_ROOT || "/v"; +// Deep search: with AIBOX_SEARCH set, only sessions whose metadata OR full +// transcript contains the (case-insensitive) query are emitted. +const Q = (process.env.AIBOX_SEARCH || "").toLowerCase().slice(0, 200); +function fileHas(p, q) { + try { + const fd = fs.openSync(p, "r"), CH = 4 * 1024 * 1024, b = Buffer.alloc(CH); + let pos = 0, carry = ""; + for (;;) { + const n = fs.readSync(fd, b, 0, CH, pos); + if (n <= 0) break; + const s = carry + b.slice(0, n).toString("utf8"); + if (s.toLowerCase().includes(q)) { fs.closeSync(fd); return true; } + carry = s.slice(-q.length); // query straddling a chunk boundary + pos += n; + if (n < CH) break; + } + fs.closeSync(fd); + } catch (e) {} + return false; +} function msgText(m) { const c = m.content; const t = typeof c === "string" ? c : Array.isArray(c) ? c.map(x => x.text || "").join(" ") : ""; @@ -1115,6 +1135,10 @@ function addDir(dir, ppath, label) { if (!f.endsWith(".jsonl")) continue; try { const p = path.join(dir, f), st = fs.statSync(p), inf = info(p, st.size); + if (Q) { + const meta = (inf.title + " " + inf.summary + " " + inf.last + " " + inf.lastUser + " " + label + " " + f).toLowerCase(); + if (!meta.includes(Q) && !fileHas(p, Q)) continue; + } out.push({ project: ppath, label: label, id: f.slice(0, -6), mtime: st.mtimeMs, title: inf.title, summary: inf.summary, last: inf.last, lastUser: inf.lastUser }); } catch (e) {} @@ -1156,14 +1180,17 @@ const http = require("http"), fs = require("fs"), cp = require("child_process"); const PORT = 45790, URLBASE = "http://127.0.0.1:" + PORT; const IMAGE = process.env.AIBOX_IMAGE, VOLUME = process.env.AIBOX_VOLUME, CFG = process.env.AIBOX_CFG; let known = new Set(); // "path\nid" pairs from the last listing — action guards -function list(cb) { +function list(cb, q) { // --entrypoint bypasses the aibox image entrypoint, which would otherwise // try to install claude into the (empty) image-local home before running - // the lister — minutes of hang on every listing. - cp.execFile("docker", ["run", "--rm", "--entrypoint", "node", + // the lister — minutes of hang on every listing. q (deep search) travels + // as an env var: argv only, no shell anywhere. + const args = ["run", "--rm", "--entrypoint", "node", "-v", VOLUME + ":/v:ro", - "-v", CFG + "/sessions-lister.js:/lister.js:ro", - IMAGE, "/lister.js"], { maxBuffer: 64 * 1024 * 1024 }, (e, out) => { + "-v", CFG + "/sessions-lister.js:/lister.js:ro"]; + if (q) args.push("-e", "AIBOX_SEARCH=" + q); + args.push(IMAGE, "/lister.js"); + cp.execFile("docker", args, { maxBuffer: 64 * 1024 * 1024 }, (e, out) => { if (e) { known = new Set(); return cb(null, "Could not read sessions (is docker running?). Details in the aibox sessions terminal."), console.error(e.message); } let l = []; try { l = JSON.parse(out); } catch (err) { known = new Set(); return cb(null, "Could not parse the session list."); } @@ -1219,7 +1246,11 @@ h1{margin:0;font-size:28px;font-weight:700;letter-spacing:-.02em} .tools{display:flex;gap:8px} .tools button{font-family:inherit;font-size:13px;font-weight:600;border:none;border-radius:999px;padding:7px 14px;cursor:pointer;background:var(--accent-soft);color:var(--accent)} .tools button:hover{background:var(--accent);color:var(--accent-ink)} -.sub{font-size:14px;color:var(--muted);margin:4px 0 24px} +.sub{font-size:14px;color:var(--muted);margin:4px 0 16px} +.search{margin:0 0 18px} +.search input{width:100%;font-family:inherit;font-size:14px;padding:10px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);outline:none} +.search input:focus{border-color:var(--accent)} +.search input::placeholder{color:var(--muted)} .banner{background:#FBEDE3;color:#8A4B24;border-radius:10px;padding:12px 16px;font-size:13.5px;margin:0 0 20px} @media(prefers-color-scheme:dark){.banner{background:#3A2517;color:#E7B48C}} .card{background:var(--surface);border-radius:14px;box-shadow:0 1px 2px rgba(30,40,50,.05),0 4px 16px rgba(30,40,50,.05);overflow:hidden} @@ -1256,6 +1287,8 @@ h1{margin:0;font-size:28px;font-weight:700;letter-spacing:-.02em}

Sessions

Loading…

+
Loading…