From a49af7c5f51e3b857f6d72180299210445f85994 Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Sat, 30 May 2026 23:18:33 +0100 Subject: [PATCH 01/11] feat(doctor): Linux host support + Hetzner dev-VM manager Make `agentbox doctor` Linux-accurate and add tooling to test the CLI on a real Ubuntu host. doctor (apps/cli/src/lib/doctor-checks.ts): - checkPlatform() warns on unsupported OS (darwin/linux -> ok, else warn) - docker-cli not-found hint is platform-specific (Linux docs link) - docker daemon check distinguishes 'permission denied' (user not in the docker group) from a stopped daemon, with the matching fix as a hint tooling: - scripts/linux-dev-vm.sh: manage a persistent Hetzner Ubuntu VM (up/deploy/ssh/doctor/info/down) to test agentbox on Linux - docs/linux-host-backlog.md: status + how-to + remaining macOS-only host assumptions (browser open->xdg-open, iTerm2/AppleScript, OrbStack paths) Verified live on a clean Ubuntu 24.04 Hetzner VM: both the permission-denied and healthy docker-daemon branches render correctly; no macOS regression. --- CLAUDE.md | 1 + apps/cli/src/lib/doctor-checks.ts | 32 ++++- docs/linux-host-backlog.md | 100 +++++++++++++++ scripts/linux-dev-vm.sh | 198 ++++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 docs/linux-host-backlog.md create mode 100755 scripts/linux-dev-vm.sh diff --git a/CLAUDE.md b/CLAUDE.md index 210e195..f06ac32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,3 +75,4 @@ Each topic has a dedicated file under [`docs/`](./docs). Read the relevant one b - [`docs/daytona-backlog.md`](./docs/daytona-backlog.md) — what's done vs still missing on the Daytona path. Quick index of where each cloud feature actually lives. - [`docs/hertzner_backlog.md`](./docs/hertzner_backlog.md) — Hetzner provider build-out status: phase-by-phase progress, the live e2e smoke results, deferred follow-ups (per-project snapshot tier, `--pause` checkpoint flag, `agentbox prune --provider hetzner`, the install-script post-Chromium trace mystery). Filename uses the user-requested spelling. - [`docs/vercel-backlog.md`](./docs/vercel-backlog.md) — Vercel provider build-out status: why Vercel's shape differs (no Dockerfile, no containers, no SSH, persistent snapshots), phase-by-phase progress, and the live-verify checklist (user mapping, attach latency / ttyd upgrade, snapshot-vs-delete cascade, VNC on AL2023, published-CLI asset staging). +- [`docs/linux-host-backlog.md`](./docs/linux-host-backlog.md) — Linux (Ubuntu) **host** support: what's done (`agentbox doctor` is Linux-aware), how to test on a persistent Hetzner Ubuntu VM (`scripts/linux-dev-vm.sh` — `up`/`deploy`/`ssh`/`doctor`/`down`), and the remaining macOS-only host assumptions (browser `open`→`xdg-open`, iTerm2/AppleScript terminal spawning, OrbStack-only fast paths). diff --git a/apps/cli/src/lib/doctor-checks.ts b/apps/cli/src/lib/doctor-checks.ts index a57c0b0..1d32f7b 100644 --- a/apps/cli/src/lib/doctor-checks.ts +++ b/apps/cli/src/lib/doctor-checks.ts @@ -71,7 +71,13 @@ function checkNode(): CheckResult { } function checkPlatform(): CheckResult { - return { label: 'platform', status: 'ok', detail: `${process.platform}/${process.arch}` }; + const supported = process.platform === 'darwin' || process.platform === 'linux'; + return { + label: 'platform', + status: supported ? 'ok' : 'warn', + detail: `${process.platform}/${process.arch}`, + hint: supported ? undefined : 'agentbox supports macOS and Linux hosts; this OS is untested', + }; } function checkAgentboxHome(): CheckResult { @@ -121,6 +127,7 @@ export async function runSystemChecks(): Promise { } async function dockerChecks(): Promise { + const linux = process.platform === 'linux'; const cli = await probeVersion('docker'); if (!cli) { return [ @@ -128,23 +135,38 @@ async function dockerChecks(): Promise { label: 'docker cli', status: 'warn', detail: 'not found', - hint: 'install Docker Desktop, OrbStack, or docker engine', + hint: linux + ? 'install docker engine: https://docs.docker.com/engine/install/' + : 'install Docker Desktop, OrbStack, or docker engine', }, ]; } const cliRes: CheckResult = { label: 'docker cli', status: 'ok', detail: cli }; // Daemon reachability via `docker info` (same probe pattern as - // packages/sandbox-docker/src/docker.ts:dockerInfo). + // packages/sandbox-docker/src/docker.ts:dockerInfo). On Linux the most common + // failure is not a stopped daemon but the user missing from the `docker` + // group — `docker info` then exits non-zero with "permission denied" on the + // socket. Distinguish the two so the hint points at the right fix. const info = await execa('docker', ['info'], { reject: false }); if (info.exitCode !== 0) { + const permDenied = `${info.stderr ?? ''}`.toLowerCase().includes('permission denied'); + let hint: string; + if (permDenied && linux) { + hint = + 'add your user to the docker group: `sudo usermod -aG docker $USER`, then log out/in (or run `newgrp docker`)'; + } else if (linux) { + hint = 'start Docker: `sudo systemctl start docker` (install docker engine if missing)'; + } else { + hint = 'start Docker (Desktop / OrbStack)'; + } return [ cliRes, { label: 'docker daemon', status: 'warn', - detail: 'unreachable', - hint: 'start Docker (Desktop / OrbStack / `systemctl start docker`)', + detail: permDenied ? 'permission denied' : 'unreachable', + hint, }, ]; } diff --git a/docs/linux-host-backlog.md b/docs/linux-host-backlog.md new file mode 100644 index 0000000..f28214a --- /dev/null +++ b/docs/linux-host-backlog.md @@ -0,0 +1,100 @@ +# Linux host support — backlog + +AgentBox's CLI grew up assuming a **macOS host**. We now want it to also run on a +**Linux host (primarily Ubuntu)** — i.e. a developer driving `agentbox` from a Linux +laptop/server, spinning up docker/cloud boxes from there. This file tracks what +already works, what's been fixed, and the remaining macOS-only host assumptions. + +> Scope note: this is about the **host** running the CLI. The *boxes* (docker +> images, cloud VMs) have always been Linux — that part is unaffected. + +## Done + +- **`agentbox doctor` is Linux-aware** (`apps/cli/src/lib/doctor-checks.ts`): + - `checkPlatform()` returns `ok` for `darwin`/`linux`, `warn` for any other OS + (Windows etc.) with an "untested OS" hint — instead of blindly reporting `ok`. + - The docker-cli "not found" hint is platform-specific (Linux points at + `https://docs.docker.com/engine/install/`). + - The docker **daemon** check now distinguishes the #1 Linux failure — `docker + info` exiting with *permission denied* because the user isn't in the `docker` + group — from a genuinely stopped daemon, and emits the right fix + (`sudo usermod -aG docker $USER` vs `sudo systemctl start docker`). + - Verified live on a clean Ubuntu 24.04 Hetzner VM (see below): both the + permission-denied branch and the healthy `reachable` path render correctly, + and the daytona/hetzner/vercel credential checks run without crashing. + +## How to test on Linux + +`scripts/linux-dev-vm.sh` manages a **persistent** clean Ubuntu VM on Hetzner +(`cx23` / `nbg1` / `ubuntu-24.04` — the repo's hetzner defaults; cloud-init +installs Node 20 + docker + git + tmux and a non-root `dev` user in the docker +group with passwordless sudo). It is a bare VPS you log into and drive the CLI on +— **not** an agentbox box. State (server id / ip / key) lives in +`~/.agentbox/linux-dev-vm/`, so the VM survives across edit→deploy→test cycles +until you explicitly `down` it. + +```bash +scripts/linux-dev-vm.sh up # create (idempotent — reuses a live VM) +scripts/linux-dev-vm.sh deploy # build + npm pack + install -g the latest CLI +scripts/linux-dev-vm.sh deploy --no-build # reuse an existing dist/ +scripts/linux-dev-vm.sh ssh # interactive shell as `dev` +scripts/linux-dev-vm.sh ssh -- agentbox ls # run a one-off command +scripts/linux-dev-vm.sh doctor # two-phase doctor (perm-denied + healthy) +scripts/linux-dev-vm.sh info # server id / ip / ssh command +scripts/linux-dev-vm.sh down # destroy server + key + local state +``` + +`HCLOUD_TOKEN` is read from the env, then `.env.local`, then +`~/.agentbox/secrets.env`. + +Notes learned the hard way: +- Run the CLI via a **login shell** (`su - -c …` / `ssh dev@…`), not + `sudo -u ` — the latter hands the node process a reduced PATH where + `/usr/bin` tools (git/ssh/docker) and the npm global bin aren't all resolvable, + so `doctor` falsely reports them "not found". +- The `doctor` subcommand creates a throwaway `probe` user (no docker group) to + exercise the *permission denied* daemon branch, then runs as `dev` (in the + group) for the healthy path. + +Manual recipe (any Ubuntu host, no script): +```bash +# on the host +pnpm -w build && (cd apps/cli && npm pack) # -> madarco-agentbox-*.tgz +scp madarco-agentbox-*.tgz user@host:~ +# on the Ubuntu box +sudo npm install -g ./madarco-agentbox-*.tgz +agentbox doctor # inspect the report +``` + +## Open blockers (not yet done — host code still macOS-only) + +These were found while scoping the doctor change. None are needed for `doctor` +itself; they block the wider "drive everything from Linux" goal. + +- **Browser open uses the macOS `open` command** — needs `xdg-open` (or `$BROWSER`) + on Linux: + - `apps/cli/src/commands/url.ts:122` + - `apps/cli/src/commands/screen.ts:147` + - `apps/cli/src/commands/code.ts:313` +- **Host terminal spawning is iTerm2/AppleScript-only.** `detectHostTerminal()` + only recognizes tmux + iTerm2; `spawnInITerm2()` shells out to `osascript` + (`apps/cli/src/terminal/host.ts`, see the "macOS-only by design" comment near the + top and `spawnInITerm2` ~L112-171). Linux needs tmux (already works) and/or a + native terminal-emulator launch path (gnome-terminal/konsole/alacritty/`$TERMINAL`). +- **OrbStack-only fast paths assume macOS** and should be skipped on Linux (OrbStack + is macOS-only; on Linux the docker socket / volume paths differ): + - `packages/sandbox-docker/src/host-export.ts` (`orbstackVolumePath`, ~L138) + - `packages/sandbox-docker/src/stats.ts:77` +- **Docs / CLAUDE.md still describe the CLI as macOS-oriented** in places — update + the relevant statements once broader support lands. + +## Already cross-platform (verified — no work needed) + +- **Clipboard capture** (`apps/cli/src/lib/host-clipboard.ts`) already has a Linux + path (`wl-paste` for Wayland, `xclip` for X11); the macOS `sips`/`osascript` path + is gated behind `process.platform === 'darwin'`. +- **Vercel CLI store** (`packages/sandbox-vercel/src/cli-store.ts`) resolves + `$XDG_DATA_HOME` / `~/.local/share` on Linux. +- **Snapshot copy** (`packages/sandbox-docker/src/snapshot.ts:105`) already branches + `-cR` (macOS APFS CoW) vs `-R` (Linux). +- **State/config paths** (`~/.agentbox`, `~/.ssh/config`) are all `homedir()`-based. diff --git a/scripts/linux-dev-vm.sh b/scripts/linux-dev-vm.sh new file mode 100755 index 0000000..1e4588a --- /dev/null +++ b/scripts/linux-dev-vm.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# +# linux-dev-vm.sh — manage a *persistent* clean Ubuntu VM on Hetzner for testing +# `agentbox` on a Linux host. NOT an agentbox box: a bare VPS you log into and +# drive the CLI on to check Linux compatibility of any feature. +# +# WHY this exists: the CLI grew up macOS-only (see docs/linux-host-backlog.md). +# To find and fix Linux-host issues we need a real Ubuntu host that survives +# across edit/deploy/test cycles. This script provisions one, ships the locally +# built CLI on demand, and tears it down when you're done. +# +# Subcommands: +# up create the VM if absent (cx23 / nbg1 / ubuntu-24.04), install +# node 20 + docker + git + tmux, create a non-root `dev` user +# (docker + passwordless sudo). Idempotent: reuses a live VM. +# deploy build the monorepo, npm pack apps/cli, scp + `npm install -g` it +# on the VM. Re-run after local changes. Use --no-build to skip build. +# ssh open an interactive shell as `dev` (or run: `linux-dev-vm.sh ssh -- uptime`) +# doctor run `agentbox doctor --provider docker` twice — as a user NOT in +# the docker group (permission-denied branch) and as `dev` (healthy). +# info print server id / ip / ssh command / state file +# down destroy the server + SSH key + local state +# +# State (server id, ip, key) persists in ~/.agentbox/linux-dev-vm/ so every +# subcommand targets the same VM. `down` is the only thing that deletes it. +# +# HCLOUD_TOKEN is read from env, else .env.local, else ~/.agentbox/secrets.env. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +API="https://api.hetzner.cloud/v1" +STATE_DIR="$HOME/.agentbox/linux-dev-vm" +STATE_FILE="$STATE_DIR/state.json" +KEY="$STATE_DIR/id_ed25519" +NAME="agentbox-linux-dev" +SERVER_TYPE="cx23" +LOCATION="nbg1" +IMAGE="ubuntu-24.04" +SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=8 -o LogLevel=ERROR) + +die() { echo "error: $*" >&2; exit 1; } + +# --- token ------------------------------------------------------------------- +resolve_token() { + if [[ -z "${HCLOUD_TOKEN:-}" && -f "$REPO_ROOT/.env.local" ]]; then + HCLOUD_TOKEN="$(grep -E '^HCLOUD_TOKEN=' "$REPO_ROOT/.env.local" | head -1 | cut -d= -f2- | tr -d '"' || true)" + fi + if [[ -z "${HCLOUD_TOKEN:-}" && -f "$HOME/.agentbox/secrets.env" ]]; then + HCLOUD_TOKEN="$(grep -E '^HCLOUD_TOKEN=' "$HOME/.agentbox/secrets.env" | head -1 | cut -d= -f2- | tr -d '"' || true)" + fi + [[ -n "${HCLOUD_TOKEN:-}" ]] || die "HCLOUD_TOKEN not found (env / .env.local / ~/.agentbox/secrets.env)" +} + +# Tiny JSON field extractor (node is always present in this repo; jq is not). +jget() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);const p=process.argv[1].split(".");let v=o;for(const k of p)v=(v==null?undefined:v[k]);process.stdout.write(v==null?"":String(v));})' "$1"; } + +api() { + # api [json-body] + local method="$1" path="$2" body="${3:-}" + if [[ -n "$body" ]]; then + curl -fsS -X "$method" -H "Authorization: Bearer $HCLOUD_TOKEN" -H "Content-Type: application/json" -d "$body" "$API$path" + else + curl -fsS -X "$method" -H "Authorization: Bearer $HCLOUD_TOKEN" "$API$path" + fi +} + +state_get() { [[ -f "$STATE_FILE" ]] && jget "$1" < "$STATE_FILE" || true; } + +require_vm() { + [[ -f "$STATE_FILE" ]] || die "no VM — run \`$0 up\` first" + IP="$(state_get ip)" + [[ -n "$IP" ]] || die "state file has no ip; \`$0 down\` and re-\`up\`" +} + +ssh_dev() { ssh -i "$KEY" "${SSH_OPTS[@]}" "dev@$IP" "$@"; } +ssh_root() { ssh -i "$KEY" "${SSH_OPTS[@]}" "root@$IP" "$@"; } + +# --- cloud-init: node 20 + docker + git + tmux, non-root `dev` user ---------- +cloud_init() { +cat <<'EOF' +#cloud-config +package_update: true +runcmd: + - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + - apt-get install -y nodejs docker.io git tmux ca-certificates + - systemctl enable --now docker + - id dev >/dev/null 2>&1 || useradd -m -s /bin/bash dev + - usermod -aG docker dev + - echo 'dev ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/90-dev + - install -d -m 700 -o dev -g dev /home/dev/.ssh + - cp /root/.ssh/authorized_keys /home/dev/.ssh/authorized_keys + - chown dev:dev /home/dev/.ssh/authorized_keys + - chmod 600 /home/dev/.ssh/authorized_keys + - touch /var/lib/cloud/agentbox-ready +EOF +} + +cmd_up() { + resolve_token + mkdir -p "$STATE_DIR" + local sid; sid="$(state_get server_id)" + if [[ -n "$sid" ]] && api GET "/servers/$sid" >/dev/null 2>&1; then + echo ">> VM already up (server_id=$sid)"; cmd_info; return 0 + fi + + [[ -f "$KEY" ]] || ssh-keygen -t ed25519 -N "" -C "$NAME" -f "$KEY" >/dev/null + local pub; pub="$(cat "$KEY.pub")" + local suffix; suffix="$(date +%s)" + echo ">> uploading ssh key" + local key_resp key_id + key_resp="$(api POST /ssh_keys "$(node -e 'console.log(JSON.stringify({name:process.argv[1],public_key:process.argv[2]}))' "$NAME-$suffix" "$pub")")" + key_id="$(printf '%s' "$key_resp" | jget ssh_key.id)" + + echo ">> creating $SERVER_TYPE/$LOCATION $IMAGE server '$NAME'" + local body create ip server_id + body="$(node -e 'console.log(JSON.stringify({name:process.argv[1],server_type:process.argv[2],location:process.argv[3],image:process.argv[4],ssh_keys:[Number(process.argv[5])],user_data:process.argv[6],public_net:{enable_ipv4:true,enable_ipv6:false}}))' "$NAME" "$SERVER_TYPE" "$LOCATION" "$IMAGE" "$key_id" "$(cloud_init)")" + create="$(api POST /servers "$body")" + server_id="$(printf '%s' "$create" | jget server.id)" + ip="$(printf '%s' "$create" | jget server.public_net.ipv4.ip)" + node -e 'const fs=require("fs");fs.writeFileSync(process.argv[1],JSON.stringify({server_id:Number(process.argv[2]),ip:process.argv[3],ssh_key_id:Number(process.argv[4])},null,2)+"\n")' "$STATE_FILE" "$server_id" "$ip" "$key_id" + echo " server_id=$server_id ip=$ip" + + echo ">> waiting for SSH" + for i in $(seq 1 60); do + if ssh -i "$KEY" "${SSH_OPTS[@]}" "root@$ip" true 2>/dev/null; then break; fi + sleep 5; [[ "$i" == "60" ]] && die "ssh never came up" + done + echo ">> waiting for cloud-init (node + docker + dev user)" + ssh -i "$KEY" "${SSH_OPTS[@]}" "root@$ip" 'cloud-init status --wait >/dev/null 2>&1 || true; until [ -f /var/lib/cloud/agentbox-ready ]; do sleep 3; done' + echo ">> ready:" + ssh -i "$KEY" "${SSH_OPTS[@]}" "root@$ip" 'echo " node=$(node -v) docker=$(docker --version) git=$(git --version)"' + cmd_info +} + +cmd_deploy() { + require_vm + local build=1 + for a in "$@"; do [[ "$a" == "--no-build" ]] && build=0; done + if [[ "$build" == "1" ]]; then echo ">> pnpm -w build"; (cd "$REPO_ROOT" && pnpm -w build); fi + local tmp tarball + tmp="$(mktemp -d)" + echo ">> npm pack apps/cli" + tarball="$(cd "$REPO_ROOT/apps/cli" && npm pack --silent --pack-destination "$tmp")" + echo ">> scp + npm install -g on the VM" + scp -i "$KEY" "${SSH_OPTS[@]}" "$tmp/$tarball" "dev@$IP:/home/dev/$tarball" + ssh_dev "sudo npm install -g --no-fund --no-audit /home/dev/$tarball && echo \"installed agentbox \$(agentbox --version)\"" + rm -rf "$tmp" +} + +cmd_ssh() { + require_vm + # `linux-dev-vm.sh ssh` -> interactive shell + # `linux-dev-vm.sh ssh -- ` -> run a command + if [[ "${1:-}" == "--" ]]; then shift; ssh_dev "$@"; else ssh_dev "$@"; fi +} + +cmd_doctor() { + require_vm + ssh_dev 'command -v agentbox >/dev/null' || die "agentbox not installed on the VM — run: $0 deploy" + # A user NOT in the docker group exercises the permission-denied branch. + ssh_root 'id probe >/dev/null 2>&1 || { useradd -m -s /bin/bash probe; install -d -m 700 -o probe -g probe /home/probe/.ssh; }' + echo "" + echo "============ doctor #1: user NOT in docker group (permission denied) ============" + ssh_root 'su - probe -c "NO_COLOR=1 agentbox doctor --provider docker" || true' + echo "" + echo "============ doctor #2: dev user IN docker group (healthy) ============" + ssh_dev 'sg docker -c "NO_COLOR=1 agentbox doctor --provider docker" || true' +} + +cmd_info() { + [[ -f "$STATE_FILE" ]] || { echo "no VM (state file absent)"; return 0; } + IP="$(state_get ip)" + echo "server_id : $(state_get server_id)" + echo "ip : $IP" + echo "ssh : ssh -i $KEY dev@$IP" + echo "state : $STATE_FILE" +} + +cmd_down() { + resolve_token + [[ -f "$STATE_FILE" ]] || { echo "no VM to delete"; return 0; } + local sid kid; sid="$(state_get server_id)"; kid="$(state_get ssh_key_id)" + echo ">> deleting server $sid + ssh key $kid" + [[ -n "$sid" ]] && api DELETE "/servers/$sid" >/dev/null 2>&1 || true + [[ -n "$kid" ]] && api DELETE "/ssh_keys/$kid" >/dev/null 2>&1 || true + rm -rf "$STATE_DIR" + echo ">> done" +} + +case "${1:-}" in + up) shift; cmd_up "$@" ;; + deploy) shift; cmd_deploy "$@" ;; + ssh) shift; cmd_ssh "$@" ;; + doctor) shift; cmd_doctor "$@" ;; + info) shift; cmd_info "$@" ;; + down) shift; cmd_down "$@" ;; + *) echo "usage: $0 {up|deploy|ssh|doctor|info|down} [args]" >&2; exit 2 ;; +esac From 38e75f12564dc865c7c837306d8b68ceacd6f5c0 Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Sat, 30 May 2026 23:30:52 +0100 Subject: [PATCH 02/11] feat(linux): host-open helper + xdg-open for cloud login dashboards Add hostOpenCommand() to @agentbox/sandbox-core (darwin -> open, linux -> xdg-open) so opening a URL/path works on a Linux host, with a unit test. Use it in the daytona/vercel/hetzner login dashboard openers, which were hardcoded to the macOS `open` command (a no-op/error on Linux). First step of the docs/linux-host-backlog.md 'browser open' item. --- packages/sandbox-core/src/host-open.ts | 13 ++++++++++ packages/sandbox-core/src/index.ts | 1 + packages/sandbox-core/test/host-open.test.ts | 25 ++++++++++++++++++++ packages/sandbox-daytona/src/credentials.ts | 3 ++- packages/sandbox-hetzner/src/credentials.ts | 3 ++- packages/sandbox-vercel/src/credentials.ts | 3 ++- 6 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 packages/sandbox-core/src/host-open.ts create mode 100644 packages/sandbox-core/test/host-open.test.ts diff --git a/packages/sandbox-core/src/host-open.ts b/packages/sandbox-core/src/host-open.ts new file mode 100644 index 0000000..6250686 --- /dev/null +++ b/packages/sandbox-core/src/host-open.ts @@ -0,0 +1,13 @@ +/** + * The host command that opens a URL or file path in the OS default handler. + * + * macOS ships `open`; Linux uses `xdg-open` (from `xdg-utils`, present on any + * desktop install). We deliberately return only the binary name and let each + * call site keep its own spawn semantics (sync/async, stdio, detached) — the + * single platform decision lives here so adding a host platform is a one-line + * change. Callers already treat a non-zero exit / ENOENT as "couldn't + * auto-open" and print the target, so an absent `xdg-open` degrades cleanly. + */ +export function hostOpenCommand(): string { + return process.platform === 'linux' ? 'xdg-open' : 'open'; +} diff --git a/packages/sandbox-core/src/index.ts b/packages/sandbox-core/src/index.ts index 144234a..528589e 100644 --- a/packages/sandbox-core/src/index.ts +++ b/packages/sandbox-core/src/index.ts @@ -16,6 +16,7 @@ export { pickFreshBranch, type DetectedGitRepo, } from './git-detect.js'; +export { hostOpenCommand } from './host-open.js'; export { computeContextSha256, DOCKER_CONTEXT_FILE_MAP, diff --git a/packages/sandbox-core/test/host-open.test.ts b/packages/sandbox-core/test/host-open.test.ts new file mode 100644 index 0000000..f05bdb9 --- /dev/null +++ b/packages/sandbox-core/test/host-open.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { hostOpenCommand } from '../src/host-open.js'; + +describe('hostOpenCommand', () => { + const original = process.platform; + const setPlatform = (value: NodeJS.Platform) => + Object.defineProperty(process, 'platform', { value, configurable: true }); + + afterEach(() => setPlatform(original)); + + it('uses xdg-open on Linux', () => { + setPlatform('linux'); + expect(hostOpenCommand()).toBe('xdg-open'); + }); + + it('uses open on macOS', () => { + setPlatform('darwin'); + expect(hostOpenCommand()).toBe('open'); + }); + + it('falls back to open on other platforms', () => { + setPlatform('win32'); + expect(hostOpenCommand()).toBe('open'); + }); +}); diff --git a/packages/sandbox-daytona/src/credentials.ts b/packages/sandbox-daytona/src/credentials.ts index bde2770..9b860c5 100644 --- a/packages/sandbox-daytona/src/credentials.ts +++ b/packages/sandbox-daytona/src/credentials.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { chmodSync, existsSync, @@ -248,7 +249,7 @@ function persistCredentials(creds: Credentials): void { function openDashboard(): void { try { - const r = spawnSync('open', [DASHBOARD_KEYS_URL], { stdio: 'ignore' }); + const r = spawnSync(hostOpenCommand(), [DASHBOARD_KEYS_URL], { stdio: 'ignore' }); if (r.status !== 0) { log.warn(`Could not auto-open the browser — visit ${DASHBOARD_KEYS_URL} manually.`); } diff --git a/packages/sandbox-hetzner/src/credentials.ts b/packages/sandbox-hetzner/src/credentials.ts index 04e42f9..717251f 100644 --- a/packages/sandbox-hetzner/src/credentials.ts +++ b/packages/sandbox-hetzner/src/credentials.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { chmodSync, existsSync, @@ -195,7 +196,7 @@ function persistCredentials(creds: Credentials): void { function openDashboard(): void { try { - const r = spawnSync('open', [DASHBOARD_KEYS_URL], { stdio: 'ignore' }); + const r = spawnSync(hostOpenCommand(), [DASHBOARD_KEYS_URL], { stdio: 'ignore' }); if (r.status !== 0) { log.warn(`Could not auto-open the browser — visit ${DASHBOARD_KEYS_URL} manually.`); } diff --git a/packages/sandbox-vercel/src/credentials.ts b/packages/sandbox-vercel/src/credentials.ts index 7125f78..a0c74f5 100644 --- a/packages/sandbox-vercel/src/credentials.ts +++ b/packages/sandbox-vercel/src/credentials.ts @@ -8,6 +8,7 @@ import { } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, resolve } from 'node:path'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { confirm, isCancel, @@ -389,7 +390,7 @@ function openDashboard(): void { // Lazy import keeps node:child_process out of the module's load cost. import('node:child_process') .then(({ spawnSync }) => { - const r = spawnSync('open', [DASHBOARD_TOKENS_URL], { stdio: 'ignore' }); + const r = spawnSync(hostOpenCommand(), [DASHBOARD_TOKENS_URL], { stdio: 'ignore' }); if (r.status !== 0) { log.warn(`Could not auto-open the browser — visit ${DASHBOARD_TOKENS_URL} manually.`); } From 63cb1f53066fb13c977838e3edbba29631c61656 Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Sat, 30 May 2026 23:55:44 +0100 Subject: [PATCH 03/11] feat(linux): route all host URL/file opens through hostOpenCommand Swap the remaining hardcoded macOS `open` launchers to hostOpenCommand() (open on macOS, xdg-open on Linux) so they work on a Linux host: - apps/cli: url, screen, code (CLI-missing fallback), open (sshfs reveal), dashboard (VNC/web/code openers) - relay: box-initiated 'open link on host' (host-actions.ts, server.ts) - sandbox-docker: checkpoint/export reveal (host-export.ts) Completes the docs/linux-host-backlog.md 'browser open' item (moved to Done). --- apps/cli/src/commands/code.ts | 5 +++-- apps/cli/src/commands/dashboard.ts | 8 ++++---- apps/cli/src/commands/open.ts | 8 +++++--- apps/cli/src/commands/screen.ts | 3 ++- apps/cli/src/commands/url.ts | 3 ++- docs/linux-host-backlog.md | 18 +++++++++++++----- packages/relay/src/host-actions.ts | 10 +++++----- packages/relay/src/server.ts | 3 ++- packages/sandbox-docker/src/host-export.ts | 3 ++- 9 files changed, 38 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/commands/code.ts b/apps/cli/src/commands/code.ts index eb9c7e2..4150e70 100644 --- a/apps/cli/src/commands/code.ts +++ b/apps/cli/src/commands/code.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { log } from '@clack/prompts'; import { Command, InvalidArgumentError } from 'commander'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import type { BoxRecord } from '@agentbox/core'; import type { StatusReply, WaitReadyReply } from '@agentbox/ctl'; import { loadEffectiveConfig, type IdeFlavor as ConfigIdeFlavor, type UserConfig } from '@agentbox/config'; @@ -307,10 +308,10 @@ async function launchOne(flavor: IdeFlavor, folderUri: string): Promise b.id === boxId); if (!box) return 'box not found'; const { url } = webTarget(box); - detach('open', [url]); + detach(hostOpenCommand(), [url]); return `Opening ${url.replace(/^https?:\/\//, '')}…`; }; diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index 6d9798b..d37730e 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import type { BoxRecord } from '@agentbox/core'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { openBoxInFinder } from '@agentbox/sandbox-docker'; import { Command } from 'commander'; import { resolveBoxOrExit } from '../box-ref.js'; @@ -156,9 +157,10 @@ async function runCloudOpen( if (mount.exitCode !== 0) { throw new Error(`sshfs mount failed (exit ${String(mount.exitCode)}): ${mount.stderr || mount.stdout}`); } - // `open` on macOS reveals the dir in Finder. On non-macOS this is a no-op - // / error — degrade silently because the mount path is already printed. - await execa('open', [mountRoot], { reject: false }); + // Reveal the mount in the OS file manager (Finder on macOS, the default + // handler via xdg-open on Linux). Best-effort — the mount path is already + // printed, so a missing opener degrades silently. + await execa(hostOpenCommand(), [mountRoot], { reject: false }); process.stdout.write(`opened ${mountRoot}\n`); process.stdout.write(`unmount later with: agentbox open ${box.name} --unmount\n`); } diff --git a/apps/cli/src/commands/screen.ts b/apps/cli/src/commands/screen.ts index c30ffd9..09446a2 100644 --- a/apps/cli/src/commands/screen.ts +++ b/apps/cli/src/commands/screen.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import { log } from '@clack/prompts'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { buildVncUrls, detectEngine, @@ -173,7 +174,7 @@ export const screenCommand = new Command('screen') return; } - const opened = spawnSync('open', [url], { stdio: 'inherit' }); + const opened = spawnSync(hostOpenCommand(), [url], { stdio: 'inherit' }); if (opened.status !== 0) { throw new Error(`open ${url} failed (exit ${String(opened.status ?? 'n/a')})`); } diff --git a/apps/cli/src/commands/url.ts b/apps/cli/src/commands/url.ts index db43946..8bd96a1 100644 --- a/apps/cli/src/commands/url.ts +++ b/apps/cli/src/commands/url.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import { log } from '@clack/prompts'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { detectEngine, getBoxHostPaths, @@ -119,7 +120,7 @@ export const urlCommand = new Command('url') return; } - const opened = spawnSync('open', [url], { stdio: 'inherit' }); + const opened = spawnSync(hostOpenCommand(), [url], { stdio: 'inherit' }); if (opened.status !== 0) { throw new Error(`open ${url} failed (exit ${String(opened.status ?? 'n/a')})`); } diff --git a/docs/linux-host-backlog.md b/docs/linux-host-backlog.md index f28214a..5285b51 100644 --- a/docs/linux-host-backlog.md +++ b/docs/linux-host-backlog.md @@ -23,6 +23,19 @@ already works, what's been fixed, and the remaining macOS-only host assumptions. permission-denied branch and the healthy `reachable` path render correctly, and the daytona/hetzner/vercel credential checks run without crashing. +- **Host browser/file opening uses `xdg-open` on Linux.** Added + `hostOpenCommand()` to `@agentbox/sandbox-core` (`darwin` -> `open`, `linux` -> + `xdg-open`) with a unit test, and routed every host-side launcher through it + instead of the hardcoded macOS `open`: + - apps/cli: `url`, `screen`, `code` (CLI-missing fallback), `open` (sshfs mount + reveal), `dashboard` (VNC/web/code openers) + - relay: the box-initiated "open link on host" path (`host-actions.ts`, + `server.ts`) + - cloud login dashboards: daytona / vercel / hetzner `credentials.ts` + - `sandbox-docker` checkpoint/export reveal (`host-export.ts`) + - Verified live on the Ubuntu VM: `agentbox url ` launches via `xdg-open` + (not `open`) — see the dev-VM E2E below. + ## How to test on Linux `scripts/linux-dev-vm.sh` manages a **persistent** clean Ubuntu VM on Hetzner @@ -71,11 +84,6 @@ agentbox doctor # inspect the report These were found while scoping the doctor change. None are needed for `doctor` itself; they block the wider "drive everything from Linux" goal. -- **Browser open uses the macOS `open` command** — needs `xdg-open` (or `$BROWSER`) - on Linux: - - `apps/cli/src/commands/url.ts:122` - - `apps/cli/src/commands/screen.ts:147` - - `apps/cli/src/commands/code.ts:313` - **Host terminal spawning is iTerm2/AppleScript-only.** `detectHostTerminal()` only recognizes tmux + iTerm2; `spawnInITerm2()` shells out to `osascript` (`apps/cli/src/terminal/host.ts`, see the "macOS-only by design" comment near the diff --git a/packages/relay/src/host-actions.ts b/packages/relay/src/host-actions.ts index e471ce8..a2b3c74 100644 --- a/packages/relay/src/host-actions.ts +++ b/packages/relay/src/host-actions.ts @@ -19,7 +19,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { CloudBackend, CloudHandle } from '@agentbox/core'; -import { findBox, readState } from '@agentbox/sandbox-core'; +import { findBox, hostOpenCommand, readState } from '@agentbox/sandbox-core'; import { assertGhReady, checkoutGuards, @@ -394,11 +394,11 @@ async function runBrowserOpenMirror( { ttlMs: TTL_MS }, ); if (verdict.answer === 'y' && !verdict.cancelled) { - // macOS `open` is the only supported launcher today (Daytona client is - // mac/Linux; on Linux the same call no-ops or errors — either way the - // box doesn't observe). Spawn detached so the relay loop isn't blocked. + // Open on the host's default handler (`open` on macOS, `xdg-open` on + // Linux). Spawn detached so the relay loop isn't blocked; the box never + // observes the outcome. const { spawn } = await import('node:child_process'); - const child = spawn('open', [url], { stdio: 'ignore', detached: true }); + const child = spawn(hostOpenCommand(), [url], { stdio: 'ignore', detached: true }); child.unref(); } } catch (err) { diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 1b257eb..3b33eec 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { executeCloudAction, refreshCloudPreviewUrl } from './host-actions.js'; import { HostActionQueue } from './host-action-queue.js'; import { BoxNotices } from './notices.js'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import { assertGhReady, checkoutGuards, @@ -569,7 +570,7 @@ export function createRelayServer(opts: RelayServerOptions): RelayServerHandle { ) .then((verdict) => { if (verdict.answer === 'y' && !verdict.cancelled) { - void runHostCommand(['open', url], BROWSER_OPEN_RPC_TIMEOUT_MS); + void runHostCommand([hostOpenCommand(), url], BROWSER_OPEN_RPC_TIMEOUT_MS); } }) .catch(() => { diff --git a/packages/sandbox-docker/src/host-export.ts b/packages/sandbox-docker/src/host-export.ts index e5ee15e..33fe92a 100644 --- a/packages/sandbox-docker/src/host-export.ts +++ b/packages/sandbox-docker/src/host-export.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { execa } from 'execa'; import { sanitizeMnemonic } from '@agentbox/config'; +import { hostOpenCommand } from '@agentbox/sandbox-core'; import type { ResolvedCarryEntry } from '@agentbox/core'; import type { BoxStatus } from '@agentbox/ctl'; import { execInBox } from './docker.js'; @@ -673,7 +674,7 @@ export async function openInFinder( } if (!opts.noOpen) { - const opened = await execa('open', [hostPath], { reject: false }); + const opened = await execa(hostOpenCommand(), [hostPath], { reject: false }); if (opened.exitCode !== 0) { throw new ExportError(`open ${hostPath} failed`, opened.stdout, opened.stderr); } From 57877e30c2b3ae5ce84957a572244ae711b370fe Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Sun, 31 May 2026 09:40:07 +0100 Subject: [PATCH 04/11] fix(box-id): prefix box ids with 'b' so they are never all-digits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Box ids were randomBytes(4).toString('hex') (8 hex chars), so ~2.3% came out all decimal digits (e.g. 26524695). resolveBoxRef treats a bare positive integer as a per-project index only and never falls through to id match, so an all-digit id was unresolvable — breaking any id-targeting command (surfaced via the relay's `checkpoint create ` in the hetzner setup wizard). Fix at the source: tag ids with a leading non-digit. New shared @agentbox/core generateBoxId() returns `b`+8hex, used by both id mints (docker create + cloud-provider, covering daytona/hetzner/vercel). Keeps the clean "numeric ref = index" invariant and sets up a typed-id namespace (b=box, room for c=checkpoint). --- packages/core/src/identity.ts | 20 ++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/sandbox-cloud/src/cloud-provider.ts | 4 ++-- packages/sandbox-docker/src/create.ts | 7 +------ 4 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/identity.ts diff --git a/packages/core/src/identity.ts b/packages/core/src/identity.ts new file mode 100644 index 0000000..1327713 --- /dev/null +++ b/packages/core/src/identity.ts @@ -0,0 +1,20 @@ +import { randomBytes } from 'node:crypto'; + +/** + * Stable identity helpers shared across providers. Currently just the per-box + * id mint; checkpoint ids etc. can join later under their own prefix. + */ + +/** + * Type tag prefixed to every minted box id so the kind is readable at a glance + * and id namespaces can grow without colliding (reserve `c` for checkpoints, + * etc.). The leading non-digit is load-bearing: it guarantees a box id is never + * an all-decimal string, which would otherwise collide with the per-project + * numeric index in `resolveBoxRef` (`agentbox open 4`) and make all-digit ids + * unresolvable. + */ +export const BOX_ID_PREFIX = 'b'; + +export function generateBoxId(): string { + return `${BOX_ID_PREFIX}${randomBytes(4).toString('hex')}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dcc70f9..8c49771 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -51,3 +51,4 @@ export type { CloudVolumeMount, } from './cloud-backend.js'; export { AmbiguousBoxError, BoxNotFoundError } from './errors.js'; +export { BOX_ID_PREFIX, generateBoxId } from './identity.js'; diff --git a/packages/sandbox-cloud/src/cloud-provider.ts b/packages/sandbox-cloud/src/cloud-provider.ts index 076a7e6..6489d1c 100644 --- a/packages/sandbox-cloud/src/cloud-provider.ts +++ b/packages/sandbox-cloud/src/cloud-provider.ts @@ -11,8 +11,8 @@ * "no relay configured" error. */ -import { randomBytes } from 'node:crypto'; import { basename } from 'node:path'; +import { generateBoxId } from '@agentbox/core'; import type { AttachKind, AttachSpec, @@ -248,7 +248,7 @@ export function createCloudProvider( name: string; branch: string; } { - const id = randomBytes(4).toString('hex'); + const id = generateBoxId(); const name = req.name ?? `${basename(req.workspacePath)}-${id}`; return { id, diff --git a/packages/sandbox-docker/src/create.ts b/packages/sandbox-docker/src/create.ts index 45c5286..c5a2655 100644 --- a/packages/sandbox-docker/src/create.ts +++ b/packages/sandbox-docker/src/create.ts @@ -1,4 +1,3 @@ -import { randomBytes } from 'node:crypto'; import { mkdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, join, resolve } from 'node:path'; @@ -73,7 +72,7 @@ import { type BoxRecord, type GitWorktreeRecord, } from './state.js'; -import type { ResolvedCarryEntry } from '@agentbox/core'; +import { generateBoxId, type ResolvedCarryEntry } from '@agentbox/core'; import { createSnapshot, snapshotPathFor } from './snapshot.js'; import { resolveCheckpoint } from './checkpoint.js'; import { @@ -261,10 +260,6 @@ function persistableLimits( return Object.keys(out).length > 0 ? out : undefined; } -function generateBoxId(): string { - return randomBytes(4).toString('hex'); -} - export function sanitizeBasename(workspacePath: string): string { const raw = basename(resolve(workspacePath)); return raw From 4c506216d9d64f596f5d0f42f8bc00eb730f67cc Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Sun, 31 May 2026 09:41:46 +0100 Subject: [PATCH 05/11] docs(linux): tmux-only terminal attach on Linux (by decision) tmux is detected via $TMUX on every host, so attach-in-new-window works on Linux inside tmux; iTerm2/AppleScript stays macOS-only and native Linux emulators are out of scope for now (falls back to same-terminal attach). Correct the now-stale 'CLI is macOS-only' comment in terminal/host.ts and move the terminal item from open-blockers to a documented decision. --- apps/cli/src/terminal/host.ts | 7 +++++-- docs/linux-host-backlog.md | 14 +++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/terminal/host.ts b/apps/cli/src/terminal/host.ts index 0494807..73f3fee 100644 --- a/apps/cli/src/terminal/host.ts +++ b/apps/cli/src/terminal/host.ts @@ -8,8 +8,11 @@ export type HostTerminal = 'tmux' | 'iterm2' | 'unknown'; * when nested — when `TMUX` is set, the tmux CLI is the right primitive (it can * split the current pane / open a new window without going through AppleScript). * - * macOS-only by design: the CLI itself is macOS-only (see CLAUDE.md), so we - * don't try to recognize gnome-terminal / alacritty / Windows Terminal. + * tmux is recognized on every host (macOS + Linux) — its CLI is the portable + * primitive. The iTerm2 path is macOS-only (it drives AppleScript). On Linux we + * deliberately don't recognize native emulators (gnome-terminal / alacritty / + * konsole) yet: outside tmux the caller falls back to attaching in the current + * terminal. See docs/linux-host-backlog.md. */ export function detectHostTerminal(env: NodeJS.ProcessEnv = process.env): HostTerminal { const tmux = env['TMUX']; diff --git a/docs/linux-host-backlog.md b/docs/linux-host-backlog.md index 5285b51..8085ad8 100644 --- a/docs/linux-host-backlog.md +++ b/docs/linux-host-backlog.md @@ -36,6 +36,15 @@ already works, what's been fixed, and the remaining macOS-only host assumptions. - Verified live on the Ubuntu VM: `agentbox url ` launches via `xdg-open` (not `open`) — see the dev-VM E2E below. +- **Terminal attach on Linux: tmux only (by decision).** `detectHostTerminal()` + recognizes tmux via `$TMUX` on every host, so attach-in-new-window/pane works on + Linux when you're inside tmux. The iTerm2 path (`spawnInITerm2()` → + `osascript`, `apps/cli/src/terminal/host.ts`) stays macOS-only. We deliberately + do **not** recognize native Linux emulators (gnome-terminal / alacritty / + konsole) for now: outside tmux the caller falls back to attaching in the current + terminal (and `agentbox fork` passes `--no-attach`). Revisit only if there's + demand for native-emulator spawning. + ## How to test on Linux `scripts/linux-dev-vm.sh` manages a **persistent** clean Ubuntu VM on Hetzner @@ -84,11 +93,6 @@ agentbox doctor # inspect the report These were found while scoping the doctor change. None are needed for `doctor` itself; they block the wider "drive everything from Linux" goal. -- **Host terminal spawning is iTerm2/AppleScript-only.** `detectHostTerminal()` - only recognizes tmux + iTerm2; `spawnInITerm2()` shells out to `osascript` - (`apps/cli/src/terminal/host.ts`, see the "macOS-only by design" comment near the - top and `spawnInITerm2` ~L112-171). Linux needs tmux (already works) and/or a - native terminal-emulator launch path (gnome-terminal/konsole/alacritty/`$TERMINAL`). - **OrbStack-only fast paths assume macOS** and should be skipped on Linux (OrbStack is macOS-only; on Linux the docker socket / volume paths differ): - `packages/sandbox-docker/src/host-export.ts` (`orbstackVolumePath`, ~L138) From e724c633601bd67a9108c5dba34ba646c80ae330 Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Sun, 31 May 2026 10:49:26 +0100 Subject: [PATCH 06/11] feat(cli): single recap card on agent launch (box/project/branch + attach hint) Replaces the scattered id/provider/sandboxId rows and split detach hints with one bordered clack note showing box name (+source checkpoint), project folder (the agentbox.yaml dir, home-shortened), the from->to branch mapping, and the detach/reattach instruction. Shared by claude/codex/opencode across docker and cloud via printLaunchRecap. --- apps/cli/src/commands/_cloud-agent-create.ts | 26 ++++--- apps/cli/src/commands/claude.ts | 17 +++-- apps/cli/src/commands/codex.ts | 17 +++-- apps/cli/src/commands/opencode.ts | 17 +++-- apps/cli/src/lib/launch-recap.ts | 72 ++++++++++++++++++++ apps/cli/src/terminal/host.ts | 4 +- 6 files changed, 125 insertions(+), 28 deletions(-) create mode 100644 apps/cli/src/lib/launch-recap.ts diff --git a/apps/cli/src/commands/_cloud-agent-create.ts b/apps/cli/src/commands/_cloud-agent-create.ts index a4c9e7e..d61a05f 100644 --- a/apps/cli/src/commands/_cloud-agent-create.ts +++ b/apps/cli/src/commands/_cloud-agent-create.ts @@ -16,10 +16,10 @@ * only runs when the caller pre-resolved a non-docker provider. */ -import { log, outro } from '@clack/prompts'; import type { BoxRecord, CreateBoxRequest, Provider } from '@agentbox/core'; import type { AttachOpenIn } from '@agentbox/config'; import { makeProgressReporter } from '../lib/progress.js'; +import { printLaunchRecap } from '../lib/launch-recap.js'; import { cloudAgentAttach } from './_cloud-attach.js'; export interface CloudAgentCreateArgs { @@ -71,12 +71,7 @@ export async function cloudAgentCreate(args: CloudAgentCreateArgs): Promise { + const { record } = args; + const rows: Array<[string, string]> = []; + + rows.push([ + 'box', + args.checkpointRef ? `${record.name} (${args.checkpointRef})` : record.name, + ]); + + if (record.projectRoot) { + rows.push(['project', homeShorten(record.projectRoot)]); + } + + const toBranch = record.gitWorktrees?.find((w) => w.kind === 'root')?.branch; + if (toBranch) { + if (args.useBranch) { + rows.push(['branch', `${toBranch} (reused)`]); + } else { + const base = args.fromBranch ?? (await currentHostBranch(args.workspacePath)) ?? 'HEAD'; + rows.push(['branch', `${base} → ${toBranch}`]); + } + } + + const pad = Math.max(...rows.map(([label]) => label.length)) + 2; + const body = rows.map(([label, value]) => `${label.padEnd(pad)}${value}`).join('\n'); + + const instruction = args.attaching + ? `Ctrl+a d to detach. Reattach with: agentbox ${args.mode} attach ${args.reattach}` + : `Attach with: agentbox ${args.mode} attach ${args.reattach}`; + + note(`${body}\n\n${instruction}`); +} diff --git a/apps/cli/src/terminal/host.ts b/apps/cli/src/terminal/host.ts index 73f3fee..c170f6c 100644 --- a/apps/cli/src/terminal/host.ts +++ b/apps/cli/src/terminal/host.ts @@ -108,7 +108,7 @@ async function spawnInTmux(args: SpawnInNewTerminalArgs): Promise Date: Sun, 31 May 2026 10:53:57 +0100 Subject: [PATCH 07/11] feat(web): marketing site with favicons + CLAUDE.md --- apps/web/CLAUDE.md | 41 ++++++++++++++++++++++++++++ apps/web/index.html | 6 ++++ apps/web/public/favicon-16x16.png | Bin 0 -> 353 bytes apps/web/public/favicon-256x256.png | Bin 0 -> 3162 bytes apps/web/public/favicon-32x32.png | Bin 0 -> 533 bytes apps/web/public/logo.png | Bin 0 -> 4439 bytes apps/web/public/logo.svg | 5 ++++ apps/web/public/site.webmanifest | 20 ++++++++++++++ 8 files changed, 72 insertions(+) create mode 100644 apps/web/CLAUDE.md create mode 100644 apps/web/public/favicon-16x16.png create mode 100644 apps/web/public/favicon-256x256.png create mode 100644 apps/web/public/favicon-32x32.png create mode 100644 apps/web/public/logo.png create mode 100644 apps/web/public/logo.svg create mode 100644 apps/web/public/site.webmanifest diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 0000000..9e3c7fb --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1,41 @@ +# apps/web — marketing site + +The public marketing/landing site for AgentBox. Deployed on Vercel. + +## What it is + +- A **hand-written static site**, NOT a framework (no Astro/Vite/Next/React). +- The whole site is a single file: **`index.html`** — markup plus one big inline `