From b8732260aa237165733ad27751789591dc85181f Mon Sep 17 00:00:00 2001 From: adminturneddevops Date: Thu, 17 Sep 2026 17:37:11 -0400 Subject: [PATCH] worktree removal --- PLAN-ABOX-SDK.md | 13 +- PLAN-CRED.md | 2 +- PLAN.md | 106 +++++----- README.md | 20 +- cmd/abox/main.go | 70 ++----- docs/_includes/examples/sdk-resume.go | 8 +- docs/api.md | 5 +- docs/cli.md | 6 +- docs/concepts.md | 18 +- docs/examples.md | 8 +- docs/examples/export-patch.md | 2 +- docs/examples/history.md | 2 +- docs/examples/list-files.md | 2 +- docs/examples/multi-turn.md | 4 +- docs/examples/resume.md | 9 +- docs/index.md | 4 +- docs/quickstart.md | 5 +- docs/sessions.md | 11 +- examples/sdk-resume/main.go | 8 +- internal/guest/tools/tools.go | 6 +- internal/guest/tools/tools_test.go | 26 +++ internal/repository/repository.go | 250 +++++++++--------------- internal/repository/repository_test.go | 255 ++++++++----------------- internal/runtime/runtime_test.go | 4 +- internal/session/session.go | 73 ++----- internal/session/session_test.go | 66 +------ internal/tui/render_test.go | 2 +- pkg/abox/abox.go | 57 ++---- pkg/abox/abox_test.go | 38 +--- 29 files changed, 385 insertions(+), 695 deletions(-) diff --git a/PLAN-ABOX-SDK.md b/PLAN-ABOX-SDK.md index e7da812..178e2c7 100644 --- a/PLAN-ABOX-SDK.md +++ b/PLAN-ABOX-SDK.md @@ -2,7 +2,12 @@ ## Context -ABox's host orchestration is already SDK-shaped — `cmd/abox/main.go` composes `config.Load` → `session.Create/LatestForRepo` → `repository.OpenForSession/ArchiveHEAD` → `runtime.Prepare/Start` → `Sandbox.UserTurn(ctx, prompt, onEvent)` — but it is all locked behind `internal/` and consumable only via the CLI. Goal: a public Go SDK package so other Go programs can embed ABox (spawn a microVM-isolated agent session, stream events, cancel turns, read usage/cost metadata), per user decisions: +ABox's host orchestration is already SDK-shaped: `cmd/abox/main.go` composes +configuration, session creation or ID-based loading, source-directory +snapshotting, VM startup, and `Sandbox.UserTurn`. The goal is a public Go SDK +package so other Go programs can embed ABox (spawn a microVM-isolated agent +session, stream events, cancel turns, and read usage metadata), per user +decisions: - **Scope: full v0.2** — extraction + configurable turn options + mid-turn cancellation + richer result metadata (token usage, stop reason, tool args/IDs). - **Purely additive to the CLI** — `cmd/abox`, `internal/tui`, `cmd/abox-vmm` untouched. The v0.2 features require *additive* changes to `protocol`, `cmd/abox-guest`, `internal/agent`, `internal/provider`, `internal/runtime`, `internal/guest/tools` (new fields/methods only; existing signatures and behavior preserved). Golden image rebuild via existing `make image-update` is a required deploy step. @@ -44,8 +49,8 @@ type Options struct { BootTimeout time.Duration // default 45s (matches CLI) } -func Open(ctx context.Context, opts Options) (*Session, error) // new session: snapshot repo, clone disk, boot, transfer archive -func Resume(ctx context.Context, sessionID string, opts Options) (*Session, error) // "" = latest for repo (session.LatestForRepo via repository.TopLevel) +func Open(ctx context.Context, opts Options) (*Session, error) // new session: snapshot source directory, clone disk, boot, transfer archive +func Resume(ctx context.Context, sessionID string, opts Options) (*Session, error) // non-empty session id required type Session struct { /* wraps *runtime.Sandbox + *session.Session */ } func (s *Session) ID() string @@ -127,7 +132,7 @@ The docs should be built in GitHub Pages 2. `make build && make image-update` (rebuild guest binary onto golden image). 3. `abox --probe-vm` — CLI still works (untouched code path, proves no internal regression). 4. `go run ./examples/sdk-basic` in a test repo with a provider key: full turn streams events, usage populated; Ctrl+C mid-turn → clean cancel, VM shuts down. -5. Resume an SDK session (`Resume("")`) — degrades or works per guest version. +5. Resume an SDK session by id — degrades or works per guest version. 6. Manual TUI smoke test (`abox`, one prompt) — behavior identical to pre-change. ## Execution order diff --git a/PLAN-CRED.md b/PLAN-CRED.md index f3b5d73..6d704d2 100644 --- a/PLAN-CRED.md +++ b/PLAN-CRED.md @@ -124,7 +124,7 @@ New: `Sandbox.PushSecrets(ctx, model, secrets)` — for protocol 2, `set_model` ### 2.3 Scrub existing session dirs New: `internal/session/scrub.go` (+test). `ScrubSecrets(root)` — surgical, never deletes sessions: -1. Walk `sessions//` (pattern from `LatestForRepo`, session.go:94). +1. Walk each directory beneath `sessions/`. 2. `guest-config.json`: unmarshal to `map[string]json.RawMessage` (preserves unknown fields); no `"secrets"` key → skip (idempotent); else delete key, tmp+rename write, 0600. 3. `config.raw`: chmod 0600 → write scrubbed JSON zero-padded to 1 MiB → chmod 0400. Extract the pad-write body of `writeConfigDisk` (runtime.go:84-95) into shared `session.WritePaddedConfig` and have runtime reuse it (runtime already imports session). 4. root.raw / transcript.json / console.log / session.json untouched. diff --git a/PLAN.md b/PLAN.md index 4d44802..86fd7f3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -60,7 +60,7 @@ The current plan makes these decisions: | Model traffic | Protocol-4 host LLM broker; the guest supplies a configured model alias and bounded request data | | Remote MCP traffic | Protocol-4 host Streamable HTTP broker; the guest supplies configured server/tool identities and arguments, never endpoints or credentials | | Providers | OpenAI and xAI through Chat Completions today; Anthropic through Messages. OpenAI/xAI Responses remain Planned | -| Repository state | Git worktree required. Clean trees archive `HEAD`; dirty or unborn trees use a private ephemeral snapshot | +| Source state | Any host directory is snapshotted exactly; host Git state is not inspected and `.git` metadata is excluded | | Host workspace sharing | Prohibited | | Repository transfer | Private snapshot copied into a writable guest disk | | Change return | Guest patch export is implemented. Reviewed host import remains Planned | @@ -569,32 +569,24 @@ Guest package tools must use origin rewrite to a loopback adapter inside HTTPS. The guest never issues CONNECT and never needs a CA bundle for brokered fetches. -## 9. Repository Provisioning +## 9. Source Provisioning -The current implementation requires the starting directory to be inside a Git -worktree and rejects submodules. It supports both clean and dirty worktrees. +The current implementation accepts any host directory. It snapshots exactly +the requested directory and does not discover a Git root or inspect host +branches, commits, ignore rules, or working state. ### 9.1 Preconditions -For a clean worktree with an existing commit, ABox records the repository root -and `HEAD` and archives `HEAD`. - -If the worktree is dirty or has no commit, ABox creates a private ephemeral -snapshot under the mode-`0700` session directory. It copies tracked files and -non-ignored untracked regular files, preserves executable bits, reflects -tracked modifications and deletions, initializes a private Git repository, -and creates a private baseline commit. It does not modify the host Git -repository. - -Untracked ignored files are excluded. Tracked files remain part of the -snapshot even when an ignore rule matches them. The dirty-tree copy path -rejects symlinks, symlinked directories, and unsupported special files. A -directory that is not inside a Git worktree is not currently supported. +ABox requires a readable directory. It includes regular files, dotfiles, and +empty directories, preserves executable bits, excludes files or directories +named `.git`, and rejects symlinks and unsupported special files. The snapshot +is bounded to the same entry, per-file, and total-byte limits enforced by the +guest extractor. Host Git and host Git configuration are not required. ### 9.2 Transfer -The host archives the selected clean or ephemeral baseline with a narrowly -constrained Git operation and streams bounded chunks over authenticated RPC. +The host creates a bounded tar snapshot directly with the Go standard library +and streams bounded chunks over authenticated RPC. The guest extraction code must reject: @@ -621,17 +613,13 @@ mounted or copied as a live writable repository. ### 9.4 Resume and Future Import -Resume boots the existing session `root.raw` and does not recopy the host -worktree. +Resume boots the existing session `root.raw` by explicit session id and does +not recopy the host source directory. Guest patch export is implemented, but host patch review and import are not. -Before import is added, clean snapshots may use `HEAD` and worktree-cleanliness -rechecks. Ephemeral dirty snapshots require a recorded source manifest and a -design that distinguishes pre-existing host changes from agent changes. -Current code must not claim safe dirty-tree import. - -Starting another session from a dirty worktree is supported; the former rule -that the user must commit or stash before the next session is obsolete. +Before import is added, the host needs a recorded source manifest and a design +that detects source-directory changes after the initial snapshot. Current code +must not claim safe host import. ## 10. Host-Guest Protocol @@ -909,13 +897,12 @@ turn. **Current status:** Basic same-disk session resume is implemented. The guest persists conversation messages in `/var/lib/abox/context.json`; the host stores -`session.json` and, for the TUI, `transcript.json`. CLI `--resume` and SDK -`Resume` boot the existing `root.raw` without recopying the repository. This -is not yet the append-only normalized event store, inspectable memory system, -checkpoint bundle, approval restoration, retention policy, or corruption -recovery specified below. Explicit-ID resume works for ephemeral dirty -snapshots, but automatic latest-for-repository matching needs correction -because current session metadata records the private snapshot root. +`session.json` and, for the TUI, `transcript.json`. CLI `--resume ` and SDK +`Resume` boot the existing `root.raw` without recopying the source directory. +Resume always requires an explicit session id; it is not inferred from the +current directory or any Git state. This is not yet the append-only normalized +event store, inspectable memory system, checkpoint bundle, approval +restoration, retention policy, or corruption recovery specified below. The first milestone must persist sessions and useful memory without a resident daemon or heavyweight database service. @@ -1284,8 +1271,7 @@ The patch-review screen provides: - Patch statistics - Reject and import actions - A final explicit import confirmation modal -- After import, a notice that the host worktree is now dirty; another session - may use the dirty-tree ephemeral snapshot path +- After import, a notice that the host source directory has changed The default action must be non-destructive. Cancellation or terminal closure must not import the patch. @@ -1366,11 +1352,10 @@ Before review, the host validates: - Patch size and file-count limits - Relative paths - No traversal -- No writes outside the repository +- No writes outside the source directory - No unsupported file modes or special files -- For a clean baseline, captured `HEAD` and worktree cleanliness still match -- For an ephemeral dirty baseline, a recorded source manifest still matches - and pre-existing changes are distinguished from guest changes +- The recorded source manifest still matches and host changes made after the + snapshot are distinguished from guest changes - Patch applies cleanly in check mode ### 16.3 Review and Confirmation @@ -1382,14 +1367,14 @@ repository unchanged. ### 16.4 Import -The host may use a fixed Git executable invocation or a suitable Go library to -apply the reviewed patch. If Git is used: +The host uses a bounded patch applier that does not require the source directory +to be a Git checkout: - No shell is involved. - The executable and arguments are fixed by trusted code. - The patch is supplied through a controlled file or standard input. - Model-generated data cannot add command-line options. -- The repository root is the captured trusted path. +- The source directory is the captured trusted path. Host patch import is an explicit exception to the guest-only effect rule because it is a reviewed user action owned by the trusted control plane. @@ -1751,23 +1736,20 @@ Exit criteria: - Device inspection shows no network device and no host-path filesystem share. -### Phase 7: Repository Transfer +### Phase 7: Source Transfer -- Support clean committed snapshots through `git archive HEAD`. -- Support dirty and unborn Git worktrees through a private ephemeral baseline. -- Include tracked and non-ignored untracked regular files; preserve tracked - modifications, deletions, and executable bits. -- Reject submodules and unsafe or unsupported file types. +- Snapshot exactly the selected host directory without requiring host Git. +- Include regular files, dotfiles, and empty directories; preserve executable + bits and exclude `.git` metadata. +- Reject symlinks and unsafe or unsupported file types. - Stream and safely extract the selected snapshot in the guest. - Initialize the private guest baseline. - Verify guest changes do not change host files. Exit criteria: -- Clean, dirty, and unborn Git worktrees transfer correctly. -- Non-Git directories, submodules, unsafe symlinks, and malicious archive - paths fail clearly. -- Ignored untracked files do not enter the snapshot. +- Plain directories and directories containing Git metadata transfer correctly. +- Unsafe symlinks, special files, and malicious archive paths fail clearly. - Malicious archive-path tests are rejected. ### Phase 8: Providers @@ -2203,8 +2185,8 @@ These phases require separate ADRs and threat-model updates. - The first host is Apple Silicon running a supported modern macOS release. - The host supports Hypervisor.framework and permits hardware virtualization. - The first guest can be ARM64 Linux. -- Repositories use Git and may begin clean, dirty, or without a commit; - non-Git directories are not currently supported. +- Source directories do not require Git. Host Git metadata and state do not + participate in snapshotting or session identity. - Provider HTTPS originates from the trusted host broker; the model loop and request construction remain in the guest. - The guest has no NIC in every first-milestone connectivity mode. @@ -2215,10 +2197,10 @@ These phases require separate ADRs and threat-model updates. these modes changes the guest device plan. - Planned package adapters will use origin rewrite rather than HTTP(S) proxy variables; no package adapter exists today. -- Users accept that untracked ignored local files are not present in an - ephemeral snapshot. Tracked files remain included. +- Users accept that regular files in the selected source directory are included + regardless of Git ignore rules, except `.git` metadata itself. - Users accept that the initial image has a limited toolchain set. -- Users accept that a successful patch import leaves a dirty host worktree. +- Users accept that a successful future patch import modifies the host source directory. - The host and local administrator are trusted. - The guest, model output, generated code, repository content, and repo-sourced instruction files are untrusted. Host configuration is the @@ -2239,8 +2221,7 @@ Resolved decisions: - Current documented runtime: libkrun 1.19.4-style API - Current guest launch: `krun_set_exec` - Current remote MCP path: host Streamable HTTP broker -- Current repository path: clean `HEAD` archive or dirty/unborn ephemeral Git - snapshot +- Current source path: bounded filesystem snapshot of the exact configured directory Still open or incomplete: @@ -2262,7 +2243,6 @@ Still open or incomplete: - Context accounting and compaction - Scoped `AGENTS.md`, global instructions, and skills - Full append-only session events and inspectable memory -- Correct latest-for-repository resume association for ephemeral snapshots - MCP approval and guest-local stdio MCP - Patch review and host import, including safe dirty-baseline handling - Cold checkpoint, rollback, fork, lineage, and lifecycle UI diff --git a/README.md b/README.md index ca7408d..ac3bbc9 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,13 @@ brew install libkrun libkrunfw ## Quickstart -From this repo (or any directory). If Git is missing, dirty, or has no -commits, ABox copies the files into a private snapshot and leaves your -host Git alone. +From this directory or any other directory. ABox snapshots that exact directory +without inspecting host Git state. `.git` metadata is excluded; the guest +creates its own private baseline for change tracking. + +Git ignore rules are not consulted. Every regular file beneath the selected +directory is copied, including dotfiles, except `.git` metadata. Start ABox +from a directory containing only files the guest is allowed to read. ![](img/abox-quickstart.gif) @@ -77,7 +81,7 @@ abox - `/mcp` lists configured Streamable HTTP MCP servers and accepts a Bearer token (`abox mcp login` for OAuth) - `/help` lists slash commands - Model-authored `run_command` opens an approval prompt (deny is the default) -- `abox --resume` reopens the latest session for this repo (same `root.raw`, LLM conversation, and TUI transcript). `abox --resume ` picks a session. Plain `abox` still starts a new session. +- `abox --resume ` reopens that session's `root.raw`, LLM conversation, and TUI transcript. Plain `abox` starts a new session and prints its id. - `ctrl+c` quits - The agent runs only inside the guest (MicroVM) @@ -147,7 +151,11 @@ The VM boots **only** the session clone, not the golden file. Destroy a session ### Resume Command -`abox --resume` does **not** clone the golden image again. It boots the existing `root.raw` for that session and the guest reloads conversation state from `/var/lib/abox/context.json` on that disk. The TUI reloads the same transcript (host `transcript.json`, or the guest context if that file is missing). The host git tree is not re-copied (that would overwrite guest work). +`abox --resume ` does **not** clone the golden image again. It boots that +session's existing `root.raw`, and the guest reloads conversation state from +`/var/lib/abox/context.json` on that disk. The TUI reloads the same transcript +(host `transcript.json`, or the guest context if that file is missing). The +host source directory is not copied again. ## Test @@ -469,7 +477,7 @@ HTTPS are host-brokered. Claims stay Planned until the hardware suite in ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ │ Five tools │ list_files, read_file, search, apply_patch, run_command in guest │ ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ -│ Repo snapshot │ Copied into guest (clean tree or ephemeral) │ +│ Source snapshot │ Exact host directory copied into guest; host Git is not inspected │ ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ │ Providers │ Grok/OpenAI (chat completions) + Anthropic Messages. /provider keys. │ ├──────────────────┼──────────────────────────────────────────────────────────────────────┤ diff --git a/cmd/abox/main.go b/cmd/abox/main.go index cc88b7e..875f97d 100644 --- a/cmd/abox/main.go +++ b/cmd/abox/main.go @@ -11,7 +11,6 @@ import ( "log" "os" "os/signal" - "path/filepath" "strings" "time" @@ -50,7 +49,7 @@ func run() error { prompt := fs.String("prompt", "", "prompt for exec mode") modelName := fs.String("model", "", "configured model profile name") probeVM := fs.Bool("probe-vm", false, "boot the guest and list files; no model call") - resume := fs.Bool("resume", false, "resume a previous session for this repository (same root.raw and conversation)") + resumeID := fs.String("resume", "", "resume the session with this id (same root.raw and conversation)") args := os.Args[1:] execMode := false if len(args) > 0 && args[0] == "exec" { @@ -63,6 +62,9 @@ func run() error { if *execFlag { execMode = true } + if len(fs.Args()) > 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")) + } cfg, cfgPath, err := config.Load() if err != nil { @@ -76,49 +78,36 @@ func run() error { return fmt.Errorf("no model profile %q (config %s)", *modelName, cfgPath) } - wd, err := os.Getwd() - if err != nil { - return err - } if err := os.MkdirAll(config.SessionRoot(), 0o700); err != nil { return err } - resumeID := "" - if *resume { - if extra := fs.Args(); len(extra) > 0 { - resumeID = extra[0] - } - } - var sess *session.Session - var snap repository.Snapshot - if *resume { - loaded, err := loadResumeSession(wd, resumeID) + var archive []byte + resuming := strings.TrimSpace(*resumeID) != "" + if resuming { + loaded, err := session.Load(strings.TrimSpace(*resumeID)) if err != nil { return err } sess = loaded fmt.Fprintf(os.Stderr, "abox: resuming session %s\n", sess.ID) } else { - created, err := session.Create(wd, "pending") + wd, err := os.Getwd() if err != nil { return err } - sess = created - opened, err := repository.OpenForSession(wd, filepath.Join(sess.Dir, "host-tree")) + sourceDir, data, err := repository.ArchiveDirectory(wd) if err != nil { return err } - snap = opened - sess.RepoRoot = snap.Root - sess.HEAD = snap.HEAD - if err := sess.WriteMeta(); err != nil { + archive = data + created, err := session.Create(sourceDir) + if err != nil { return err } - if snap.Ephemeral { - fmt.Fprintf(os.Stderr, "abox: no clean committed worktree; using an ephemeral snapshot. host git is unchanged.\n") - } + sess = created + fmt.Fprintf(os.Stderr, "abox: created session %s\n", sess.ID) } var sb *runtime.Sandbox @@ -128,7 +117,7 @@ func run() error { if image == "" { image = config.GuestImagePath() } - if err := runtime.Prepare(sess, image, sel, *resume); err != nil { + if err := runtime.Prepare(sess, image, sel, resuming); err != nil { if execMode { return err } @@ -147,7 +136,7 @@ func run() error { vmState = "failed" } else { if started.GuestProtocol < 2 { - if *resume { + if resuming { started.Stop() return fmt.Errorf("cannot resume protocol-1 session %s after secretless config rewrite; rebuild the guest image and start a new session", sess.ID) } @@ -175,13 +164,9 @@ func run() error { } fmt.Fprintf(os.Stderr, "abox: %v\n", err) } - if !*resume { - archive, err := repository.ArchiveHEAD(snap.Root) - if err != nil { - return err - } + if !resuming { if err := sb.TransferArchive(context.Background(), archive); err != nil { - fmt.Fprintf(os.Stderr, "abox: repo transfer: %v\n", err) + return fmt.Errorf("source transfer: %w", err) } } } @@ -205,7 +190,7 @@ func run() error { return runExec(sb, *prompt) } var transcript []string - if *resume { + if resuming { transcript = resumeLog(sess, sb) if len(transcript) > 0 { _ = session.WriteTranscript(sess.TranscriptPath(), transcript) @@ -298,21 +283,6 @@ func runExec(sb *runtime.Sandbox, prompt string) error { return err } -func loadResumeSession(wd, id string) (*session.Session, error) { - if id != "" { - return session.Load(id) - } - abs, err := filepath.Abs(wd) - if err != nil { - abs = wd - } - roots := []string{abs} - if top, err := repository.TopLevel(wd); err == nil { - roots = append(roots, top) - } - return session.LatestForRepo(roots...) -} - func runMCP(args []string) error { if len(args) == 0 { return fmt.Errorf("usage: abox mcp add --mode [--credential-env NAME] \n abox mcp login ") diff --git a/docs/_includes/examples/sdk-resume.go b/docs/_includes/examples/sdk-resume.go index 3a0c04e..07e06aa 100644 --- a/docs/_includes/examples/sdk-resume.go +++ b/docs/_includes/examples/sdk-resume.go @@ -12,11 +12,11 @@ import ( func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - id := "" - if len(os.Args) > 1 { - id = os.Args[1] + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: sdk-resume ") + os.Exit(2) } - sess, err := abox.Resume(ctx, id, abox.Options{}) + sess, err := abox.Resume(ctx, os.Args[1], abox.Options{}) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/docs/api.md b/docs/api.md index b6149a0..577e2ab 100644 --- a/docs/api.md +++ b/docs/api.md @@ -24,7 +24,8 @@ func Resume(ctx context.Context, sessionID string, opts Options) (*Session, erro func (s *Session) Close() error ``` -`Resume("", opts)` picks the latest session for `opts.RepoPath`. +`Resume` requires a non-empty session id. Session selection is independent of +`opts.RepoPath`, the current directory, and host Git state. Both require a protocol-4 guest. Older disks return `ErrGuestTooOld`. `Open` also scrubs leftover plaintext secrets out of `~/.abox/sessions` @@ -36,7 +37,7 @@ Always `defer sess.Close()`. `Close` stops the VM and the host broker. | Field | Type | Default | | --- | --- | --- | -| `RepoPath` | `string` | cwd | +| `RepoPath` | `string` | cwd; exact source directory snapshotted by `Open` | | `Model` | `string` | first profile in `config.yaml` | | `Image` | `string` | config / `~/.abox/images/abox-guest.raw` | | `VMMPath` | `string` | config or `abox-vmm` on `PATH` | diff --git a/docs/cli.md b/docs/cli.md index 0c68803..4792db0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,8 +16,7 @@ permalink: /cli/ ## Commands ```bash -abox # TUI, new session for this repo -abox --resume # latest session for this repo +abox # TUI, new session from the current directory abox --resume # that session's root.raw abox --model grok-default # profile name from config.yaml abox --probe-vm # boot + list_files; no model call @@ -32,6 +31,9 @@ Headless uses the same agent, broker, and approval paths as the TUI. There is no TUI approver, so model-authored `run_command` is **denied**. See [Approvals]({{ '/approvals' | relative_url }}). +New sessions print their id. Resume always requires that id and does not use +the current directory, Git repository, branch, or `HEAD` to choose a session. + `--probe-vm` does not need a provider key. It is also the only path that will still talk to a pre-protocol-4 guest (list files only). diff --git a/docs/concepts.md b/docs/concepts.md index 0b01289..feb2ce8 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -112,9 +112,15 @@ Model-authored `run_command` asks the host before exec. Default is deny. Host-initiated `Session.RunCommand` / `abox --probe-vm` are supervisor RPCs, not model tool calls, and do not go through that gate. -## Repo snapshot - -`Open` prefers a clean committed Git worktree and tars `HEAD` into the guest. -If Git is missing, dirty, or has no commits, ABox copies the files into a -private snapshot and leaves host Git alone. Submodules are not supported. -`Resume` does not recopy the host tree (that would overwrite guest work). +## Source snapshot + +`Open` snapshots exactly the configured source directory into the guest. It +does not discover a Git root, inspect branches or `HEAD`, or require Git on the +host. `.git` files and directories are excluded, and symlinks and special files +are rejected. The guest creates its own private Git baseline after transfer so +patch export remains available. `Resume(id)` boots the existing disk and does +not recopy the host source directory. + +Git ignore rules do not control this snapshot. All regular files and dotfiles +other than `.git` metadata are included, so the selected source directory must +contain only files the guest is allowed to read. diff --git a/docs/examples.md b/docs/examples.md index cb0bf94..66f9987 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -33,7 +33,7 @@ on `PATH` (the `darwin_arm64` archive on the image and provider key: [Quickstart]({{ '/quickstart' | relative_url }}). Probe methods do not need a key. A successful `Open` speaks protocol 4. -The CLI is `abox`, `abox --resume`, `abox --model`, `abox --probe-vm`, +The CLI is `abox`, `abox --resume `, `abox --model`, `abox --probe-vm`, `abox exec --prompt`, `abox mcp add` / `mcp login`, and `abox creds migrate`. There are no extra flags for `ReadFile`, `RunCommand`, `ExportPatch`, `MaxTurns`, or live `SetMCPTokens` — those stay on the SDK page as @@ -42,7 +42,7 @@ SDK-only. Full command list: [CLI and TUI]({{ '/cli' | relative_url }}). | Call | Sample | CLI | | --- | --- | --- | | `Open` + `Turn` | [Basic turn]({{ '/examples/basic' | relative_url }}) | `abox`, `abox exec --prompt` | -| `Resume` | [Resume]({{ '/examples/resume' | relative_url }}) | `abox --resume` | +| `Resume` | [Resume]({{ '/examples/resume' | relative_url }}) | `abox --resume ` | | `Turn` + canceled `ctx` | [Cancel]({{ '/examples/cancel' | relative_url }}) | Ctrl+C | | `TurnOpts` | [Turn options]({{ '/examples/turn-opts' | relative_url }}) | SDK-only (`MaxTurns` / `Timeout`) | | `ListFiles` | [List files]({{ '/examples/list-files' | relative_url }}) | `abox --probe-vm` | @@ -54,7 +54,7 @@ SDK-only. Full command list: [CLI and TUI]({{ '/cli' | relative_url }}). | `Turn` events | [Print events]({{ '/examples/print-events' | relative_url }}) | `abox exec --prompt` | | `Capabilities` | [Capabilities]({{ '/examples/capabilities' | relative_url }}) | protocol 4 required | | `Options` | [Custom VM]({{ '/examples/custom-vm' | relative_url }}) | `~/.abox/config.yaml` | -| `ErrGuestTooOld` | [Errors]({{ '/examples/errors' | relative_url }}) | `abox --resume` of an old disk | -| `History` | [History]({{ '/examples/history' | relative_url }}) | `abox --resume` | +| `ErrGuestTooOld` | [Errors]({{ '/examples/errors' | relative_url }}) | `abox --resume ` of an old disk | +| `History` | [History]({{ '/examples/history' | relative_url }}) | `abox --resume ` | | `Turn` twice | [Multi-turn]({{ '/examples/multi-turn' | relative_url }}) | `abox` (TUI) | | `SetMCPTokens` | [MCP tokens]({{ '/examples/mcp-tokens' | relative_url }}) | `abox mcp add` / `mcp login` | diff --git a/docs/examples/export-patch.md b/docs/examples/export-patch.md index 3b96314..0c15048 100644 --- a/docs/examples/export-patch.md +++ b/docs/examples/export-patch.md @@ -20,7 +20,7 @@ There is no `abox export` (or similar). Guest edits stay on that session's ```bash abox # ask the agent to add a file -abox --resume # later; same disk +abox --resume # later; same disk ``` Dumping `git diff` vs the imported baseline is SDK `ExportPatch`. diff --git a/docs/examples/history.md b/docs/examples/history.md index c963bf1..833ee73 100644 --- a/docs/examples/history.md +++ b/docs/examples/history.md @@ -20,7 +20,7 @@ missing). ```bash abox # new session; hello history is empty -abox --resume # same conversation in the TUI +abox --resume # same conversation in the TUI ``` `abox exec` is one prompt; it does not dump history. diff --git a/docs/examples/list-files.md b/docs/examples/list-files.md index 609096c..55da0e3 100644 --- a/docs/examples/list-files.md +++ b/docs/examples/list-files.md @@ -16,7 +16,7 @@ No model call. Same RPC as `abox --probe-vm` (path `.`, depth 4, limit 50). ```bash abox --probe-vm -abox --resume --probe-vm # existing disk; still no model +abox --resume --probe-vm # existing disk; still no model ``` No provider key. `--probe-vm` is also the only CLI path that still talks to a diff --git a/docs/examples/multi-turn.md b/docs/examples/multi-turn.md index f01fd03..3840363 100644 --- a/docs/examples/multi-turn.md +++ b/docs/examples/multi-turn.md @@ -15,14 +15,14 @@ Two `Turn`s on the same VM. Guest context persists for the process lifetime (and ## CLI The TUI is multi-turn on one VM. Guest context persists on `root.raw` after -quit, for `--resume`. +quit, for `--resume `. ```bash abox # Remember the codeword: cedar. Reply ok. # What was the codeword? -abox --resume +abox --resume ``` `abox exec` is a single prompt. Two turns in one process is SDK (or two diff --git a/docs/examples/resume.md b/docs/examples/resume.md index 41f731a..9ba29b9 100644 --- a/docs/examples/resume.md +++ b/docs/examples/resume.md @@ -10,18 +10,19 @@ permalink: /examples/resume/ `abox.Resume` on the one SDK, [`pkg/abox`]({{ '/api' | relative_url }}). -Boots an existing `root.raw`. Empty id = latest session for this repo (`go run .` with no args). Pass a session id as `os.Args[1]`. The guest binary on that disk is whatever was cloned when the session was created. Protocol 4 is required; resume of a pre-rebuild disk returns `ErrGuestTooOld`. +Boots an existing `root.raw` by session id (`go run . `). The guest binary +on that disk is whatever was cloned when the session was created. Protocol 4 +is required; resume of a pre-rebuild disk returns `ErrGuestTooOld`. ## CLI ```bash -abox --resume # latest session for this repo abox --resume # that session's root.raw -abox exec --resume --prompt "Summarize what we already did in this session." +abox exec --resume --prompt "Summarize what we already did in this session." ``` Ids are directory names under `~/.abox/sessions/`. Resume boots the existing -`root.raw`; it does not re-copy the host git tree. Same protocol-4 rule as +`root.raw`; it does not re-copy the host source directory. Same protocol-4 rule as the SDK: an old disk fails at start ([Errors]({{ '/examples/errors' | relative_url }})). ## SDK diff --git a/docs/index.md b/docs/index.md index a5574d3..2e5047b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,12 +47,12 @@ The SDK boots the same microVM as the CLI. A current guest speaks **protocol 4** | Capability | Detail | | --- | --- | -| Session | Clone golden disk, boot libkrun, snapshot repo into `/work/repo` | +| Session | Snapshot a source directory, clone the golden disk, and boot libkrun | | Turn | Stream `text` / `tool` / `result` / `done` events; optional usage | | Cancel | `ctx` cancel → `cancel_turn`; kills in-flight `run_command` | | Tools | Guest `list_files`, `read_file`, `search`, `apply_patch`, `run_command` + host-brokered MCP | | Approvals | `SetApprover` for model-authored `run_command` (default deny) | -| Resume | Boot an existing `root.raw` (`Resume`, same as `abox --resume`) | +| Resume | Boot an existing `root.raw` by session id (`Resume`, same as `abox --resume `) | | Probe | `ListFiles` / `ReadFile` / `RunCommand` without a model turn | ## What it does not do diff --git a/docs/quickstart.md b/docs/quickstart.md index 58a7f15..28ea0c0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -47,8 +47,9 @@ You can also write `~/.abox/credentials.env` yourself with `XAI_API_KEY=…` ## First program -One import: `github.com/AdminTurnedDevOps/ABox/pkg/abox`. Work from any git -repo (the SDK snapshots that tree into the guest). +One import: `github.com/AdminTurnedDevOps/ABox/pkg/abox`. Work from any +directory; the SDK snapshots exactly that directory into the guest without +requiring or inspecting host Git. ```bash go get github.com/AdminTurnedDevOps/ABox@latest diff --git a/docs/sessions.md b/docs/sessions.md index ff5d75e..b6c0c1f 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -19,13 +19,14 @@ the home): | `root.raw` | Writable VM disk (`/dev/vda`) | | `config.raw` | Sealed config (`/dev/vdb`): session id + model alias. **No secrets** | | `guest-config.json` | Host-side copy of that config, also secretless | -| `session.json` | Host metadata (id, repo root, HEAD, created) | +| `session.json` | Host metadata (id, source directory, created) | | `transcript.json` | CLI TUI log (SDK does not write this) | | `console.log` | Guest serial | | `rpc.sock` | Host vsock proxy | -`Open` creates a new id. `Resume(id)` or `Resume("")` (latest for repo) boots -that `root.raw` again. The host git tree is not copied on resume. +`Open` creates a new id. `Resume(id)` boots that `root.raw` again. An id is +required; resume does not infer session identity from a directory or Git state. +The host source directory is not copied on resume. On every start, ABox scrubs leftover plaintext secrets out of `config.raw` and `guest-config.json` (including leftover sessions under the old @@ -34,7 +35,7 @@ and `guest-config.json` (including leftover sessions under the old ## Lifetime ```text -Open → clone golden → write secretless config → boot → tar HEAD into /work/repo +Open → snapshot source directory → clone golden → boot → transfer into /work/repo Turn → user_turn / agent_event (repeat); host brokers HTTPS Close → shutdown RPC, SIGINT abox-vmm ``` @@ -51,7 +52,7 @@ Guest conversation state lives on the session disk at | --- | --- | --- | | Disk | New clone of golden | Existing `root.raw` | | Guest binary | Whatever was in golden **at clone time** | Same as when that session was created | -| Repo | Fresh tar of current HEAD | Guest files already on disk | +| Source files | Fresh snapshot of exact configured directory | Guest files already on disk | | Config disk | Secretless, current model | Rewritten secretless; old keys stripped | To pick up a new `abox-guest` (protocol 4), `make image-update` then **Open**, diff --git a/examples/sdk-resume/main.go b/examples/sdk-resume/main.go index 3a0c04e..07e06aa 100644 --- a/examples/sdk-resume/main.go +++ b/examples/sdk-resume/main.go @@ -12,11 +12,11 @@ import ( func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - id := "" - if len(os.Args) > 1 { - id = os.Args[1] + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: sdk-resume ") + os.Exit(2) } - sess, err := abox.Resume(ctx, id, abox.Options{}) + sess, err := abox.Resume(ctx, os.Args[1], abox.Options{}) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/internal/guest/tools/tools.go b/internal/guest/tools/tools.go index 501e268..457c9ad 100644 --- a/internal/guest/tools/tools.go +++ b/internal/guest/tools/tools.go @@ -265,9 +265,11 @@ func (r Repo) InitBaseline() error { cfg = exec.Command("git", "config", "user.name", "abox-guest") cfg.Dir = r.Root _ = cfg.Run() - add := exec.Command("git", "add", "-A") + add := exec.Command("git", "add", "-f", "-A") add.Dir = r.Root - _ = add.Run() + if out, err := add.CombinedOutput(); err != nil { + return fmt.Errorf("git add baseline: %w: %s", err, out) + } commit := exec.Command("git", "commit", "--allow-empty", "-m", "abox baseline") commit.Dir = r.Root commit.Env = []string{ diff --git a/internal/guest/tools/tools_test.go b/internal/guest/tools/tools_test.go index 9955cdb..3096b6a 100644 --- a/internal/guest/tools/tools_test.go +++ b/internal/guest/tools/tools_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "time" @@ -101,3 +102,28 @@ func TestListAndRead(t *testing.T) { t.Fatalf("read %q bin=%v err=%v", content, bin, err) } } + +func TestInitBaselineTracksFilesIgnoredBySourceRules(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil { + t.Fatal(err) + } + secret := filepath.Join(dir, "local.env") + if err := os.WriteFile(secret, []byte("before\n"), 0o600); err != nil { + t.Fatal(err) + } + r := Repo{Root: dir} + if err := r.InitBaseline(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(secret, []byte("after\n"), 0o600); err != nil { + t.Fatal(err) + } + patch, _, err := r.ExportPatch() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(patch, "local.env") || !strings.Contains(patch, "+after") { + t.Fatalf("ignored source file was not tracked in guest baseline:\n%s", patch) + } +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 6d63e2a..8aedd51 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -1,200 +1,120 @@ +// Package repository snapshots a host source directory for transfer into the +// guest. It does not inspect or modify host version-control state. package repository import ( + "archive/tar" "bytes" "fmt" + "io" + "io/fs" "os" - "os/exec" "path/filepath" - "strings" ) -type Snapshot struct { - Root string - HEAD string - Ephemeral bool - HostSource string -} - -func ValidateClean(start string) (Snapshot, error) { - root, err := gitOutput(start, "rev-parse", "--show-toplevel") - if err != nil { - return Snapshot{}, fmt.Errorf("not a git worktree: %w", err) - } - head, err := gitOutput(root, "rev-parse", "HEAD") - if err != nil { - return Snapshot{}, fmt.Errorf("repository has no commits; create an initial commit so ABox can snapshot HEAD") - } - status, err := gitOutput(root, "status", "--porcelain") - if err != nil { - return Snapshot{}, fmt.Errorf("git status: %w", err) - } - if strings.TrimSpace(status) != "" { - return Snapshot{}, fmt.Errorf("worktree is not clean; commit or stash before starting ABox") - } - if hasUnsupportedSubmodules(root) { - return Snapshot{}, fmt.Errorf("submodules are not supported in milestone one") - } - return Snapshot{Root: root, HEAD: head, HostSource: root}, nil -} +const ( + maxArchiveEntries = 20000 + maxArchiveFile = 32 << 20 + maxArchiveBytes = 256 << 20 +) -// OpenForSession uses a clean committed worktree when one exists. -// Otherwise it copies the repository worktree into scratchDir, makes a -// private commit there, and returns that. The host Git repo is not changed. -func OpenForSession(start, scratchDir string) (Snapshot, error) { - root, err := TopLevel(start) - if err != nil { - return Snapshot{}, fmt.Errorf("not a git worktree: %w", err) - } - if snap, err := ValidateClean(root); err == nil { - return snap, nil - } - if hasUnsupportedSubmodules(root) { - return Snapshot{}, fmt.Errorf("submodules are not supported in milestone one") - } - if err := copyWorktree(root, scratchDir); err != nil { - return Snapshot{}, fmt.Errorf("ephemeral snapshot: %w", err) - } - if err := initScratchRepo(scratchDir); err != nil { - return Snapshot{}, err - } - snap, err := ValidateClean(scratchDir) +// ArchiveDirectory creates a bounded tar snapshot of exactly sourceDir. Git +// metadata is excluded because the guest creates its own private baseline. +func ArchiveDirectory(sourceDir string) (string, []byte, error) { + root, err := filepath.Abs(sourceDir) if err != nil { - return Snapshot{}, fmt.Errorf("ephemeral snapshot: %w", err) + return "", nil, fmt.Errorf("resolve source directory: %w", err) } - snap.Ephemeral = true - snap.HostSource = root - return snap, nil -} - -func StillClean(s Snapshot) error { - cur, err := ValidateClean(s.Root) + root = filepath.Clean(root) + info, err := os.Stat(root) if err != nil { - return err - } - if cur.HEAD != s.HEAD { - return fmt.Errorf("host HEAD moved from %s to %s", s.HEAD, cur.HEAD) + return "", nil, fmt.Errorf("source directory: %w", err) } - return nil -} - -func ArchiveHEAD(root string) ([]byte, error) { - cmd := exec.Command("git", "archive", "--format=tar", "HEAD") - cmd.Dir = root - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("git archive: %w: %s", err, stderr.String()) + if !info.IsDir() { + return "", nil, fmt.Errorf("source path is not a directory: %s", root) } - return stdout.Bytes(), nil -} -func TopLevel(start string) (string, error) { - return gitOutput(start, "rev-parse", "--show-toplevel") -} - -func gitOutput(dir string, args ...string) (string, error) { - cmd := exec.Command("git", args...) - cmd.Dir = dir - var stderr bytes.Buffer - cmd.Stderr = &stderr - out, err := cmd.Output() - if err != nil { - msg := strings.TrimSpace(stderr.String()) - if msg != "" { - return "", fmt.Errorf("%w: %s", err, msg) + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + entries := 0 + totalBytes := int64(0) + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr } - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -func hasUnsupportedSubmodules(root string) bool { - _, err := os.Stat(filepath.Join(root, ".gitmodules")) - return err == nil -} - -func copyWorktree(src, dst string) error { - if err := os.MkdirAll(dst, 0o700); err != nil { - return err - } - cmd := exec.Command("git", "ls-files", "--cached", "--others", "--exclude-standard", "-z") - cmd.Dir = src - var stderr bytes.Buffer - cmd.Stderr = &stderr - out, err := cmd.Output() - if err != nil { - return fmt.Errorf("git ls-files: %w: %s", err, strings.TrimSpace(stderr.String())) - } - for _, name := range bytes.Split(out, []byte{0}) { - if len(name) == 0 { - continue - } - rel := filepath.FromSlash(string(name)) - clean := filepath.Clean(rel) - if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { - return fmt.Errorf("unsafe repository path %q", rel) - } - path := src - var info os.FileInfo - parts := strings.Split(clean, string(filepath.Separator)) - for i, part := range parts { - path = filepath.Join(path, part) - info, err = os.Lstat(path) - if err != nil { - break - } - if info.Mode()&os.ModeSymlink != 0 || i < len(parts)-1 && !info.IsDir() { - return fmt.Errorf("unsupported file type %q", rel) + if path == root { + return nil + } + if entry.Name() == ".git" { + if entry.IsDir() { + return fs.SkipDir } + return nil + } + + entries++ + if entries > maxArchiveEntries { + return fmt.Errorf("source directory has more than %d entries", maxArchiveEntries) } + info, err := entry.Info() if err != nil { - if os.IsNotExist(err) { - continue // A tracked file deleted in the dirty worktree stays deleted. - } return err } - if !info.Mode().IsRegular() { - return fmt.Errorf("unsupported file type %q", rel) + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() && !info.Mode().IsRegular() { + return fmt.Errorf("unsupported file type %q", path) } - target := filepath.Join(dst, clean) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err + if info.Size() > maxArchiveFile { + return fmt.Errorf("file %q exceeds %d bytes", path, maxArchiveFile) } - data, err := os.ReadFile(path) + + rel, err := filepath.Rel(root, path) if err != nil { return err } - if err := os.WriteFile(target, data, info.Mode().Perm()); err != nil { + header, err := tar.FileInfoHeader(info, "") + if err != nil { return err } - if err := os.Chmod(target, info.Mode().Perm()); err != nil { + header.Name = filepath.ToSlash(rel) + if info.IsDir() { + header.Name += "/" + } + if err := tw.WriteHeader(header); err != nil { return err } - } - return nil -} + if info.IsDir() { + return nil + } -func initScratchRepo(dir string) error { - cmds := [][]string{ - {"git", "init", "-b", "main"}, - {"git", "add", "-A"}, - {"git", "commit", "--allow-empty", "-m", "abox ephemeral snapshot"}, - } - env := append(os.Environ(), - "GIT_AUTHOR_NAME=abox", - "GIT_AUTHOR_EMAIL=abox@local", - "GIT_COMMITTER_NAME=abox", - "GIT_COMMITTER_EMAIL=abox@local", - ) - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - cmd.Env = env - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%s: %w: %s", strings.Join(args, " "), err, out) + totalBytes += info.Size() + if totalBytes > maxArchiveBytes { + return fmt.Errorf("source directory exceeds %d bytes", maxArchiveBytes) + } + file, err := os.Open(path) + if err != nil { + return err + } + openedInfo, statErr := file.Stat() + if statErr != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + file.Close() + if statErr != nil { + return statErr + } + return fmt.Errorf("source file changed while snapshotting: %q", path) } + _, copyErr := io.CopyN(tw, file, info.Size()) + closeErr := file.Close() + if copyErr != nil { + return fmt.Errorf("snapshot %q: %w", path, copyErr) + } + return closeErr + }) + if err != nil { + _ = tw.Close() + return "", nil, err + } + if err := tw.Close(); err != nil { + return "", nil, err } - return nil + return root, buf.Bytes(), nil } diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index 434b4cb..ecaa88b 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -5,237 +5,148 @@ import ( "bytes" "io" "os" - "os/exec" "path/filepath" "strings" "testing" ) -func TestValidateClean(t *testing.T) { - dir := t.TempDir() - run := func(args ...string) { - t.Helper() - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } - run("git", "init", "-b", "main") - os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hi"), 0o644) - run("git", "add", "a.txt") - run("git", "commit", "-m", "init") - - snap, err := ValidateClean(dir) - if err != nil { - t.Fatal(err) - } - if snap.HEAD == "" { - t.Fatal("empty HEAD") - } - - os.WriteFile(filepath.Join(dir, "a.txt"), []byte("dirty"), 0o644) - if _, err := ValidateClean(dir); err == nil { - t.Fatal("expected dirty tree error") - } +type archiveEntry struct { + body string + mode int64 + dir bool } -func TestOpenForSessionEphemeral(t *testing.T) { - dir := t.TempDir() - run := func(args ...string) { - t.Helper() - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) +func readArchive(t *testing.T, data []byte) map[string]archiveEntry { + t.Helper() + out := map[string]archiveEntry{} + tr := tar.NewReader(bytes.NewReader(data)) + for { + header, err := tr.Next() + if err == io.EOF { + return out + } + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + out[strings.TrimSuffix(header.Name, "/")] = archiveEntry{ + body: string(body), mode: header.Mode, dir: header.FileInfo().IsDir(), } } - run("git", "init", "-b", "main") - if err := os.WriteFile(filepath.Join(dir, "script.sh"), []byte("#!/bin/sh\necho clean\n"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "tracked.log"), []byte("clean"), 0o644); err != nil { +} + +func TestArchiveDirectorySnapshotsPlainDirectory(t *testing.T) { + t.Setenv("PATH", "") + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "nested", "empty"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "deleted.txt"), []byte("remove me"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, ".env"), []byte("local=value"), 0o600); err != nil { t.Fatal(err) } - run("git", "add", "script.sh", "tracked.log", "deleted.txt") - run("git", "commit", "-m", "init") - - if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n*.log\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("ignored.txt\n"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, ".git", "info", "exclude"), []byte("info-secret\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, "ignored.txt"), []byte("included"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "script.sh"), []byte("#!/bin/sh\necho dirty\n"), 0o755); err != nil { + if err := os.WriteFile(filepath.Join(root, "nested", "script.sh"), []byte("#!/bin/sh\necho ok\n"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "tracked.log"), []byte("dirty"), 0o644); err != nil { + + source, data, err := ArchiveDirectory(root) + if err != nil { t.Fatal(err) } - if err := os.Remove(filepath.Join(dir, "deleted.txt")); err != nil { - t.Fatal(err) + wantSource, _ := filepath.Abs(root) + if source != wantSource { + t.Fatalf("source=%q want %q", source, wantSource) } - if err := os.WriteFile(filepath.Join(dir, "ignored.env"), []byte("secret"), 0o600); err != nil { - t.Fatal(err) + entries := readArchive(t, data) + if got := entries[".env"].body; got != "local=value" { + t.Fatalf(".env=%q", got) } - if err := os.WriteFile(filepath.Join(dir, "info-secret"), []byte("secret"), 0o600); err != nil { - t.Fatal(err) + if got := entries["ignored.txt"].body; got != "included" { + t.Fatalf("ignored.txt=%q", got) } - subdir := filepath.Join(dir, "subdir") - if err := os.Mkdir(subdir, 0o755); err != nil { - t.Fatal(err) + if got := entries["nested/script.sh"]; got.body != "#!/bin/sh\necho ok\n" || got.mode&0o111 == 0 { + t.Fatalf("script=%+v", got) } - oddName := "untracked\nfile.txt" - if err := os.WriteFile(filepath.Join(subdir, oddName), []byte("included"), 0o644); err != nil { - t.Fatal(err) + if got := entries["nested/empty"]; !got.dir { + t.Fatalf("empty directory=%+v", got) } +} - scratch := t.TempDir() - snap, err := OpenForSession(subdir, scratch) - if err != nil { +func TestArchiveDirectoryUsesExactDirectoryAndExcludesGitMetadata(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "chosen") + if err := os.MkdirAll(filepath.Join(source, ".git", "objects"), 0o755); err != nil { t.Fatal(err) } - if !snap.Ephemeral { - t.Fatal("expected ephemeral snapshot") - } - hostInfo, err := os.Stat(snap.HostSource) - if err != nil { + if err := os.MkdirAll(filepath.Join(source, "nested", ".git"), 0o755); err != nil { t.Fatal(err) } - dirInfo, err := os.Stat(dir) - if err != nil { + if err := os.WriteFile(filepath.Join(root, "outside.txt"), []byte("outside"), 0o644); err != nil { t.Fatal(err) } - if !os.SameFile(hostInfo, dirInfo) { - t.Fatalf("HostSource=%q is not repository root %q", snap.HostSource, dir) - } - for name, want := range map[string]string{ - "script.sh": "#!/bin/sh\necho dirty\n", - "tracked.log": "dirty", - ".gitignore": "*.env\n*.log\n", - filepath.Join("subdir", oddName): "included", - } { - got, err := os.ReadFile(filepath.Join(snap.Root, name)) - if err != nil { - t.Fatalf("read %q: %v", name, err) - } - if string(got) != want { - t.Fatalf("%q=%q want %q", name, got, want) - } - } - for _, name := range []string{"deleted.txt", "ignored.env", "info-secret"} { - if _, err := os.Stat(filepath.Join(snap.Root, name)); !os.IsNotExist(err) { - t.Fatalf("excluded file %q entered host-tree: %v", name, err) - } - } - info, err := os.Stat(filepath.Join(snap.Root, "script.sh")) - if err != nil { + if err := os.WriteFile(filepath.Join(source, "inside.txt"), []byte("inside"), 0o644); err != nil { t.Fatal(err) } - if info.Mode().Perm()&0o111 == 0 { - t.Fatalf("script mode=%o, executable bit lost", info.Mode().Perm()) + if err := os.WriteFile(filepath.Join(source, ".git", "HEAD"), []byte("secret metadata"), 0o644); err != nil { + t.Fatal(err) } - archive, err := ArchiveHEAD(snap.Root) + _, data, err := ArchiveDirectory(source) if err != nil { t.Fatal(err) } - archived := make(map[string]int64) - tr := tar.NewReader(bytes.NewReader(archive)) - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - t.Fatal(err) - } - archived[hdr.Name] = hdr.Mode + entries := readArchive(t, data) + if _, ok := entries["inside.txt"]; !ok { + t.Fatal("selected directory file missing") } - for _, name := range []string{"deleted.txt", "ignored.env", "info-secret"} { - if _, ok := archived[name]; ok { - t.Fatalf("excluded file %q entered archive", name) + for name := range entries { + if name == "outside.txt" || name == ".git" || strings.Contains(name, "/.git") { + t.Fatalf("unexpected archive entry %q", name) } } - if archived["script.sh"]&0o111 == 0 { - t.Fatalf("archived script mode=%o, executable bit lost", archived["script.sh"]) - } - if _, ok := archived[filepath.ToSlash(filepath.Join("subdir", oddName))]; !ok { - t.Fatalf("NUL-delimited untracked name missing from archive: %#v", archived) - } } -func TestOpenForSessionRejectsSymlink(t *testing.T) { - dir := t.TempDir() - cmd := exec.Command("git", "init", "-b", "main") - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git init: %v: %s", err, out) - } - if err := os.WriteFile(filepath.Join(dir, "target"), []byte("data"), 0o644); err != nil { +func TestArchiveDirectoryRejectsSymlink(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "target"), []byte("data"), 0o644); err != nil { t.Fatal(err) } - if err := os.Symlink("target", filepath.Join(dir, "link")); err != nil { + if err := os.Symlink("target", filepath.Join(root, "link")); err != nil { t.Skipf("symlinks unavailable: %v", err) } - _, err := OpenForSession(dir, t.TempDir()) + _, _, err := ArchiveDirectory(root) if err == nil || !strings.Contains(err.Error(), "unsupported file type") { t.Fatalf("got %v", err) } } -func TestOpenForSessionRejectsSymlinkedDirectory(t *testing.T) { - dir := t.TempDir() - run := func(args ...string) { - t.Helper() - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } - run("git", "init", "-b", "main") - nested := filepath.Join(dir, "nested") - if err := os.Mkdir(nested, 0o755); err != nil { +func TestArchiveDirectoryRejectsInvalidSource(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("data"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(nested, "file.txt"), []byte("inside"), 0o644); err != nil { - t.Fatal(err) + if _, _, err := ArchiveDirectory(file); err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("file error=%v", err) } - run("git", "add", "nested/file.txt") - run("git", "commit", "-m", "init") - - outside := t.TempDir() - if err := os.WriteFile(filepath.Join(outside, "file.txt"), []byte("outside"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.RemoveAll(nested); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, nested); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - _, err := OpenForSession(dir, t.TempDir()) - if err == nil || !strings.Contains(err.Error(), "unsupported file type") { - t.Fatalf("got %v", err) + if _, _, err := ArchiveDirectory(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("expected missing-directory error") } } -func TestValidateCleanEmptyRepo(t *testing.T) { - dir := t.TempDir() - cmd := exec.Command("git", "init", "-b", "main") - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git init: %v: %s", err, out) - } - _, err := ValidateClean(dir) - if err == nil || !strings.Contains(err.Error(), "no commits") { - t.Fatalf("got %v", err) +func TestArchiveDirectorySupportsEmptyDirectory(t *testing.T) { + _, data, err := ArchiveDirectory(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if entries := readArchive(t, data); len(entries) != 0 { + t.Fatalf("entries=%v", entries) } } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 85a3b25..f0b242c 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -30,7 +30,7 @@ func TestCloneFileCopiesContents(t *testing.T) { func TestPrepareResumeDoesNotClobberRoot(t *testing.T) { t.Setenv("HOME", t.TempDir()) - s, err := session.Create("/repo", "head") + s, err := session.Create("/source") if err != nil { t.Fatal(err) } @@ -60,7 +60,7 @@ func TestPrepareResumeDoesNotClobberRoot(t *testing.T) { func TestPrepareResumeRewritesReadOnlyConfig(t *testing.T) { t.Setenv("HOME", t.TempDir()) - s, err := session.Create("/repo", "head") + s, err := session.Create("/source") if err != nil { t.Fatal(err) } diff --git a/internal/session/session.go b/internal/session/session.go index 56a28e3..b4ebeb5 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -17,12 +17,11 @@ type Session struct { ID string `json:"id"` Capability string `json:"capability"` Created time.Time `json:"created"` - RepoRoot string `json:"repo_root"` - HEAD string `json:"head"` + SourceDir string `json:"source_dir,omitempty"` Dir string `json:"dir"` } -func Create(repoRoot, head string) (*Session, error) { +func Create(sourceDir string) (*Session, error) { id, err := randomHex(16) if err != nil { return nil, err @@ -42,8 +41,7 @@ func Create(repoRoot, head string) (*Session, error) { ID: id, Capability: cap, Created: time.Now().UTC(), - RepoRoot: repoRoot, - HEAD: head, + SourceDir: sourceDir, Dir: dir, } if err := s.WriteMeta(); err != nil { @@ -53,8 +51,8 @@ func Create(repoRoot, head string) (*Session, error) { } func Load(id string) (*Session, error) { - if id == "" { - return nil, fmt.Errorf("empty session id") + if !validID(id) { + return nil, fmt.Errorf("invalid session id %q", id) } dir := filepath.Join(config.SessionRoot(), id) data, err := os.ReadFile(filepath.Join(dir, "session.json")) @@ -73,59 +71,6 @@ func Load(id string) (*Session, error) { return &s, nil } -// LatestForRepo returns the newest session whose RepoRoot matches any of roots -// and that still has a root.raw disk. -func LatestForRepo(roots ...string) (*Session, error) { - want := map[string]struct{}{} - for _, r := range roots { - if r == "" { - continue - } - abs, err := filepath.Abs(r) - if err != nil { - abs = filepath.Clean(r) - } - want[abs] = struct{}{} - want[filepath.Clean(r)] = struct{}{} - } - if len(want) == 0 { - return nil, fmt.Errorf("no repository root to match") - } - entries, err := os.ReadDir(config.SessionRoot()) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("no sessions to resume") - } - return nil, err - } - var best *Session - for _, e := range entries { - if !e.IsDir() { - continue - } - s, err := Load(e.Name()) - if err != nil { - continue - } - root := s.RepoRoot - if abs, err := filepath.Abs(root); err == nil { - root = abs - } - if _, ok := want[root]; !ok { - if _, ok := want[filepath.Clean(s.RepoRoot)]; !ok { - continue - } - } - if best == nil || s.Created.After(best.Created) { - best = s - } - } - if best == nil { - return nil, fmt.Errorf("no session to resume for this repository") - } - return best, nil -} - func (s *Session) WriteMeta() error { data, err := json.MarshalIndent(s, "", " ") if err != nil { @@ -221,3 +166,11 @@ func randomHex(n int) (string, error) { } return hex.EncodeToString(b), nil } + +func validID(id string) bool { + if len(id) != 32 { + return false + } + _, err := hex.DecodeString(id) + return err == nil +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 8b89df9..2bd8b08 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -5,14 +5,13 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/AdminTurnedDevOps/ABox/internal/config" ) func TestWriteGuestConfigExcludesMCPAndSecrets(t *testing.T) { t.Setenv("HOME", t.TempDir()) - s, err := Create("/repo", "deadbeef") + s, err := Create("/source") if err != nil { t.Fatal(err) } @@ -74,7 +73,7 @@ func TestWritePaddedConfigLayout(t *testing.T) { func TestLoadRequiresRootRaw(t *testing.T) { t.Setenv("HOME", t.TempDir()) - s, err := Create("/repo/a", "h1") + s, err := Create("/source/a") if err != nil { t.Fatal(err) } @@ -88,60 +87,14 @@ func TestLoadRequiresRootRaw(t *testing.T) { if err != nil { t.Fatal(err) } - if got.ID != s.ID || got.RepoRoot != "/repo/a" { + if got.ID != s.ID || got.SourceDir != "/source/a" { t.Fatalf("%#v", got) } } -func TestLatestForRepoPicksNewestMatching(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - old, err := Create("/repo/app", "h1") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(old.RootDisk(), []byte("a"), 0o600); err != nil { - t.Fatal(err) - } - time.Sleep(5 * time.Millisecond) - other, err := Create("/repo/other", "h2") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(other.RootDisk(), []byte("b"), 0o600); err != nil { - t.Fatal(err) - } - time.Sleep(5 * time.Millisecond) - newer, err := Create("/repo/app", "h3") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(newer.RootDisk(), []byte("c"), 0o600); err != nil { - t.Fatal(err) - } - got, err := LatestForRepo("/repo/app") - if err != nil { - t.Fatal(err) - } - if got.ID != newer.ID { - t.Fatalf("got %s want %s", got.ID, newer.ID) - } -} - -func TestLatestForRepoSkipsMissingDisk(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - s, err := Create("/repo/app", "h1") - if err != nil { - t.Fatal(err) - } - _ = s - if _, err := LatestForRepo("/repo/app"); err == nil { - t.Fatal("expected error when root.raw missing") - } -} - func TestTranscriptRoundTrip(t *testing.T) { t.Setenv("HOME", t.TempDir()) - s, err := Create("/repo", "h") + s, err := Create("/source") if err != nil { t.Fatal(err) } @@ -165,12 +118,11 @@ func TestReadTranscriptMissing(t *testing.T) { } } -func TestLatestForRepoNone(t *testing.T) { +func TestLoadRejectsInvalidSessionID(t *testing.T) { t.Setenv("HOME", t.TempDir()) - if err := os.MkdirAll(config.SessionRoot(), 0o700); err != nil { - t.Fatal(err) - } - if _, err := LatestForRepo(filepath.Join(t.TempDir(), "nope")); err == nil { - t.Fatal("expected no session error") + for _, id := range []string{"", "../outside", "not-hex", strings.Repeat("a", 31)} { + if _, err := Load(id); err == nil { + t.Fatalf("expected %q to be rejected", id) + } } } diff --git a/internal/tui/render_test.go b/internal/tui/render_test.go index 6300122..8d070b7 100644 --- a/internal/tui/render_test.go +++ b/internal/tui/render_test.go @@ -193,7 +193,7 @@ func TestRenderApprovalMarksTheSelectedChoice(t *testing.T) { } } -func TestRenderApprovalDefaultsWorkdirToRepoRoot(t *testing.T) { +func TestRenderApprovalDefaultsWorkdirToGuestSource(t *testing.T) { got := renderApproval(newTheme(false), protocol.RunCommandApprovalParams{Command: "ls", TimeoutSec: 5}, false, 60) if !strings.Contains(got, protocol.GuestRepoDir) { t.Errorf("empty workdir should display the guest repo root %q:\n%s", protocol.GuestRepoDir, got) diff --git a/pkg/abox/abox.go b/pkg/abox/abox.go index 48f3d89..3c0a2cc 100644 --- a/pkg/abox/abox.go +++ b/pkg/abox/abox.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "sync" "time" @@ -50,11 +49,20 @@ func Open(ctx context.Context, opts Options) (*Session, error) { } func Resume(ctx context.Context, sessionID string, opts Options) (*Session, error) { + if sessionID == "" { + return nil, fmt.Errorf("session ID is required") + } return open(ctx, opts, true, sessionID) } func open(ctx context.Context, opts Options, resume bool, resumeID string) (*Session, error) { - opts = opts.withDefaults() + if resume { + if opts.BootTimeout == 0 { + opts.BootTimeout = 45 * time.Second + } + } else { + opts = opts.withDefaults() + } n, scrubErr := session.ScrubSecretsEverywhere() if n > 0 { fmt.Fprintf(os.Stderr, "abox: scrubbed plaintext secrets from %d old session(s)\n", n) @@ -78,33 +86,27 @@ func open(ctx context.Context, opts Options, resume bool, resumeID string) (*Ses } var sess *session.Session - var snap repository.Snapshot + var archive []byte if resume { - loaded, err := loadResume(opts.RepoPath, resumeID) + loaded, err := session.Load(resumeID) if err != nil { resolver.Close() return nil, err } sess = loaded } else { - created, err := session.Create(opts.RepoPath, "pending") + sourceDir, data, err := repository.ArchiveDirectory(opts.RepoPath) if err != nil { resolver.Close() - return nil, fmt.Errorf("create session: %w", err) + return nil, fmt.Errorf("snapshot source directory: %w", err) } - sess = created - opened, err := repository.OpenForSession(opts.RepoPath, filepath.Join(sess.Dir, "host-tree")) + archive = data + created, err := session.Create(sourceDir) if err != nil { resolver.Close() - return nil, fmt.Errorf("snapshot repo: %w", err) - } - snap = opened - sess.RepoRoot = snap.Root - sess.HEAD = snap.HEAD - if err := sess.WriteMeta(); err != nil { - resolver.Close() - return nil, err + return nil, fmt.Errorf("create session: %w", err) } + sess = created } image := opts.Image @@ -158,16 +160,10 @@ func open(ctx context.Context, opts Options, resume bool, resumeID string) (*Ses } sb.SetGuestCallHandler(broker) if !resume { - archive, err := repository.ArchiveHEAD(snap.Root) - if err != nil { - sb.Stop() - resolver.Close() - return nil, fmt.Errorf("archive repo: %w", err) - } if err := sb.TransferArchive(ctx, archive); err != nil { sb.Stop() resolver.Close() - return nil, fmt.Errorf("transfer repo: %w", err) + return nil, fmt.Errorf("transfer source directory: %w", err) } } return &Session{cfg: cfg, sess: sess, sb: sb, sel: sel, resolver: resolver, broker: broker}, nil @@ -184,21 +180,6 @@ func credentialStartupError(resolveErr, pushErr error) error { return errors.Join(errs...) } -func loadResume(repoPath, id string) (*session.Session, error) { - if id != "" { - return session.Load(id) - } - abs, err := filepath.Abs(repoPath) - if err != nil { - abs = repoPath - } - roots := []string{abs} - if top, err := repository.TopLevel(repoPath); err == nil { - roots = append(roots, top) - } - return session.LatestForRepo(roots...) -} - type Event = protocol.AgentEvent type Capabilities struct { diff --git a/pkg/abox/abox_test.go b/pkg/abox/abox_test.go index d0d4ce7..c9d0dd7 100644 --- a/pkg/abox/abox_test.go +++ b/pkg/abox/abox_test.go @@ -126,39 +126,9 @@ func TestOpenReturnsLegacySessionScrubError(t *testing.T) { } } -func TestLoadResumeByID(t *testing.T) { - t.Setenv("ABOX_HOME", t.TempDir()) - created, err := session.Create(t.TempDir(), "head") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(created.RootDisk(), []byte("disk"), 0o600); err != nil { - t.Fatal(err) - } - got, err := loadResume("ignored", created.ID) - if err != nil { - t.Fatal(err) - } - if got.ID != created.ID { - t.Fatalf("id %q", got.ID) - } -} - -func TestLoadResumeLatestForRepo(t *testing.T) { - t.Setenv("ABOX_HOME", t.TempDir()) - repo := t.TempDir() - created, err := session.Create(repo, "head") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(created.RootDisk(), []byte("disk"), 0o600); err != nil { - t.Fatal(err) - } - got, err := loadResume(filepath.Join(repo, "."), "") - if err != nil { - t.Fatal(err) - } - if got.ID != created.ID { - t.Fatalf("id %q", got.ID) +func TestResumeRequiresSessionID(t *testing.T) { + sess, err := Resume(context.Background(), "", Options{}) + if sess != nil || err == nil || !strings.Contains(err.Error(), "session ID is required") { + t.Fatalf("session=%v err=%v", sess, err) } }