Files
Lahfir c559ecf793 feat: add the sandbox command (#505)
* feat: add the install and sandbox commands

An agent had no way to give itself an isolated desktop; setting one up was a manual job.

Adds `agent-desktop install` and `agent-desktop sandbox` (create, start, stop, status, list, rm, exec) on a small provider contract, with plain errors and the guest's own output passed through `exec`.

* fix: pass a guest exit status of 125 through sandbox exec

A guest program can exit 125 itself, and exec reported that as a provider failure. Status 125 now counts as a provider failure only when the provider printed its failure object; otherwise the guest output and status pass through.

* fix: give a sandbox record with an unknown provider a recovery that works

sandbox list told the user to run sandbox rm for a record naming a
provider this build does not know, but that command failed on the same
unknown provider, and --provider was refused as a mismatch. Such a record
now reports its own error, points at --provider, and is recoverable with
a known provider the same way a malformed record is.

* refactor: move install under the sandbox command

install only ever builds or reports sandbox providers, so it now lives at
`agent-desktop sandbox install [provider]` beside the other sandbox
actions. The top-level install command is gone, which takes the published
command count from 62 to 61. Behavior and output are unchanged.

* refactor: name the macOS sandbox provider macos in tests and examples

The provider is being renamed from macos-vm to macos, so the examples,
test strings and the planted architecture-check paths use the new name.

* test: give the slow trace capture test the full action budget

A 500 ms wall-clock budget ran out on a slow Windows runner before the
action dispatched. The test still fails if capture is not capped, because
an uncapped capture sleeps for the whole budget.

* fix: report a sandbox directory without a record as broken, and settle sandbox exec errors

A sandbox directory whose record file is missing was listed as broken, but
rm, status, start, stop and exec answered SANDBOX_NOT_FOUND. They now return
INVALID_ARGS with the --provider recovery command, as the docs say.

Errors from sandbox exec skipped the delivery settling every other command
gets, so an invalid name reported retry unknown. They now go through the
same settling.

* fix: close four sandbox gaps in record identity, removal, mcp globals and exec output

- A record whose name differs from its directory is refused as malformed, so
  list and status never run a provider against another sandbox's path.
- Removing a sandbox whose provider already deleted the directory succeeds
  instead of ending in SANDBOX_NOT_FOUND.
- The MCP server drops a server global that conflicts with one the guest
  sets, so --wait-for-gone on the server no longer breaks a guest --wait-for.
- The docs state the 8 MiB limit on each sandbox exec output stream.

* fix: recover an oversized sandbox record and return plain guest output over mcp

- A record file above the size limit returned INTERNAL from every verb and
  could not be removed. It is now a malformed record, so
  sandbox rm --provider clears it.
- The MCP sandbox exec tool returned an error for guest output that is not
  JSON, such as --help. It now returns that text with the guest's status.
- The docs say sandbox exec passes neither stderr nor stdin.

* fix: keep the record of an unanswered create and stop the provider when the command is cancelled

- A create that timed out, or whose provider ended without a readable reply,
  dropped its record when the sandbox directory held nothing else. Delivery
  is unknown there, so the record now stays and the sandbox still lists and
  can be removed. A create the provider refuses still leaves no record.
- A terminating signal during a provider call killed agent-desktop and left
  the provider script and its children running. The sandbox path now
  forwards SIGHUP, SIGINT and SIGTERM to the provider's process group. It is
  installed only there, so the FFI library never replaces a host's handlers.

* fix: kill a provider that ignores SIGTERM on cancel, and drop a create record only on a refusal

- The signal handler sent SIGTERM to the provider's group and ended the
  host at once, so a provider or guest that ignores SIGTERM kept running.
  It now waits up to two seconds for the group to exit, then sends SIGKILL.
- A create reply that was ok:true but incomplete, or ok:false with
  data.changed:true, still dropped the record. The record is now dropped
  only for a complete failure reply that does not report a change, or an
  unavailable provider.

* fix: drop a create record only when the provider never ran, and reap the provider on cancel

- A create whose provider ran but printed too much output still dropped
  its record. The record is now dropped after a run error only when
  nothing was delivered: the script could not start, or the platform has
  no sandbox support.
- The cancel handler waited its full two-second grace even after the
  provider exited, because the exited provider was never reaped. It now
  reaps it while it waits.

* fix: cut the sandbox recovery paths that kept breaking, and serialize changes to one sandbox

Most review findings on this command came from three places: the
`--provider` recovery for broken records, the per-case broken-record
errors, and the MCP code that pushed server globals into `sandbox exec`.
This removes them instead of adding more guards.

- A sandbox whose record is missing, malformed, unreadable or names a
  provider this build lacks is `broken` to every command, with one error
  that names the directory to delete. `rm --provider` is gone.
- `create` no longer takes over a directory that already holds files.
- `create`, `start`, `stop` and `rm` hold a per-sandbox lock while the
  provider works; a second one fails at once with nothing delivered.
- `sandbox list` passes on a failed status check's own suggestion instead
  of always advising `rm`.
- A refused create that drops its record reports `not_delivered`.
- `sandbox exec`: a guest killed by a signal reports 128 + signal, a guest
  envelope with exit 125 passes through, and errors before the provider
  runs are `not_delivered`.
- MCP no longer passes server globals to `sandbox exec`; the guest command
  carries its own.
- Help, skill and JSON contract no longer name a provider this PR does
  not ship.

Tests that restated clap or serde, covered a removed staging step, or
repeated one path were dropped. Net: about 750 fewer lines.

* fix: refuse an option-shaped sandbox name over mcp, and document that the host session never reaches the guest

An MCP sandbox exec call whose name started with '-' was parsed as a host
flag, so '--help' returned the host's help as if the guest printed it.
The session and agent-id environment variables were silently ignored by
sandbox exec; the contract and skill now say so.

* feat: let sandbox start show the sandbox in a window and take provider options

`sandbox start <name> --window` asks the provider to show the sandbox's
desktop on the host instead of running it headless, and `--set key=value`
passes the provider an option for that start, the same way `create` does.
The flag reaches the provider as the `window=on` setting.

* fix(mcp): take sandbox exec output as the guest's, not as an envelope

The MCP call layer decided what a child's stdout meant by trying to parse
it, so a guest that printed nothing and exited 0 came back as an error and
guest JSON came back as the host envelope. Each tool now declares its output
kind once: envelope tools are parsed, and sandbox exec hands back its
child's own bytes with its exit status, empty output included. A host
failure still arrives as its envelope text, marked as an error.

Verified over stdio against the built binary with a stub provider: silent
guest output, guest JSON, a non-zero guest status, a host
SANDBOX_NOT_FOUND envelope, and an ordinary tool envelope.
2026-11-10 20:24:03 -08:00
..