diff --git a/CHANGELOG.md b/CHANGELOG.md index ceda647..105e626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v1.2.0 - 2026-08-15 + +- Persist resumable Claude Code, Codex, OpenCode, Pi, and Stado session state + in an owner-only, per-project host directory so default disposable clones can + be deleted and later recreated without losing their agent conversations. + Credentials and unrelated host histories remain outside the store. +- Add `--ephemeral-sessions` / `-e` for runs that should leave no resumable + agent state, plus `devbox sessions path|clear` for inspecting and explicitly + deleting the retained project state. +- Existing kept boxes gain the session mount on next entry and migrate known + in-guest session paths without overwriting a pre-existing OpenCode database. + ## v1.1.0 - 2026-08-07 - `gh` is now included in golden images. With `--proxy`, the real guest CLI uses diff --git a/README.md b/README.md index 4d4a278..ca14469 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ with an AI-CLI toolchain — **claude**, **codex**, **opencode**, **pi**, and Project site: [devbox.foobarto.me](https://devbox.foobarto.me/). `cd` into a project, type `devbox`, and you're in a throwaway Linux VM with the -project mounted and the tools ready. Exit the shell and the VM is gone. +project mounted and the tools ready. Exit the shell and the VM is gone; the +project's resumable AI sessions remain in a narrow, owner-only host state +directory so the next clone can continue them. ```sh cd ~/code/some-project @@ -17,8 +19,9 @@ devbox # clone → mount CWD → shell in → delete on ex ## Why -- **Disposable.** Each box is deleted on exit by default. Nothing to clean up, - nothing accretes. +- **Disposable.** Each box is deleted on exit by default. Toolchains, package + installs, credentials, processes, and caches disappear; only the project and + its deliberately persistent AI session records remain. - **Isolated.** Real dev work in a VM boundary, not your host. Only the mounted folder is visible to the box. - **Fast.** A one-time *golden* image carries the heavy toolchain; each run is a @@ -45,7 +48,7 @@ brew install foobarto/tap/devbox Installs `devbox` and `devbox-ai-proxy` on your `PATH`. The current stable GitHub release is -[`v1.1.0`](https://github.com/foobarto/devbox/releases/tag/v1.1.0); source +[`v1.2.0`](https://github.com/foobarto/devbox/releases/tag/v1.2.0); source archives are available from that release. Config lives under `~/.config/devbox/` (or `$XDG_CONFIG_HOME/devbox`). @@ -72,6 +75,7 @@ devbox --gui|-G [DIR] [FLAGS] [-- APP ...] same GUI behavior through the main devbox build [--image N] [--force] build/refresh the golden image devbox ls list devbox instances devbox destroy NAME | --all | --goldens +devbox sessions [path|clear --yes] [DIR] ``` ### Run flags @@ -83,6 +87,7 @@ devbox destroy NAME | --all | --goldens | `--memory SIZE`, `-M SIZE` | memory for this box, e.g. `12GiB` (default `6GiB`). | | `--disk SIZE`, `-D SIZE` | disk ceiling, e.g. `80GiB` (default `100GiB`). Sparse, so it costs only what is written; grow-only. | | `--keep`, `-k` | don't auto-delete the box on exit. | +| `--ephemeral-sessions`, `-e` | keep AI session records on this box's disposable disk instead of attaching the project's persistent session store. | | `--ssh-agent`, `-s` | forward the host SSH agent into the box (git/GitHub) and configure signed Git commits. Host **private keys never enter the VM** — only the agent socket and selected public key are used. | | `--proxy[=URL]`, `-p[=URL]` | point the AI CLIs and `gh` at a host-side credential proxy; credentials stay on the host. Default `http://host.lima.internal:4141`. | | `--traffic-audit[=connect\|off]`, `-T` | explicitly route normal web tooling through an audited CONNECT proxy and block direct TCP/UDP 80/443. `off` removes it from a kept box. It is separate from `-a`. | @@ -154,6 +159,8 @@ Use `-a` for the usual agent-config + proxy + SSH-agent setup. Add `--gui` or path, and boots. - applies `DIR/.devbox.toml` if present (per-project setup — see [`examples/.devbox.toml`](examples/.devbox.toml)), + - mounts the project's owner-only AI session state and links each agent's + native transcript/index paths to it, - drops you into a shell in `DIR`, - on exit, **deletes** the clone — unless that invocation uses `--keep`. @@ -161,6 +168,42 @@ Because the name is deterministic, re-running `devbox` in the same folder finds the same box. That's what makes "one box per folder" and re-entering `--keep` boxes work. +## AI session continuity + +Resumable session state is persistent by default even when the VM is not. +Devbox gives each canonical project path a separate directory under +`${XDG_STATE_HOME:-~/.local/state}/devbox/sessions/`, mounts only that directory +read-write, and uses it for Claude Code, Codex (including the isolated +`--proxy` profile), OpenCode, Pi, and Stado session records. Existing kept +boxes gain the mount on their next entry and restart once if necessary. +Devbox refuses to attach the same store to two differently named boxes at once, +which avoids concurrent writers corrupting an agent's session database; destroy +the retained owner first or make the second box ephemeral. + +Authentication remains separate: Claude/Codex/OpenCode auth files, provider +keys, and unrelated sessions already present on the host are not placed in the +session store. Session transcripts can still contain prompts, source snippets, +paths, and tool output, so treat the directory as sensitive state. It is mode +`0700` and is also a deliberate cross-lifecycle trust channel: instructions in +an old transcript are available again when you resume it. + +Use each tool's native resume command after entering the same project. For +example, Claude Code supports `claude --continue` / `claude --resume`, and +Codex supports `codex resume --last` or its session picker. Inspect or remove +the exact project store from the host with: + +```sh +devbox sessions path . +devbox sessions clear . # confirms interactively; destroy a retained box first +devbox sessions clear --yes . # explicit non-interactive removal +``` + +`devbox destroy` intentionally leaves sessions intact. Use +`--ephemeral-sessions` (`-e`) when a run should leave no resumable agent state; +on a kept box that already has the host mount, the flag unlinks the native +agent stores and restarts the box once to remove that mount completely. +Override the host root with `DEVBOX_SESSION_DIR` when needed. + ## Images `--image` accepts several forms: @@ -312,8 +355,8 @@ warning when it finds one. ## Config -Everything host-side lives under `~/.config/devbox/` (override with -`$DEVBOX_CONFIG_DIR`): +Configuration and generated golden metadata live under `~/.config/devbox/` +(override with `$DEVBOX_CONFIG_DIR`): ``` ~/.config/devbox/ @@ -336,6 +379,11 @@ disk = "150GiB" Resource precedence is **CLI flags > `.devbox.toml` > `config.toml` > built-in defaults** (4 CPUs, 6GiB, 100GiB). +Persistent AI transcripts are state rather than configuration, so they live +separately under `${XDG_STATE_HOME:-~/.local/state}/devbox/sessions/` (override +with `$DEVBOX_SESSION_DIR`). `devbox sessions path DIR` resolves the exact +per-project directory. + ## Tests Unit tests cover the pure logic (name derivation, image-stanza + golden-YAML diff --git a/VERSION b/VERSION index 9084fa2..26aaba0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.2.0 diff --git a/bin/devbox b/bin/devbox index 4999fa3..cb964f8 100755 --- a/bin/devbox +++ b/bin/devbox @@ -21,6 +21,8 @@ # devbox ls # devbox destroy NAME | --all | --goldens # devbox proxy [start|stop|status|audit] manage the shared host-side proxy +# devbox sessions [path|clear [--yes]] [DIR] +# inspect or remove persistent AI session state # devbox --version|-V # # Run flags @@ -37,6 +39,10 @@ # disk ceiling, e.g. 80GiB (default 100GiB). qcow2 is sparse, # so this costs only what is written; it is grow-only. # --keep, -k do not auto-delete the box on shell exit. +# --ephemeral-sessions, -e +# do not attach the project's persistent AI session store +# for this box. By default, resumable agent sessions survive +# disposal while credentials and unrelated host history do not. # --ssh-agent, -s forward the host SSH agent into the box (git/GitHub auth) # and configure SSH-signed commits; private keys stay host-side. # --proxy[=URL], -p[=URL] @@ -97,6 +103,9 @@ # # Env # DEVBOX_CONFIG_DIR config/golden dir (default ~/.config/devbox) +# DEVBOX_SESSION_DIR persistent per-project AI session root +# (default $XDG_STATE_HOME/devbox/sessions, falling back +# to ~/.local/state/devbox/sessions) # DEVBOX_PROXY_URL default --proxy URL # DEVBOX_BUILD_TIMEOUT # how long a golden build may take (default 90m). Lima's @@ -130,6 +139,7 @@ DEFAULT_BUILD_TIMEOUT="${DEVBOX_BUILD_TIMEOUT:-90m}" CONFIG_DIR="${DEVBOX_CONFIG_DIR:-$HOME/.config/devbox}" GLOBAL_CONFIG="$CONFIG_DIR/config.toml" DEFAULT_KEYS_FILE="$CONFIG_DIR/api-keys.env" +AGENT_SESSION_BASE="${DEVBOX_SESSION_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/devbox/sessions}" PROXY_DEFAULT_URL="${DEVBOX_PROXY_URL:-http://host.lima.internal:4141}" # A GitHub proxy capability is valid for eight hours. Refresh it well before # expiry while this host-side `devbox` session remains attached to the guest. @@ -270,6 +280,62 @@ instance_name() { # deterministic per (image, absolute dir) printf 'devbox-%s-%s' "$slug" "$hash" } +# Session state follows the project rather than the disposable VM or selected +# image. The longer hash avoids two same-named projects ever sharing transcripts +# accidentally, while the slug keeps the host-side directory understandable. +project_session_key() { # $1 absolute project directory + local dir="$1" slug hash + slug="$(_slug "$(basename -- "$dir")" 32)" + hash="$(printf '%s' "$dir" | sha256sum | cut -c1-16)" + printf '%s-%s' "$slug" "$hash" +} + +agent_session_base() { + python3 - "$AGENT_SESSION_BASE" <<'PY' +import os +import sys +print(os.path.realpath(os.path.abspath(os.path.expanduser(sys.argv[1])))) +PY +} + +project_session_dir() { # $1 absolute project directory + local base + base="$(agent_session_base)" + printf '%s/%s' "$base" "$(project_session_key "$1")" +} + +validate_session_path() { # $1 exact per-project host directory + local path="$1" base + base="$(agent_session_base)" + case "$base" in + /|"$HOME"|"$HOME/.ssh"|"$HOME/.ssh/"*|"$HOME/.pki"|"$HOME/.pki/"*|"$HOME/.sigstore"|"$HOME/.sigstore/"*|"$HOME/.gnupg"|"$HOME/.gnupg/"*) + die "unsafe DEVBOX_SESSION_DIR: $base" + ;; + esac + [[ "$path" == "$base/"* && "$(dirname -- "$path")" == "$base" ]] \ + || die "refusing non-project session path: $path" +} + +prepare_session_dir() { # $1 exact per-project host directory + local path="$1" base owner old_umask + base="$(agent_session_base)" + validate_session_path "$path" + old_umask="$(umask)" + umask 077 + mkdir -p -- "$base" + [[ -d "$base" && ! -L "$base" ]] || die "session root must be a real directory: $base" + owner="$(stat -c %u "$base" 2>/dev/null || true)" + [[ "$owner" == "$(id -u)" ]] || die "session root is not owned by the current user: $base" + chmod 700 -- "$base" + if [[ -e "$path" || -L "$path" ]]; then + [[ -d "$path" && ! -L "$path" ]] || die "project session path must be a real directory: $path" + else + mkdir -- "$path" + fi + chmod 700 -- "$path" + umask "$old_umask" +} + # ---------------------------------------------------------------- lima state -- instance_exists() { limactl list -q 2>/dev/null | grep -qx "$1"; } instance_status() { limactl list "$1" --format '{{.Status}}' 2>/dev/null || echo Unknown; } @@ -278,6 +344,62 @@ ensure_golden_stopped() { log "Stopping golden $1 before clone"; limactl stop "$1" } +session_mount_enabled() { # $1 instance $2 exact host state directory + limactl list "$1" --json 2>/dev/null | python3 -c ' +import json, sys +path = sys.argv[1] +try: + item = json.load(sys.stdin) + if isinstance(item, list): + item = item[0] if item else {} + mounts = item.get("config", {}).get("mounts", []) + print(any(m.get("location") == path and m.get("writable") is True for m in mounts)) +except (json.JSONDecodeError, AttributeError, IndexError, TypeError): + print(False) +' "$2" +} + +session_mount_present() { # $1 instance $2 exact host state directory + limactl list "$1" --json 2>/dev/null | python3 -c ' +import json, sys +path = sys.argv[1] +try: + item = json.load(sys.stdin) + if isinstance(item, list): + item = item[0] if item else {} + mounts = item.get("config", {}).get("mounts", []) + print(any(m.get("location") == path for m in mounts)) +except (json.JSONDecodeError, AttributeError, IndexError, TypeError): + print(False) +' "$2" +} + +ensure_session_mount() { # $1 existing instance $2 exact host state directory + local name="$1" path="$2" status + [[ "$(session_mount_enabled "$name" "$path")" == True ]] && return 0 + status="$(instance_status "$name")" + if [[ "$status" == Running ]]; then + log "Restarting $name once to attach persistent AI session state" + limactl stop "$name" + fi + log "Attaching persistent AI session state to $name" + limactl edit "$name" --mount "$path:w" >/dev/null + [[ "$status" == Running ]] && limactl start "$name" +} + +remove_session_mount() { # $1 existing instance $2 exact host state directory + local name="$1" path="$2" status quoted + [[ "$(session_mount_present "$name" "$path")" == True ]] || return 0 + status="$(instance_status "$name")" + if [[ "$status" == Running ]]; then + log "Restarting $name once to detach persistent AI session state" + limactl stop "$name" + fi + quoted="$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$path")" + limactl edit "$name" --set "del(.mounts[] | select(.location == $quoted))" >/dev/null + [[ "$status" == Running ]] && limactl start "$name" +} + require_ssh_agent() { [[ -n "${SSH_AUTH_SOCK:-}" && -S "${SSH_AUTH_SOCK:-}" ]] || \ die "--ssh-agent needs a running host SSH agent (SSH_AUTH_SOCK is unset or not a socket)" @@ -1184,6 +1306,178 @@ apply_agent_config() { log "Copied $copied non-secret agent config file(s); skipped $skipped possible credential(s)." } +apply_session_persistence() { # $1 instance $2 mounted per-project host directory + local name="$1" state="$2" + log "Linking resumable AI sessions to host state: $state" + + # OpenCode keeps current session records in one SQLite database. Point only + # that database at persistent state; auth.json and the rest of its data home + # remain on the disposable guest disk. + python3 - "$state" <<'PY' \ + | limactl shell "$name" -- bash -c 'sudo tee /etc/profile.d/zz-devbox-08-session-persistence.sh >/dev/null; sudo chmod 644 /etc/profile.d/zz-devbox-08-session-persistence.sh' +import shlex +import sys + +root = sys.argv[1] +print("# devbox: per-project resumable agent sessions; no credential homes") +print(f"export DEVBOX_AI_SESSION_DIR={shlex.quote(root)}") +print(f"export OPENCODE_DB={shlex.quote(root + '/opencode/opencode.db')}") +PY + + # Migrate session material already present in a kept VM on first use, then + # replace only known session paths with links into the mounted state root. + # Unknown/custom links are deliberately left alone. + limactl shell "$name" -- bash -s -- "$state" <<'GUEST' +set -euo pipefail +state="$1" +umask 077 +mkdir -p "$state" "$state/claude" "$state/codex" "$state/opencode" \ + "$state/pi" "$state/stado" +chmod 700 "$state" "$state/claude" "$state/codex" "$state/opencode" \ + "$state/pi" "$state/stado" + +link_dir() { # $1 guest path $2 path relative to state root + src="$1"; dst="$state/$2" + mkdir -p "$(dirname "$src")" "$(dirname "$dst")" "$dst" + chmod 700 "$dst" + if [ -L "$src" ]; then + current="$(readlink -f "$src" 2>/dev/null || true)" + if [ "$current" = "$dst" ]; then return 0; fi + printf '[devbox] WARN: preserving custom session link: %s\n' "$src" >&2 + return 0 + fi + if [ -e "$src" ]; then + if [ ! -d "$src" ]; then + printf '[devbox] WARN: expected a session directory, preserving: %s\n' "$src" >&2 + return 0 + fi + cp -au "$src/." "$dst/" + rm -rf -- "$src" + fi + ln -s "$dst" "$src" +} + +link_append_file() { # append-only history/index file + src="$1"; dst="$state/$2" + mkdir -p "$(dirname "$src")" "$(dirname "$dst")" + if [ -L "$src" ]; then + current="$(readlink -f "$src" 2>/dev/null || true)" + if [ "$current" = "$dst" ]; then return 0; fi + printf '[devbox] WARN: preserving custom session link: %s\n' "$src" >&2 + return 0 + fi + if [ -e "$src" ]; then + if [ ! -f "$src" ]; then + printf '[devbox] WARN: expected a session index file, preserving: %s\n' "$src" >&2 + return 0 + fi + touch "$dst" + cat "$src" >> "$dst" + rm -f -- "$src" + else + touch "$dst" + fi + chmod 600 "$dst" + ln -s "$dst" "$src" +} + +# Claude Code: transcripts plus the session-bound shell/file restore material. +link_dir "$HOME/.claude/projects" "claude/projects" +link_dir "$HOME/.claude/sessions" "claude/sessions" +link_dir "$HOME/.claude/file-history" "claude/file-history" +link_dir "$HOME/.claude/session-env" "claude/session-env" +link_dir "$HOME/.claude/shell-snapshots" "claude/shell-snapshots" +link_append_file "$HOME/.claude/history.jsonl" "claude/history.jsonl" + +# Codex uses a separate CODEX_HOME while --proxy is active. Both profiles share +# the project transcript/index store, so changing auth mode does not hide chats. +for codex_home in "$HOME/.codex"; do + link_dir "$codex_home/sessions" "codex/sessions" + link_dir "$codex_home/archived_sessions" "codex/archived_sessions" + link_dir "$codex_home/shell_snapshots" "codex/shell-snapshots" + link_append_file "$codex_home/session_index.jsonl" "codex/session_index.jsonl" +done +if [ -f /etc/profile.d/zz-devbox-11-codex-proxy.sh ]; then + codex_home="$HOME/.devbox/codex-proxy" + link_dir "$codex_home/sessions" "codex/sessions" + link_dir "$codex_home/archived_sessions" "codex/archived_sessions" + link_dir "$codex_home/shell_snapshots" "codex/shell-snapshots" + link_append_file "$codex_home/session_index.jsonl" "codex/session_index.jsonl" +fi + +# Pi stores one JSONL session tree per working directory. +link_dir "$HOME/.pi/agent/sessions" "pi/sessions" + +# Preserve OpenCode's legacy JSON store and session attachments alongside the +# current database configured above. If upgrading a kept VM, move its current +# database only when no persistent database exists yet; SQLite DBs are never +# merged or overwritten. +link_dir "$HOME/.local/share/opencode/storage" "opencode/storage" +link_dir "$HOME/.local/share/opencode/project" "opencode/project" +link_dir "$HOME/.local/share/opencode/snapshot" "opencode/snapshot" +link_dir "$HOME/.local/share/opencode/tool-output" "opencode/tool-output" +oc_src="$HOME/.local/share/opencode/opencode.db" +oc_dst="$state/opencode/opencode.db" +if [ -f "$oc_src" ] && [ ! -e "$oc_dst" ]; then + mv "$oc_src" "$oc_dst" + for suffix in -wal -shm; do + [ ! -e "$oc_src$suffix" ] || mv "$oc_src$suffix" "$oc_dst$suffix" + done +elif [ -f "$oc_src" ] && [ -e "$oc_dst" ]; then + conflict="$state/opencode/kept-box-conflict-$(date -u +%Y%m%dT%H%M%SZ)-$$" + mkdir -p "$conflict" + mv "$oc_src" "$conflict/opencode.db" + for suffix in -wal -shm; do + [ ! -e "$oc_src$suffix" ] || mv "$oc_src$suffix" "$conflict/opencode.db$suffix" + done + printf '[devbox] WARN: preserved conflicting kept-box OpenCode DB at %s\n' "$conflict" >&2 +fi + +# Stado keeps transcripts and persistent worktrees outside its credential and +# plugin directories. Its Git refs remain in the already-mounted project. +link_dir "$HOME/.local/share/stado/sessions" "stado/sessions" +link_dir "$HOME/.local/state/stado/worktrees" "stado/worktrees" +GUEST +} + +disable_session_persistence() { # $1 instance $2 state path previously used by this project + local name="$1" state="$2" + log "Using ephemeral AI sessions in $name" + limactl shell "$name" -- bash -s -- "$state" <<'GUEST' +set -euo pipefail +state="$1" +sudo rm -f /etc/profile.d/zz-devbox-08-session-persistence.sh +for path in \ + "$HOME/.claude/projects" "$HOME/.claude/sessions" \ + "$HOME/.claude/file-history" "$HOME/.claude/session-env" \ + "$HOME/.claude/shell-snapshots" \ + "$HOME/.codex/sessions" "$HOME/.codex/archived_sessions" \ + "$HOME/.codex/shell_snapshots" \ + "$HOME/.devbox/codex-proxy/sessions" \ + "$HOME/.devbox/codex-proxy/archived_sessions" \ + "$HOME/.devbox/codex-proxy/shell_snapshots" \ + "$HOME/.pi/agent/sessions" \ + "$HOME/.local/share/opencode/storage" \ + "$HOME/.local/share/opencode/project" \ + "$HOME/.local/share/opencode/snapshot" \ + "$HOME/.local/share/opencode/tool-output" \ + "$HOME/.local/share/stado/sessions" "$HOME/.local/state/stado/worktrees"; do + if [ -L "$path" ]; then + target="$(readlink "$path" 2>/dev/null || true)" + case "$target" in "$state"/*) rm -f -- "$path"; mkdir -p "$path";; esac + fi +done +for path in "$HOME/.claude/history.jsonl" \ + "$HOME/.codex/session_index.jsonl" \ + "$HOME/.devbox/codex-proxy/session_index.jsonl"; do + if [ -L "$path" ]; then + target="$(readlink "$path" 2>/dev/null || true)" + case "$target" in "$state"/*) rm -f -- "$path";; esac + fi +done +GUEST +} + # --------------------------------------------------------- project manifest -- project_manifest() { # $1 .devbox.toml -> normalized JSON python3 - "$1" <<'PY' @@ -1441,11 +1735,15 @@ run_cleanup() { clear_gh_proxy_endpoint "$_DB_NAME" clear_traffic_proxy_endpoint "$_DB_NAME" fi + if [[ -n "${_DB_SESSION_DIR:-}" ]]; then + log "AI sessions retained: ${_DB_SESSION_DIR}" + log " resume from the same project with the agent's native resume command" + fi exit "$rc" } cmd_run() { - local dir="" image="$DEFAULT_IMAGE" image_set=0 keep=0 with_creds=0 with_agent_config=0 api_keys="" proxy="" no_auth=0 ssh_agent=0 name="" gui=0 traffic_audit="" + local dir="" image="$DEFAULT_IMAGE" image_set=0 keep=0 with_creds=0 with_agent_config=0 api_keys="" proxy="" no_auth=0 ssh_agent=0 name="" gui=0 traffic_audit="" ephemeral_sessions=0 local extra_mounts=() extra_copies=() manifest_requests=() gui_command=() local cli_cpus="" cli_memory="" cli_disk="" local golden_digest="" golden_provision="" golden_provision_user="" @@ -1460,6 +1758,7 @@ cmd_run() { --disk|-D) shift; cli_disk="${1:?--disk needs a value}";; --disk=*|-D=*) cli_disk="${1#*=}";; --keep|-k) keep=1;; + --ephemeral-sessions|-e) ephemeral_sessions=1;; --ssh-agent|-s) ssh_agent=1;; --with-creds|-c) with_creds=1;; --with-agent-config|-g) with_agent_config=1;; @@ -1583,6 +1882,14 @@ cmd_run() { golden_hash="$(golden_spec_hash "$image" "$golden_digest" "$golden_provision" "$golden_provision_user")" golden="$(golden_name "$image" "$golden_hash")" [[ -n "$name" ]] || name="$(instance_name "$image" "$dir")" + local session_state session_owner="" + session_state="$(project_session_dir "$dir")" + if [[ $ephemeral_sessions -eq 0 ]]; then + prepare_session_dir "$session_state" + session_owner="$(session_state_other_instance "$session_state" "$name")" + [[ -z "$session_owner" ]] || \ + die "AI session state is already attached to $session_owner; destroy it or use --ephemeral-sessions" + fi local preexisting=0 if instance_exists "$name"; then @@ -1598,6 +1905,7 @@ cmd_run() { fi ensure_golden_stopped "$golden" local clone_args=(--mount "${dir}:w") m + [[ $ephemeral_sessions -eq 1 ]] || clone_args+=(--mount "${session_state}:w") for m in "${extra_mounts[@]}"; do clone_args+=(--mount "$(_lima_mount_arg "$m")"); done # CPU/memory are per-box: applied to the clone so a project can be sized # without rebuilding the shared golden. @@ -1622,9 +1930,11 @@ cmd_run() { log "Cloning $golden → $name (mount: $dir${extra_mounts:+, +${#extra_mounts[@]} extra mount(s)})" limactl --tty=false clone "$golden" "$name" "${clone_args[@]}" fi + [[ $ephemeral_sessions -eq 1 || $preexisting -eq 0 ]] || ensure_session_mount "$name" "$session_state" [[ $ssh_agent -eq 1 && $preexisting -eq 1 ]] && enable_ssh_agent "$name" _DB_NAME="$name"; _DB_DIR="$dir"; _DB_IMAGE="$image"; _DB_KEEP="$keep" + _DB_SESSION_DIR=""; [[ $ephemeral_sessions -eq 1 ]] || _DB_SESSION_DIR="$session_state" trap run_cleanup EXIT INT TERM if [[ "$(instance_status "$name")" != "Running" ]]; then @@ -1670,6 +1980,14 @@ cmd_run() { if [[ ${#extra_copies[@]} -gt 0 ]]; then local c; for c in "${extra_copies[@]}"; do apply_copy "$name" "$c"; done fi + if [[ $ephemeral_sessions -eq 1 ]]; then + if [[ $preexisting -eq 1 ]]; then + disable_session_persistence "$name" "$session_state" + remove_session_mount "$name" "$session_state" + fi + else + apply_session_persistence "$name" "$session_state" + fi if [[ -n "$manifest" ]]; then local manifest_packages @@ -1725,6 +2043,84 @@ cmd_proxy() { esac } +session_state_in_use() { # $1 exact per-project state directory + command -v limactl >/dev/null 2>&1 || return 1 + limactl list --json 2>/dev/null | python3 -c ' +import json, sys +path = sys.argv[1] +try: + items = [json.loads(line) for line in sys.stdin if line.strip()] + used = any( + mount.get("location") == path + for item in items + for mount in item.get("config", {}).get("mounts", []) + ) + raise SystemExit(0 if used else 1) +except (json.JSONDecodeError, AttributeError, TypeError): + raise SystemExit(2) +' "$1" +} + +session_state_other_instance() { # $1 state directory $2 intended instance + limactl list --json 2>/dev/null | python3 -c ' +import json, sys +path, intended = sys.argv[1:] +for line in sys.stdin: + if not line.strip(): + continue + item = json.loads(line) + if item.get("name") == intended: + continue + if any(m.get("location") == path for m in item.get("config", {}).get("mounts", [])): + print(item.get("name", "unknown")) + break +' "$1" "$2" +} + +cmd_sessions() { + local sub="${1:-path}" dir="$PWD" assume_yes=0 state + [[ $# -eq 0 ]] || shift + while [[ $# -gt 0 ]]; do + case "$1" in + --yes|-y) assume_yes=1;; + -h|--help) die "usage: devbox sessions [path|clear [--yes]] [DIR]";; + -*) die "sessions: unknown flag: $1";; + *) dir="$1";; + esac + shift + done + dir="$(abspath "$dir")" + [[ -d "$dir" ]] || die "not a directory: $dir" + state="$(project_session_dir "$dir")" + validate_session_path "$state" + case "$sub" in + path) printf '%s\n' "$state";; + clear) + if [[ ! -e "$state" && ! -L "$state" ]]; then + log "no persistent AI sessions for $dir" + return 0 + fi + [[ -d "$state" && ! -L "$state" ]] || die "refusing unexpected session path: $state" + local mount_status=0 + if session_state_in_use "$state"; then + die "session state is still mounted by a Devbox; destroy that box before clearing it" + else + mount_status=$? + [[ $mount_status -eq 1 ]] || die "could not verify whether session state is still mounted" + fi + if [[ $assume_yes -eq 0 ]]; then + [[ -t 0 ]] || die "sessions clear needs an interactive terminal or --yes" + printf 'Delete persistent AI sessions for %s? [y/N] ' "$dir" >&2 + local answer=""; IFS= read -r answer + [[ "$answer" == y || "$answer" == Y ]] || { log "cancelled."; return 0; } + fi + rm -rf -- "$state" + log "deleted persistent AI sessions for $dir" + ;; + *) die "usage: devbox sessions [path|clear [--yes]] [DIR]";; + esac +} + cmd_destroy() { local t="${1:-}"; [[ -n "$t" ]] || die "usage: devbox destroy NAME | --all | --goldens" local list @@ -1753,6 +2149,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then case "${1:-}" in -h|--help|help) usage; exit 0;; -V|--version|version) printf 'devbox %s\n' "$DEVBOX_VERSION"; exit 0;; + sessions) shift; cmd_sessions "$@"; exit $?;; esac need limactl case "${1:-}" in diff --git a/docs/agent-capabilities-security.md b/docs/agent-capabilities-security.md index b45b01e..cafe714 100644 --- a/docs/agent-capabilities-security.md +++ b/docs/agent-capabilities-security.md @@ -18,19 +18,22 @@ allowed to use it. | capability | agent inside a default Devbox | same agent on the host | |---|---|---| -| Files and processes | Sees the mounted project and guest filesystem/processes, plus only explicitly mounted or copied host paths. It cannot read the rest of the host home directory or control host processes. | Can read and modify every file and process the host user is permitted to access, including local tool configuration and authentication state. | +| Files and processes | Sees the mounted project, its narrow Devbox-managed AI session store, and guest filesystem/processes, plus only explicitly mounted or copied host paths. It cannot read the rest of the host home directory or control host processes. | Can read and modify every file and process the host user is permitted to access, including local tool configuration and authentication state. | | Host credentials | Receives none by default. | Can read, copy, or invoke credentials and authenticated CLIs available to the host user. | | Desktop session | No access to the host Wayland socket or GPU nodes. | Can use the host desktop session and any local desktop capabilities available to the user. | | Network and remote services | Has normal guest networking and can use only credentials/capabilities explicitly provided to it. | Can use host network configuration, authenticated clients, and any credentials available to the host user. | The project directory is mounted read-write by default. Isolation does not stop an agent from changing the project or sending its contents to a network service -that it is authorized to use. +that it is authorized to use. The default per-project session store is also +read-write so resumable transcripts survive clone deletion; use +`--ephemeral-sessions` when that persistence is unwanted. ## Capability matrix | feature | what an agent in the Devbox can do | what remains outside the Devbox | |---|---|---| +| Persistent AI sessions (default) | Read and modify this project's Devbox-managed Claude, Codex, OpenCode, Pi, and Stado session records across VM lifecycles. This enables native resume commands but also carries transcript instructions and tool output into future clones. | Other projects' Devbox stores, existing host agent histories, auth files, provider tokens, caches, and general host state remain unmounted. `--ephemeral-sessions` / `-e` disconnects the native session paths for that run. | | `--ssh-agent` / `-s` | Request authentication and signatures using identities currently loaded in the host SSH agent. This includes SSH/Git access accepted by those identities and Devbox's SSH-format Git commit signing. | It cannot read or copy the private-key material from the agent. It does not gain access to unmounted host files or a host shell. | | `--proxy` / `-p` | Make requests through Devbox's configured AI and GitHub routes using the host account's authentication. It can consume quotas and create, read, modify, or upload remote data to the extent the authenticated provider account permits. | It does not receive the underlying API keys, OAuth tokens, or host `gh` token. The guest runs its own CLIs; it cannot run host commands through the proxy. | | `--traffic-audit` / `-T` | Send proxy-aware public web traffic through a short-lived generic CONNECT capability. Direct TCP/UDP 80/443 fails under the guest firewall; CONNECT audit records reveal destination, timing, and byte counts, while plaintext HTTP can be recorded in detail. | It grants no AI, GitHub, SSH, or host-login credential. HTTPS remains encrypted after CONNECT, and non-web ports remain outside the rule. The generic proxy refuses host/private/LAN destinations. | @@ -61,6 +64,30 @@ running directly on the host can normally use the same agent socket and, unlike the Devbox agent, can also access other host files and authenticated tools that the user account can reach. +## Persistent AI session state + +Devbox mounts one owner-only host directory derived from the canonical project +path and links only the bundled agents' native transcript, index, attachment, +and session-worktree paths into it. It does not mount `~/.claude`, `~/.codex`, +the OpenCode data home, or another broad host configuration directory. Codex's +normal and proxy-specific homes share only their project session records, so +changing the authentication route does not expose or strand the transcript. +The CLI refuses to attach one project store to two differently named boxes at +the same time, avoiding concurrent SQLite/session writers across VMs. + +This storage is data persistence, not a security sandbox. An agent can rewrite +its own saved transcript, and a resumed session can reintroduce old prompt +content, source snippets, paths, tool output, or malicious instructions. The +store can therefore act as a cross-lifecycle injection channel. Review a +session before resuming it when the previous VM processed untrusted input. + +`devbox destroy` leaves this state intact by design. `devbox sessions path DIR` +shows the exact directory; `devbox sessions clear DIR` removes it after every +box mounting it has been destroyed. The clear command confirms interactively +unless `--yes` is supplied. `--ephemeral-sessions` leaves new transcript data +on the disposable guest disk; on an already-kept box it disconnects the native +agent paths and restarts once to remove the host mount completely. + ## Credential proxy `--proxy` is a **request capability** whose primary security goal is preventing @@ -128,8 +155,9 @@ GUI forwarding must be added explicitly with `--gui` or `-G`. Avoid using `-a`, `--ssh-agent`, `--proxy`, or `--gui` for unknown code unless you have consciously accepted their separate risks. For the narrowest -untrusted-code environment, begin with `devbox --no-auth` and no extra mounts, -copies, agent forwarding, proxy, or GUI forwarding. +untrusted-code environment, begin with +`devbox --no-auth --ephemeral-sessions` and no extra mounts, copies, agent +forwarding, proxy, or GUI forwarding. ## Reviewing repository-controlled requests @@ -152,3 +180,6 @@ capabilities you selected. 5. Remove a capability when finished: exit and destroy the disposable box, or re-enter a kept box with `--no-auth` to remove credential-proxy state and `--traffic-audit=off` to remove proxy-or-fail traffic auditing. +6. Treat resumed transcripts as untrusted input when the previous session read + untrusted material. Use `--ephemeral-sessions` or `devbox sessions clear` + when cross-lifecycle state is not appropriate. diff --git a/docs/index.html b/docs/index.html index 44a6460..e59014e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -43,7 +43,7 @@
-

v1.1.0 · Lima ≥ 2.0

+

v1.2.0 · Lima ≥ 2.0

devbox

A practical VM boundary for AI-assisted development.

-

Claude, Codex, OpenCode, Pi, Stado, Herdr, and gh are preloaded. Open exactly the host-backed capabilities a task needs—then throw the box away when you are done.

+

Claude, Codex, OpenCode, Pi, Stado, Herdr, and gh are preloaded. Open exactly the host-backed capabilities a task needs—then throw the box away while keeping only its resumable project sessions.

Read the docs
@@ -93,12 +93,12 @@

A sealed box is useless. An open one is risky.

00

Fresh by default

-

No host credentials, desktop session, or SSH agent is exposed. You can log in inside the ephemeral VM if you choose.

+

No host credentials, desktop session, or SSH agent is exposed. Only this project's owner-only AI session store survives clone disposal; use -e to make that ephemeral too.

(default)

01

Non-secret agent context

-

Selected rules, prompts, settings, and custom agents cross in. Credentials, histories, caches, and key directories do not.

+

Selected rules, prompts, settings, and custom agents cross in. Credentials, unrelated host histories, caches, and key directories do not.

--with-agent-config
@@ -200,12 +200,13 @@

Guest GUI apps, on your host.

  1. 01

    Build

    Provision Homebrew, AI CLIs, gh, and build essentials into a golden image.

  2. 02

    Clone

    Attach to a kept box or quickly clone the golden image for the project directory.

  3. -
  4. 03

    Mount

    Mount the project writable at the same path, apply approved configuration, and enter.

  5. -
  6. 04

    Destroy

    Exit to delete the clone, unless you explicitly used --keep.

  7. +
  8. 03

    Mount

    Mount the project plus its narrow resumable-session store, apply approved configuration, and enter.

  9. +
  10. 04

    Destroy

    Exit to delete the clone while retaining project sessions, unless you explicitly used --keep.

$ devbox [DIR] [FLAGS]                     # enter a disposable development VM
 $ devbox gui [DIR] [FLAGS] [-- APP ...]  # open a GUI-ready shell or app
 $ devbox build [--image N] [--force]      # create or refresh a golden image
+$ devbox sessions path|clear [DIR]       # inspect or remove resumable state
 $ devbox destroy NAME | --all | --goldens
@@ -214,6 +215,7 @@

Guest GUI apps, on your host.

KEY FLAGS

The ones you’ll reach for.

Flags make host-backed capabilities explicit. They work on a new box and, where applicable, refresh or remove state in an existing kept box.

--keep, -k

Leave the box running when the shell exits.

+
--ephemeral-sessions, -e

Keep this run's AI transcripts on the disposable guest disk.

--proxy[=URL], -p

Use host-side AI and GitHub authentication without exposing real tokens in the VM.

--traffic-audit, -T

Route normal web tooling through the audited proxy or fail on direct web ports.

--ssh-agent, -s

Forward the host SSH agent for authentication and SSH-signed commits.

diff --git a/test/devbox.bats b/test/devbox.bats index ad94554..5c06965 100644 --- a/test/devbox.bats +++ b/test/devbox.bats @@ -74,6 +74,51 @@ setup() { [ "$a" != "$b" ] } +# ------------------------------------------------------ session persistence ---- +@test "project session state follows the project rather than the VM image" { + original="$AGENT_SESSION_BASE" + AGENT_SESSION_BASE="$BATS_TEST_TMPDIR/state" + a="$(project_session_dir /home/u/proj)" + b="$(project_session_dir /home/u/proj)" + [ "$a" = "$b" ] + [[ "$a" =~ /proj-[0-9a-f]{16}$ ]] + [ "$a" != "$(project_session_dir /home/u/other)" ] + AGENT_SESSION_BASE="$original" +} + +@test "project session state is created owner-only" { + original="$AGENT_SESSION_BASE" + AGENT_SESSION_BASE="$BATS_TEST_TMPDIR/state" + path="$(project_session_dir /home/u/proj)" + prepare_session_dir "$path" + [ -d "$path" ] + [ "$(stat -c %a "$AGENT_SESSION_BASE")" = 700 ] + [ "$(stat -c %a "$path")" = 700 ] + AGENT_SESSION_BASE="$original" +} + +@test "session persistence covers native stores without persisting auth homes" { + run declare -f apply_session_persistence + [ "$status" -eq 0 ] + [[ "$output" == *'.claude/projects'* ]] + [[ "$output" == *'codex_home/sessions'* ]] + [[ "$output" == *'.devbox/codex-proxy'* ]] + [[ "$output" == *'.pi/agent/sessions'* ]] + [[ "$output" == *'OPENCODE_DB'* ]] + [[ "$output" == *'.local/share/stado/sessions'* ]] + [[ "$output" != *'.codex/auth.json'* ]] + [[ "$output" != *'.local/share/opencode/auth.json'* ]] +} + +@test "new boxes mount persistent session state and kept boxes gain it once" { + source_text="$(<"$DEVBOX")" + [[ "$source_text" == *'clone_args+=(--mount "${session_state}:w")'* ]] + [[ "$source_text" == *'ensure_session_mount "$name" "$session_state"'* ]] + [[ "$source_text" == *'remove_session_mount "$name" "$session_state"'* ]] + [[ "$source_text" == *'session_state_other_instance "$session_state" "$name"'* ]] + [[ "$source_text" == *'--ephemeral-sessions|-e) ephemeral_sessions=1'* ]] +} + # --------------------------------------------------------- emit_base_stanza ---- @test "base stanza: bare template name" { run emit_base_stanza ubuntu-24.04 @@ -633,7 +678,7 @@ setup() { @test "every long run, build, and destroy flag has a single-letter alias" { source_text="$(<"$DEVBOX")" for alias in \ - '--image|-i' '--keep|-k' '--ssh-agent|-s' '--proxy|-p' '--no-auth|-n' \ + '--image|-i' '--keep|-k' '--ephemeral-sessions|-e' '--ssh-agent|-s' '--proxy|-p' '--no-auth|-n' \ '--api-keys|-K' '--with-creds|-c' '--with-agent-config|-g' \ '--gui|-G' '--traffic-audit|-T' \ '--mount|-m' '--copy|-C' '--name|-N' '--force|-f' '--all|-A' '--goldens|-G' \ @@ -642,6 +687,21 @@ setup() { done } +@test "sessions path works without Lima and clear has an explicit destructive path" { + project="$BATS_TEST_TMPDIR/project" + session_base="$BATS_TEST_TMPDIR/session-root" + mkdir -p "$project" + run env DEVBOX_SESSION_DIR="$session_base" bash "$DEVBOX" sessions path "$project" + [ "$status" -eq 0 ] + state="$output" + [[ "$state" == "$session_base/"* ]] + mkdir -p "$state" + printf 'session transcript\n' > "$state/example.jsonl" + run env DEVBOX_SESSION_DIR="$session_base" bash "$DEVBOX" sessions clear --yes "$project" + [ "$status" -eq 0 ] + [ ! -e "$state" ] +} + @test "help states that --keep is the only opt-out from cleanup" { run bash "$DEVBOX" --help [ "$status" -eq 0 ] diff --git a/test/e2e.sh b/test/e2e.sh index d2a91b9..905fad2 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -46,6 +46,7 @@ destroy_project_box() { cleanup() { destroy_project_box "$MANIFEST_PROJECT" + "$DEVBOX_BIN" sessions clear --yes "$MANIFEST_PROJECT" >/dev/null 2>&1 || true [[ "${DEVBOX_E2E_KEEP_ARTIFACTS:-}" == 1 ]] || rm -rf "$PROJECT_ROOT" } trap cleanup EXIT INT TERM