diff --git a/Cargo.lock b/Cargo.lock index 5ef7fa12..9704d9a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,7 @@ dependencies = [ "glob", "hostname", "http-body-util", + "humantime", "hyper", "hyper-rustls", "hyper-util", @@ -107,8 +108,10 @@ dependencies = [ "hyper-util", "serde", "serde_json", + "tempfile", "tokio", "tracing", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 36576999..99919e99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ bytes = "1" clap = { version = "4", features = ["derive"] } futures-core = "0.3" http-body-util = "0.1" +humantime = "2" humantime-serde = "1" hostname = "0.4" hyper = { version = "1", features = ["client", "http1", "server"] } diff --git a/README.md b/README.md index 0a45f22a..bda82ff1 100644 --- a/README.md +++ b/README.md @@ -253,15 +253,17 @@ Controller JWT authentication uses client ID `vscode` by default on the prefix-less route; choose another ID with `daemon.llmProxy.clientId`. Every client ID the proxy presents (`vscode` or your choice, plus `copilot-cli` and `vscode-copilot` for the routes below) must be in the controller's -`allowedClientIds`. With `authentication.type: oidc` the client ID has no effect: the +`allowedClientIds`, and so must the client ID of every session registered for +`/sandbox/`. With `authentication.type: oidc` the client ID has no effect: the OIDC token is the same for every route. The listen address must be loopback. **Routes.** Besides the prefix-less route (the manual shape used above, with the credential mode from `llmGateway.githubOAuth` and the upstream from -`llmGateway.proxyUrl`), the proxy serves fixed path prefixes for the managed -Copilot programs, one per program route, so the client ID is decided by the file -the daemon writes and never by request headers. The prefix is stripped before -forwarding. A path whose first segment looks like a route name without matching +`llmGateway.proxyUrl`), the proxy serves fixed path prefixes: one per managed +Copilot program route, whose client ID is decided by the file the daemon writes +and never by request headers, and `/sandbox/`, whose client ID comes from the +registered session (the route name is unrelated to the top-level `sandbox` +configuration key). The prefix is stripped before forwarding. A path whose first segment looks like a route name without matching one exactly is refused with `404 route_unknown` rather than served as the prefix-less route; this also applies to a gateway sub-path that happens to start with a route name (for example `/vscode-copilot-proxy/...`), so do not use such @@ -273,11 +275,13 @@ paths on the prefix-less route: | `/vscode-copilot/` | `vscode-copilot` | gateway identity only | `llmGateway.url` | | `/vscode-copilot-passthrough/` | `vscode-copilot` | gateway identity in `Authorization`, the client's own bearer token in `x-llm-token` | `llmGateway.proxyUrl` (required) | | `/vscode-copilot-capi//` | `vscode-copilot` | as the pass-through route; a credential-less `GET /_ping` is forwarded without `x-llm-token`; the only route that tunnels WebSocket upgrades | `llmGateway.proxyUrl` (required) | +| `/sandbox/` | the registered session's (see **Sandbox route and per-session pairing**) | gateway identity only | `llmGateway.url` | -**Pairing.** Every route requires the per-device pairing value in the -`x-agentdesktop-pairing` header, except `/vscode-copilot-capi/`, which takes it +**Pairing.** Every route except `/sandbox/` requires the per-device pairing +value in the `x-agentdesktop-pairing` header; `/vscode-copilot-capi/` takes it as the first path segment after the prefix (VS Code cannot add a header there) -and ignores the header. The daemon creates it in its state directory +and ignores the header, and `/sandbox/` takes a per-session pairing in the +header instead. The daemon creates it in its state directory (`llm-proxy-pairing`, owner-only) on first use and keeps it across restarts; a manual setup copies it from there. The state directory in `--user` mode is `$XDG_STATE_HOME/agentdesktop`, by default `~/.local/state/agentdesktop` (the @@ -323,7 +327,8 @@ LLM clients display the message. Codes: `host_not_allowed`, `pairing_invalid`, `client_credential_missing`, `upgrade_not_supported`, `body_too_large`, `body_invalid`, `agentdesktop_credential` (no gateway credential for this device: not enrolled, revoked, controller unreachable or slow), `agentdesktop_unavailable`, -`gateway_unreachable`, `gateway_timeout`. +`gateway_unreachable`, `gateway_timeout`, and on the git/egress routes +`git_host_not_allowed`, `git_upstream_unreachable`, `egress_not_supported`. To attach the local user's GitHub App OAuth token as well, add the App's client ID to the gateway configuration (keep the existing `authentication` block): @@ -681,6 +686,623 @@ routes: This route is a pass-through, not an LLM route, so requests on it have no token or cost records; see [Token and cost usage](#token-and-cost-usage). +## Sandboxed launch (Windows, preview) + +`agentdesktop run claude` starts Claude Code inside a Windows AppContainer so the +agent sees the workspace, its own state and little else; `agentdesktop run +opencode` and `agentdesktop run copilot` do the same for OpenCode and GitHub +Copilot CLI (see **OpenCode and Copilot CLI in the box** below). The daemon drives +Microsoft's MXC executor; nothing is installed on the device by Agentdesktop +itself. Startup setting (local only, user mode): + +```yaml +daemon: + user: true + llmProxy: + listen: 127.0.0.1:4000 + containment: + executor: C:\Program Files\mxc\x64\wxc-exec.exe + # egress: allow # opt-out of the default `deny`: the box gets open outbound + # engine: mxc # the sandbox engine; `mxc` is the only value + # git: # git over the relay (https remotes) + # allow: [https://github.com/your-org/] + # pushAllow: [https://github.com/your-org/team-repo.git] + # credentials: + # - urlPrefix: https://github.com/your-org/ + # username: x-access-token + # secret: + # ssh: # git over ssh, signing through the user's ssh-agent + # hosts: [github.com] + # keys: ["SHA256:..."] # fingerprints as `ssh-add -l` prints them + # egressUpstream: # chain the git leg through a central agentgateway + # url: https://egress.example.com +``` + +**Wrap mode (preview).** A top-level `containment` policy block +(controller-delivered, or in the local file on controller-less devices) +makes the boxed launch the default for a named program: + +```yaml +containment: + programs: + claudeCode: + executable: 'C:\Program Files\Anthropic\ClaudeCode\claude.exe' +``` + +`mode` defaults to `wrap`; `mode: native` is the per-program opt-out. In wrap +mode `agentdesktop run claude` launches only the configured executable +(`~\.local\bin` and PATH are not searched), `agentdesktop run --wait` returns +the launch's recorded exit code to the calling shell (the executor's code, +not the program's own), and a Windows user-mode daemon reports per-program +posture in its daemon-info under `containment.programs.`: `mode`, +`executable`, `aliasesPresent`, `violations` and `lastViolationUnixSeconds` +(a report-only scan of native runs of the managed binary). A daemon that +predates this setting rejects a configuration that contains it, so upgrade +the controller and then every device before setting it in +controller-delivered policy. The managed package that installs the vendor +binary, the PATH-first `claude` launchers and the updater-off drop-in is +built from the recipe in `scripts/windows-package/`; the deployment guide is +[docs/windows-managed-package.md](docs/windows-managed-package.md). + +**Egress is denied by default.** A box has no direct outbound network unless +`daemon.containment.egress: allow` is set; with `allow` the daemon logs +`egress allow: the box has unrestricted outbound` at every launch. A config +written without the key means `deny` to this daemon and `allow` to an older +one; set the key explicitly if a rollback is possible. + +Under `deny` the box reaches the model through the local LLM proxy and git +remotes through the relay's `/git` route: the daemon writes a per-launch +gitconfig (`GIT_CONFIG_GLOBAL`) that rewrites every `git.allow` prefix to the +loopback relay, and the relay re-originates TLS to the remote, attaching the +matching `git.credentials` entry (longest prefix, allowed hosts only) on its own +leg; the box never sees the credential. Only the smart-HTTP endpoints are +served; a remote outside `git.allow` gets `403 git_host_not_allowed`, a push to +a remote outside `git.pushAllow` gets git's `fatal: remote error: ...`, and +upstream redirects are followed by the daemon (at most 3, only within the same +allow prefix). Allow entries are matched case-sensitively on the path and per +path element, so `team-repo` does not cover `team-repo.git`; use the remote +URL exactly as cloned, or an org prefix. Request bodies on the git route are +buffered (redirects and the retry resend them) and capped at 32 MiB; a larger +push fails with `413 body_too_large`. ssh remotes +of allowed hosts that are not in `git.ssh.hosts` get a clear refusal +(`ssh remotes are not enabled for this host; use the https remote or add the +host to daemon.containment.git.ssh.hosts`). +With `egressUpstream` set the relay sends the git requests to that endpoint +(path case preserved, `Host` = the egress host, a `503` retried once, except +on a push) instead +of to the remote; the central gateway's routes are then the allowlist too. +With `egressUpstream` set, `git.allow` and `git.pushAllow` must name a single +host: the relay sends only the path to the egress endpoint, so the gateway's +routes for those paths must lead to that host. The daemon refuses to start +otherwise. +CONNECT and proxy-form requests to the listener are always refused +(`403 egress_not_supported`); there is no generic outbound egress. The +generated gitconfig carries the launch's session pairing and is removed with +the launch directory. The generated gitconfig applies under `egress: allow` +too: credentials kept in `~\.gitconfig` or a credential manager are not used +in the box; configure `git.credentials` for private remotes. + +**Git over ssh** (`daemon.containment.git.ssh`, Windows OpenSSH agent +required): for each host in `git.ssh.hosts` (bare hostnames, port 22) the +daemon binds a loopback leg and writes a per-launch `ssh_config` and +`known_hosts` (seeded from your `known_hosts` via `ssh-keygen -F`; unknown +hosts are accepted on first use, per launch); the box gets `GIT_SSH`, a +one-line launcher script that Git for Windows runs with the agentdesktop +executable (no shell, which cannot start in the box) and that runs Windows +OpenSSH with that `ssh_config`, and `SSH_AUTH_SOCK` naming a per-launch named +pipe that only that box can use. The daemon brokers the box's agent +requests to your `\\.\pipe\openssh-ssh-agent`: only identity listing and +signing are forwarded; adding, removing, locking and extension requests are +refused. The private key never enters the box or the daemon. + +Known limits: the box needs Git for Windows; a `core.sshCommand` in the +repository's (or system) git configuration takes precedence over `GIT_SSH` and +git runs it through `sh`, which cannot start in the box (the box cannot edit +`.git/config` — it is granted read-only — so the user has to unset it on the +host); git over ssh needs an agentdesktop executable name without spaces. + +Posture: + +- The daemon signs with your ssh keys on behalf of the box. A signature is + not bound to a destination: the box can authenticate as you to any server + that accepts an offered key, over any channel it has. The key filter is the + blast-radius control: `git.ssh.keys` lists the fingerprints offered to the + box; empty or absent offers no key at all, and `["*"]` offers every key in + your agent. The filter matches the exact key blob, and the agent signs with + the private key behind it: offering only a certificate still gives the box + signatures from that private key, usable with any server that trusts the + plain key. Treat each offered key's reach as that of its private key. +- An ssh host grants fetch AND push to that whole host; `git.allow` and + `git.pushAllow` prefixes do not apply to ssh. Use https remotes where + per-repository push control matters. +- The loopback legs are machine-wide while the launch runs: any local + process can use them (the same caveat as the relay). A leg is a TCP pump to + `host:22`, so plain `ssh ` sessions through it work too. +- With `egressUpstream` set the daemon refuses a non-empty `git.ssh.hosts` + (the egress endpoint is L7 https only). + +If any part of the ssh setup fails (leg bind, reading the box's identity, +creating the pipe) the launch is stopped and fails; it never runs without +ssh. The pipe and the legs close when the session ends (box exit, revocation, +daemon restart). ssh used in the first instant of a launch may report the +agent as unavailable: the pipe is created right after the box starts. + +`run` needs the local LLM proxy: without `daemon.llmProxy.listen` it answers +`409`. + +`daemon.containment.engine` selects the sandbox engine; `mxc` is its only +value and the default, so the key can stay unset. An older daemon refuses a +config that sets it. + +The daemon reports the setting as `containment` in daemon-info (`available` +with the reason when not). Then, from the workspace directory: + +```powershell +agentdesktop run claude -- --model opus +``` + +Everything after `--` goes to Claude Code. The daemon answers `409` when the +effective configuration has no `llmGateway` with `authentication`, no +`programs.claudeCode` with `useLlmGateway: true`, uses subscription sign-in +(not available inside the box yet), or sets a top-level `sandbox` (Claude +Code's own sandbox does not run inside the box; remove it or launch without +`run`). With `daemon.claudeCode.config` set, Claude Code launches answer `409` +(the box reads `~\.claude\settings.json`); other programs still launch. +Behaviour change: this setting used to make containment unavailable as a +whole. `containment.available` in daemon-info is now `true` on such a device +and the reason text no longer appears there; a client that gated a Claude +launch on `available` now gets the `409` from `run` instead. With controller +JWT authentication, `claude-code` must be in the controller's +`allowedClientIds`; with `authentication.type: oidc` the client ID has no +effect. + +What the box can see: the workspace, `~\.claude` (skills, agents, commands, +`CLAUDE.md` and `.claude.json` stay writable; `CLAUDE_CONFIG_DIR` points there), +a private `tmp`. Read-only: the Claude Code and Git installations, the +Agentdesktop client, the launch's generated gitconfig (the box's git reads only +that file; it carries `user.name`, `user.email`, `user.signingkey`, +`commit.gpgsign`, `core.longpaths` and `safe.directory` copied from your global +configuration, nothing else), `~\.claude\settings.json`, `~\.claude\hooks`, +`~\.claude\plugins`, the workspace's `.claude\settings*.json`, `.claude\hooks`, +`.mcp.json`, a `HEAD` file at the workspace root, and `.git\config`, +`.git\hooks` and `.git\commondir` of the workspace repository (`.git\HEAD` +stays writable so branch switches work). Every box also carries the workspace +configuration of OpenCode and GitHub Copilot CLI under the same rule: +`opencode.json`, `opencode.jsonc` and `.opencode` at the workspace root, +and `.github\copilot`, `.github\mcp.json`, `.github\hooks` and +`.github\agents` (a box may not plant configuration that a later, unboxed +harness executes). An OpenCode box is the exception for its own three +entries: `opencode.json`, `opencode.jsonc` and `.opencode` are read-only +whether or not they exist (see "OpenCode and Copilot CLI in the box"). +When the workspace has no `.github` folder, the folder +itself is denied (the sandbox cannot deny an entry whose parent folder is +missing), so a boxed agent cannot create `.github\workflows` in a fresh +repository; create `.github` outside the box first if the agent should write +there. A checkout or pull inside the box that has to write one of these +pinned paths cannot create or replace it; run that git operation outside the +box. +Each file or folder in that list is denied when it does not exist, so the box +cannot create it in place; a workspace without a `.claude` folder gets +the folder denied as a whole; a workspace without `.git` cannot `git init` one. +When the `.git` entry is an empty directory (exists but has no children), the config, +hooks and commondir guards are emitted, so the box cannot `git init` into the existing +empty folder. A `.git` file (worktree or submodule) remains box-writable: a `gitdir:` +rewrite is not prevented. +Residual risk: direct creation of those denied paths is blocked, but renaming a file +into such a path succeeds, and the file then exists on disk. For example, an agent +could rename a file into `\.git\commondir` to point git at a writable directory +with its own config and hooks, or into `\HEAD` so that git treats the workspace +as a bare repository. Renaming a folder into these names has not been tested. +The daemon creates `~\.claude` if it is missing. Always denied: +`~\.claude\.credentials.json`, `~\.claude\history.jsonl`, `~\.claude\ide`, +`~\.ssh`, `~\.aws`, `~\.azure`, `~\.kube`, `~\.gitconfig` and +`~\.config\git\config` (the box uses the generated gitconfig instead). The workspace may not be a drive or share root, the home +directory or one of its ancestors, or anything under `%SystemRoot%`, +`%ProgramFiles%`, `%ProgramData%`, `AppData`, and the actual `%APPDATA%` and +`%LOCALAPPDATA%` paths (Folder Redirection can place these outside `\AppData`), +`~\.claude`, `~\.agentdesktop` (the harness homes), the daemon's state +directory or a denied path (`%ProgramFiles(x86)%` counts as a system +directory), nor contain one of them. Outbound network is denied by default (see +above; `daemon.containment.egress: allow` opts out); the box can reach every loopback listener on this machine +(host loopback is allowed so it can reach the local LLM proxy), and the +clipboard is off in the box; a gateway behind a private CA is not supported +yet. + +The box gets no pipe to the daemon, no sign-in and no gateway credential. +Each `run` registers a session with the local LLM proxy and gives the box only +that session's pairing (`/sandbox` route); the proxy adds the user's gateway +identity to each request. Loopback is machine-wide, so the pairing is a bearer +secret on this machine; with `git.allow` set it also grants that session's git +egress scope, not just the model relay. Sign-in still happens once, at `run` time. The session +is revoked when the box exits; restarting the daemon revokes all sessions, and +a running box gets `403 pairing_invalid` until it is relaunched (use +`--resume`); a box idle for 8 hours loses its session the same way. The box +environment sets `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1` +(https://code.claude.com/docs/en/env-vars), so the gateway URL in +`~\.claude\settings.json` does not apply inside the box; the `apiKeyHelper` +there is not run (observed, Claude Code 2.1.300). The box's own `TEMP` lives +under the launch directory. Residual limits: the executor's log lines are +copied into the daemon log and an early exit quotes the log tail, so anything +the executor echoes travels there; after a sign-out and new sign-in, a running +box uses the newly signed-in identity within about a minute; and a box could +occupy the sign-in's loopback redirect port, failing a concurrent sign-in +(the token itself stays protected). Telemetry +hooks do not reach the daemon from inside the box in this preview. Arguments +containing `" % ^ & | < > !` or control characters are rejected, because +`cmd.exe` starts the box's console window. + +`run` answers `400` for a rejected program (one of `claude-code`, `opencode`, +`copilot`), workspace or argument, `403` when +another user calls a user-mode daemon on Windows, `409` for the configuration +rules above and when the local LLM proxy is not running, `429` when too many +relay sessions exist (revoke unused ones), `401`/`424`/`502` straight from the +sign-in, and `500` when preparing the launch fails (creating `~\.claude` or +writing the request), when the executor cannot be started, when recording the +launch pid fails or the daemon cannot watch the executor (in both cases it +stops the executor first), or when the executor exits within three seconds +(its last log lines are in the message). If the daemon can no longer watch a +running sandboxed session, it stops it. Launches are listed by +`GET /v1/launches` (running ones plus the last 32 that exited; each carries +its `sessionId`, and `integrityDrift: true` once a change was detected). A daemon restart keeps running boxes alive but revokes +their relay sessions: at startup the daemon removes the request files of +every earlier launch, and the directory of a launch whose box has exited. Residual risk: on Windows a read-only file inside +a read-write folder can be replaced from the box (write a temp file, rename over +it; Claude Code's own atomic save does this). That covers every read-only entry +above that sits inside a writable folder: `~\.claude\settings.json` (hooks, +`apiKeyHelper`), `~\.claude\hooks`, `~\.claude\plugins`, the workspace's +`.claude\settings*.json` and `.claude\hooks` (which can also be committed to the +repository), `.mcp.json`, the workspace-root `HEAD`, and `.git\config`, `.git\hooks` +and `.git\commondir`. A `.git` file (worktree or submodule) is box-writable (a rewrite +is not prevented). The boxed agent can +change these, and the next unboxed session reads them (Claude Code outside the box; +git outside the box, where `core.fsmonitor` and `core.hooksPath` run code). The +read-only grant stops in-place writes but not replacement. The daemon detects such +changes but does not prevent them. At launch it records the content hash and file +identity of each root file and of every child in a recursively hashed folder (up to +1000 entries and 10 MiB per folder; a larger or deeper folder, such as a +well-filled `~\.claude\plugins`, is recorded by its structure instead: names, kinds, +sizes and identities for files, folders and links, recursively, without file contents, +so a changed manifest or an atomically saved file still registers, while an in-place +edit that keeps a file's size does not; past 10,000 entries, or past 64 levels of +nesting anywhere in the tree, only the top-level names are recorded), and the identity of the workspace `.claude` and `.git`, so a +replacement with identical bytes is detected (except past 10,000 entries): lab-proven +end to end for root files, unit-tested for folder children. It compares them every 60 seconds while the box runs and at box +exit; a transition back to the original state and a repeated change each count again. +Each change is logged once as a warning (`executable config changed while launch +ran`, with path, change type and both content hashes, `N/A` where the entry is not a +file), and the launch shows `integrityDrift: true` in +`GET /v1/launches`. Changes are not undone. A change is not proof that the box made it: +the daemon's own reconcile when its configuration changes, Claude Code saving settings +(inside or outside the box), git writing `.git\config`, and other boxes in the same +workspace all register. A daemon restart drops the recorded state of running boxes +without a check. The daemon's own +directory (and the launched program's directory) is granted read-only recursively, +so running a portable `agentdesktop.exe` or `claude.exe` from inside the profile +widens that grant to everything under it except the denied entries (install under +Program Files, the MSI default, to keep the grant narrow). Files the agent creates in the workspace are not gated yet; nested +repositories under the workspace, a renamed or recreated `.git` or `.claude` directory +(detected, not prevented), and editor task files such as +`.vscode\tasks.json` are not protected; `~\.claude\.claude.json` (user-scope +MCP servers) and skills under `~\.claude` stay writable so the agent keeps its +context; see "Managed settings for containment" below for delivery of managed `allowManaged*Only` locks. + +### OpenCode and Copilot CLI in the box + +`agentdesktop run opencode` and `agentdesktop run copilot` start the native +`opencode.exe` / `copilot.exe` (found in `~\.local\bin`, then on the daemon's +`PATH`; npm `.cmd`/`.ps1` shims are refused, and a Scoop or Chocolatey exe +shim resolves but then fails in the box, because only the shim's folder is +granted). For Copilot CLI place the native `copilot.exe` at +`~\.local\bin\copilot.exe` (other install layouts are not verified yet). The +box, the relay session, the workspace rules, the union workspace pins +(OpenCode's own three entries differ, see the harness-home bullet), the +denied secrets and the git and ssh relay are the same as for Claude Code; the +git relay and ssh broker serve every program alike, so a boxed OpenCode or +Copilot CLI can push and sign to the allowed hosts exactly as Claude Code can. +What differs: + +- **Egress must be `deny`.** With `daemon.containment.egress: allow` both + answer `409 egress allow is supported for claude-code only`: with open + outbound OpenCode could reach other providers, remote configuration and + plugin installs, and Copilot CLI could sign in to GitHub, past the relay. +- **Configuration.** `programs.openCode` needs `useLlmGateway: true` (the + default) and a non-empty `model` that is one of its `models` (that key is + sent to the relay as the Anthropic model ID; the entry's model object is not + passed into the box); `programs.copilot` needs + a `models` entry with `provider: agentdesktop-anthropic` (the first such + entry by key is used; its `wireModel`, which must be a string, else + `modelId`, else the key, becomes `COPILOT_MODEL`; a `--model` argument + overrides it, as Copilot CLI documents). A missing piece answers `409` + naming the field. The client ID is the program ID (`opencode`, `copilot`): + with controller JWT authentication `allowedClientIds` must list it or every + launch answers `409` (the unboxed Copilot CLI route keeps `copilot-cli`; + OpenCode's boxed and unboxed launches share `opencode`, told apart by the + session ID in the daemon log). +- **Relay injection.** OpenCode gets its provider configuration inline in + `OPENCODE_CONFIG_CONTENT` (the Anthropic provider at the relay, the + session pairing as a header, `model` and `enabled_providers` pinned, + autoupdate and sharing off), plus `OPENCODE_DISABLE_AUTOUPDATE`, + `OPENCODE_DISABLE_MODELS_FETCH` and `OPENCODE_DISABLE_PROJECT_CONFIG`; a + managed OpenCode configuration (`%ProgramData%\opencode`, + `daemon.openCode.config`) is not visible in the box. Copilot CLI runs in + BYOK mode: `COPILOT_PROVIDER_TYPE=anthropic`, `COPILOT_PROVIDER_BASE_URL` + at the relay, a placeholder key, the pairing in `COPILOT_PROVIDER_HEADERS`, + and `COPILOT_AUTO_UPDATE=false`. +- **Harness home.** Each program gets a home under + `~\.agentdesktop\harness\` (beside the user's own tool state, so a + boxed run never touches the unboxed one; `~\.agentdesktop` and `harness` are + created private). OpenCode's `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, + `XDG_STATE_HOME` and `XDG_CACHE_HOME` and Copilot's `COPILOT_HOME` point + there and are read-write (for `XDG_CONFIG_HOME` only its `opencode` folder, + which holds the pinned entries): sessions and OpenCode's database persist + across launches, like `~\.claude\projects`. OpenCode's + `cache\opencode\bin`, where it would download ripgrep and language + servers, is read-only in the box and daemon-managed: the daemon empties it + before every launch, so a file an earlier box left there after renaming the + cache root is removed before the next box could run it, and a reparse point + at the folder itself stops the launch. For OpenCode's grep and file search + place the native `rg.exe` beside `opencode.exe` (normally `~\.local\bin`), + whose folder is on the box's `PATH`. Two boxes of one program share that + home (as two Claude boxes share `~\.claude`), OpenCode's `opencode.db` + included. Its executable configuration is read-only when present and denied + when absent: for OpenCode the global `opencode.json`, `opencode.jsonc`, + `config.json`, the legacy `config` file, `tui.json`, `tui.jsonc`, + `AGENTS.md` and the `agent(s)`, `command(s)`, `mode(s)`, `plugin(s)`, + `skill(s)`, `tool(s)` and `themes` folders under `config\opencode`; for + Copilot CLI `settings.json`, `mcp-config.json`, `lsp-config.json`, + `providers.json`, `copilot-instructions.md` and the `instructions`, + `agents`, `skills`, `hooks`, `extensions` and `installed-plugins` folders. + OpenCode's global `config.json`, `opencode.json` and `opencode.jsonc` are + the exception: the daemon writes `{}` into each one that is absent before + the launch (an existing file is left as it is), so they are always + read-only. A denied path answers "operation not permitted" rather than "not + found", and OpenCode does not start when it cannot read its global config. + Copilot's `providers.json` is pre-created the same way, because Copilot CLI + refuses to start when it cannot read it. Copilot's `config.json` (sign-in + state and trusted folders) stays writeable under the `COPILOT_HOME` grant: + denying it alone ends the CLI silently, so this is the documented fallback. + Residual: the box can change Copilot's trusted-folder list, and Copilot + asks for folder trust for the workspace on launch. These entries are + integrity roots like `~\.claude\settings.json`. OpenCode also + treats `~\.opencode` under the real home as a configuration folder (even with + `OPENCODE_DISABLE_PROJECT_CONFIG`); the box cannot reach it only because + nothing grants it. An OpenCode box gets the three workspace entries + `opencode.json`, `opencode.jsonc` and `.opencode` read-only whether or not + they exist (a denied absent path aborts its config scan): it can create + each of the three once because the workspace is read-write, its own + `OPENCODE_DISABLE_PROJECT_CONFIG=1` stops it reading them, but a later + unboxed OpenCode could read a planted file. The union pins in every other + box are unchanged. +- **Copilot package cache.** Copilot CLI extracts its runtime on first start, + which the box cannot do. The cache lives in + `~\.agentdesktop\harness\copilot-pkg` (read-only in the box) and is never + filled by the daemon: run `copilot --version` once outside the box with + `COPILOT_PKG_CACHE_HOME` set to that folder. Until a non-empty folder for + the exe's version exists there, `run copilot` answers `409` with that + command (an exe without a version resource needs exactly one primed + version). Both failure shapes outlast the 3 s early-exit watch, so they + show as a launch that starts and then exits: a cache that does not match + the exe makes Copilot retry the extraction for about 21 s and exit 1, and + a Copilot without a usable provider exits 1 after about 4 s + (`No authentication information found`). +- **Refused arguments** (`400`): any argument that is or starts with + `--sandbox` for Copilot CLI (the box is the sandbox; its own cannot nest); + for OpenCode `serve` and `web` wherever they appear as a separate argument + (a positional prompt word `serve` is refused too) and any argument starting + with `--port`, `--hostname` or `--mdns`: an in-box listener would expose the + session pairing to every local process. +- **Copilot sandbox policy.** If Copilot's local managed settings require its + own sandbox (`sandbox.enabled` under `HKLM` or `HKCU\SOFTWARE\Policies\GitHubCopilot`, + or in `%ProgramFiles%\GitHubCopilot\managed-settings.json`), `run copilot` + answers `409`: launch Copilot CLI unboxed or lift the requirement for boxed + devices. Server-managed policy is not fetched in BYOK mode, so these local + sources are the complete check as long as no GitHub sign-in is available to + the box and egress is `deny` (guaranteed for Copilot boxes). The box reads + the harness home's `config.json`, which holds a sign-in only if someone + signed in unboxed with `COPILOT_HOME` pointed there; that read-write + fallback is the chosen variant (denying the file ends the CLI silently), so + its trusted-folders residual above applies. A repository's + committed `.github\copilot\settings.json` that enables the sandbox is not + checked: measured on Copilot CLI 1.0.101, the boxed CLI still runs with that + setting in place (it does not take effect inside the box). + +Not pinned for any program: workspace instruction files (`AGENTS.md`, +`.github\copilot-instructions.md` and the like are prompt input), editor files +such as `.vscode\tasks.json`, and repository scripts in general. + +### Managed settings for containment + +Claude Code reads `allowManagedHooksOnly` and `allowManagedMcpServersOnly` only from +managed sources (placing them in user or project settings has no effect; see +https://code.claude.com/docs/en/managed-settings). With the system-mode daemon +(`agentdesktop daemon`), the locks are delivered in controller config as + +```yaml +programs: + claudeCode: + useLlmGateway: false + allowManagedHooksOnly: true + allowManagedMcpServersOnly: true + allowedMcpServers: [...] +``` + +The keys sit directly under `claudeCode`, not nested under `settings`. The system +daemon writes them to `%ProgramFiles%\ClaudeCode\managed-settings.d\50-agentdesktop.json`. +With the user-mode daemon (`agentdesktop daemon --user`), the same keys land in +`~\.claude\settings.json` where the two `allowManaged*Only` keys have no effect +(managed-only keys); `allowedMcpServers` is a user allowlist. The locks reach the file +tier only through the system daemon; `run` needs the user-mode daemon. On a device that +runs both daemons, the two need different `programs.claudeCode` values: the system +service carries the locks with `useLlmGateway: false` (so the drop-in holds only the +locks and the user daemon's gateway settings stay in force), the user daemon carries +`useLlmGateway: true` for `run`. One controller serves one configuration to every +daemon, so give the system service a local configuration file (no `controller:`), or +deliver the locks through MDM/HKLM instead. Run the user daemon on a per-user pipe. The +locks reach Claude Code only while the system service's `daemon.claudeCode.config` is +unset (an override writes them where Claude Code does not read managed settings). + +Upgrade note: `allowManagedHooksOnly`, `allowManagedMcpServersOnly` and +`allowedMcpServers` under `programs.claudeCode` are now validated (until now they passed +through unchecked). Before upgrading the controller, run the new binary's `agentdesktop +daemon --dry-run --config ` (add `--user` if the file configures +a user-mode-only program such as `programs.copilot`); an error that names +`programs.claudeCode.allowManagedHooksOnly`, `allowManagedMcpServersOnly` or +`allowedMcpServers` must be fixed first. The controller checks the file when it starts +and refuses one that fails; a file changed while it runs is refused and the last good +one kept. A device whose cached configuration fails the rule does not start: delete +`remote-config.yaml` in its state directory (by default `C:\ProgramData\AgentDesktop\state` +for the system daemon on Windows, `/var/lib/agentdesktop` on Linux/macOS, +`~/.local/state/agentdesktop` for `--user`; the startup error names the file) and start it +again; it starts from its local configuration and receives the current one from the +controller. Claude Code drops an invalid entry and enforces the rest; agentdesktop refuses +the whole configuration instead, so the problem shows at the controller. + +Under `allowManagedHooksOnly: true`, hooks the user-mode daemon writes into +`~\.claude\settings.json` stop running; the system daemon's hooks in the drop-in keep +running while the file tier is selected (Claude Code selects the file tier only when +HKLM/Intune is absent or sets `managedSourcesBehavior: "merge"`). `allowManagedMcpServersOnly: +true` restricts servers only together with a managed `allowedMcpServers` list; without a +managed list every server that passes the denylist loads. With the lock off, a user-level +`allowedMcpServers` is a soft allowlist the user and the box can change. Use `serverUrl` +or `serverCommand` entries (a `serverName` entry is a user-chosen label, not a security +control). Example managed JSON snippet: + +```json +{"allowManagedHooksOnly": true, "allowManagedMcpServersOnly": true, "allowedMcpServers": [{"serverUrl": "https://mcp.example.com/*"}]} +``` + +Delivery options on Windows: the system daemon (above), or customer MDM/HKLM registry +value `HKLM\SOFTWARE\Policies\ClaudeCode` name `Settings` (REG_SZ or REG_EXPAND_SZ, JSON +string), or GPO/Intune. When HKLM (Intune/GPO) delivers any policy key, Claude Code +selects HKLM under the default `first-wins`. From the drop-in it then still reads `env` +(per variable, Claude Code 2.1.223+), `allowManagedMcpServersOnly` (2.1.273+), and the +`allowedMcpServers` list while the MCP lock is on. It ignores the drop-in's hooks, +`apiKeyHelper`, `allowManagedHooksOnly` and every other key. `managedSourcesBehavior: +"merge"` in the HKLM policy (2.1.242+) adds the drop-in's hooks and makes +`allowManagedHooksOnly` take the strictest value. `apiKeyHelper` is still read from HKLM +only, so a fleet that pushes HKLM policy and uses the system daemon's gateway helper +must put `apiKeyHelper` into the HKLM policy as well, or put the locks into HKLM and +leave the drop-in to `env` and hooks. HKCU is user-writable and is not an admin source; +`null` means no admin source delivers a policy and Claude Code may then apply the +user-writable HKCU value, which the daemon does not report. Daemon-info shows the policy +as of the last GET request (the daemon reads the managed files and HKLM on every +daemon-info request, so a system-daemon push shows up on the next GET) via +`managedSettingsSource` (`"hklm"`, `"file"`, or `null`), `managedHooksLocked` and +`managedMcpLocked` (both boolean), and `managedSettingsError` (present when the posture +cannot be determined); each launch records the same three fields reflecting the policy +in force when it started, or source `null` and both lock fields `false` when the posture +could not be determined at launch time (a read error, invalid JSON, or an empty HKLM value +in an admin source); daemon-info is the surface that reports the error, and the daemon logs +a warning when a posture error occurs during launch. Server-managed settings (from claude.ai +or a Claude apps +gateway) rank above HKLM and the file tier when a session authenticates to Anthropic's +API directly or signs in to a gateway; outside the box they apply to unboxed sessions, +not to launches inside the box. The posture model assumes Claude Code 2.1.273 or later. + +Behaviour change for the user-mode daemon on Windows: its pipe now admits the +owning user only (SYSTEM and that user's SID), no longer every interactive user. +On a device that also runs the system service, set `daemon.socket` to a per-user +pipe (for example `\\.\pipe\agentdesktop-`) and pass it to `run` with +`--socket` (or `AGENTDESKTOP_SOCKET` in the unified binary). A scheduled task can instead start the daemon with `daemon --user --socket `, which takes precedence over `daemon.socket`; this preview adds no +pipe discovery or Windows logon autostart. + +### Sandbox route and per-session pairing + +A program that runs inside a sandbox (for example a Windows AppContainer) must +not hold the device pairing or any gateway credential, and needs no network +beyond loopback. For that the proxy has a further route, `/sandbox`, whose +pairing is a **per-session** value: whoever starts the sandboxed program (a +launcher in the user's session) registers a session over the local API, puts +the session's pairing into the program's environment, and revokes the session +when the program exits. + +```sh +agentdesktop llm-proxy session create --client-id claude-code --label "project x" +# { +# "id": "0123456789abcdef", +# "pairing": "<43 characters, shown here and nowhere else>", +# "clientId": "claude-code", +# "label": "project x", +# "createdAtUnixSeconds": 1760600000, +# "expiresAtUnixSeconds": 1760628800, +# "baseUrl": "http://127.0.0.1:4000/sandbox" +# } +agentdesktop llm-proxy session list # id, client id, label, expiry per line +agentdesktop llm-proxy session revoke 0123456789abcdef +``` + +The same operations are `POST`/`GET /v1/llm-proxy/sessions` and +`DELETE /v1/llm-proxy/sessions/{id}` on the local API; `POST` takes +`{"clientId": "...", "label": "...", "ttlSeconds": 28800}` and answers `409` +when the proxy could not be bound at startup (`llmProxy.bound: false`). A +proxy that stopped later still has its address, so a session created then +gets a `baseUrl` nothing serves until the daemon restarts. For Claude Code the +sandboxed program's environment is: + +``` +ANTHROPIC_BASE_URL=http://127.0.0.1:4000/sandbox +ANTHROPIC_API_KEY=placeholder +ANTHROPIC_CUSTOM_HEADERS=x-agentdesktop-pairing: +``` + +On `/sandbox` the proxy looks the header up among the registered sessions, +drops every client credential (`Authorization`, `x-api-key`, `api-key`, +`x-llm-token`) and sends the gateway identity for the session's client id, the +same way the `/copilot-cli` route does for its fixed id. There is no +pass-through on this route, and the gateway identity is sent without a +subscription token: with `programs.claudeCode.auth: subscription` (or the +same for `claudeDesktop`) a session for that program is refused with `409`. +With `authentication.type: controllerJwt` the session's client id must be in +`allowedClientIds`; that is checked when the session is created (so a refused +id fails at `session create` rather than at the first prompt) and independently +by the controller on every credential it issues; a session that already exists +keeps its gateway identity when the configuration later switches that program +to subscription mode, until it is revoked or idles out. Narrowing +`allowedClientIds` makes its requests fail within about a minute, because the +proxy reuses a credential for at most 60 seconds and the controller then denies +the next fetch. With `oidc` the client id only labels the log. The daemon +never starts an interactive sign-in on behalf of a sandbox: if a stored OIDC +session is missing or unusable, the box's request fails and the user must sign +in on the host. + +Sessions live in memory and expire `ttlSeconds` after their last request +(default 28800, at most 86400 and at least 60; the timeout restarts on every +request that presents the session's pairing, whether the proxy then forwards +or refuses it, so a session in use never expires on its own). Revoking a +session refuses its next request; a response already streaming completes. A daemon +holds at most 64 unexpired sessions (`429`); expired ones are dropped every +60 seconds. A daemon restart drops all sessions: the sandboxed program then +gets `403 pairing_invalid` on its next request and has to be started again +with a new session. Each request on `/sandbox` leaves one line in the daemon +log, `LLM proxy sandbox request`, with the session id, client id, method, path +(prefix stripped, no query), status, refusal code, request and response bytes, +duration and outcome. A completed or refused request has `outcome=complete`; +one whose client disconnected before the upstream responded has `outcome=canceled` +(status 0, response bytes 0); and one cut off during the response has +`outcome=aborted`. For a request the proxy refused, the response bytes are 0: +the error body is not counted. The log never carries a header, a body or a +pairing value. + +What a session pairing is worth: it is a bearer secret for `/sandbox` on this +machine. Any local process that holds it gets the user's gateway identity for +the session's client id, on any method and path under `llmGateway.url`, until +the session is revoked, idles out or the daemon restarts. It is not bound to +the sandbox (loopback is reachable by every process on the machine), it is +refused on every other route, and it is never forwarded upstream. The device +pairing in turn never opens `/sandbox`, so the files that carry it (the +`llm-proxy-pairing` file in the state directory, VS Code's +`chatLanguageModels.json` and `settings.json`, Copilot CLI's +`providers.json`) must stay unreadable from inside the sandbox; otherwise the +program could use the device-wide routes instead. Sessions are minted on the +local API only, never on the proxy's TCP listener. On Windows the local API's +named pipe admits every interactive user of the machine, so another +interactive user could create or revoke sessions on this daemon, the same +exposure `/v1/llm-gateway/credential` has today. + +Note for hand-configured clients on the prefix-less route: a first path +segment that starts with `sandbox` is now refused as an unknown route, like +the other managed prefixes. + ## Periodic re-apply (opt-in) The daemon applies its configuration at startup and whenever the controller diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 8e323951..ca594b79 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -32,6 +32,7 @@ bytes.workspace = true clap.workspace = true glob = "0.3" hostname.workspace = true +humantime.workspace = true hyper.workspace = true hyper-rustls.workspace = true hyper-util = { workspace = true, features = ["client-legacy", "http1"] } @@ -88,5 +89,12 @@ windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_Console", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_JobObjects", + "Win32_System_Pipes", "Win32_System_Registry", + "Win32_System_RemoteDesktop", + "Win32_System_Threading", ] } diff --git a/crates/agent/src/api.rs b/crates/agent/src/api.rs index 662ceb34..7b4090c3 100644 --- a/crates/agent/src/api.rs +++ b/crates/agent/src/api.rs @@ -46,6 +46,12 @@ pub struct AppState { pub usage: Option, /// The device user's program choice; `None` serves no program routes. pub programs: Option, + /// Distinct native runs of wrap-mode programs' managed executables, + /// counted by the violation scan since daemon start (report-only). + /// `Some` only in a Windows user-mode daemon whose `daemon.containment` + /// is present; daemon-info fills per-program posture from it. + pub(crate) violation_ledger: + Option>>, } /// Reads and changes the device user's program choice, and re-applies the @@ -336,7 +342,75 @@ async fn health(State(state): State) -> Json { } async fn daemon_info(State(state): State) -> Json { - Json(state.daemon_info) + let mut info = state.daemon_info; + if cfg!(windows) + && let Some(containment) = info.containment.as_mut() + { + match tokio::task::spawn_blocking( + crate::provider::claude_code::managed::managed_posture_now, + ) + .await + { + Ok(posture) => { + containment.managed_settings_source = posture.source; + containment.managed_hooks_locked = posture.hooks_locked; + containment.managed_mcp_locked = posture.mcp_locked; + containment.managed_settings_error = posture.error; + } + Err(error) => { + tracing::debug!("failed to join managed-settings posture task: {error:#}"); + } + } + } + // Per-program wrap posture, device-local: mode and executable from the + // applied configuration, launcher presence, and the violation ledger. + // Absent off Windows and outside a user-mode daemon with + // `daemon.containment` (the ledger exists only there). + if let Some(ledger) = state.violation_ledger.clone() + && info.containment.is_some() + { + let config = state.config.clone(); + let state_dir = state.state_dir.clone(); + match tokio::task::spawn_blocking(move || wrap_postures(&config, &state_dir, &ledger)).await + { + Ok(programs) => { + if let Some(containment) = info.containment.as_mut() { + containment.programs = programs; + } + } + Err(error) => { + tracing::debug!("failed to join wrap posture task: {error:#}"); + } + } + } + Json(info) +} + +/// The wrap-mode programs' posture for daemon-info, read per request from the +/// applied configuration and the violation ledger. An unreadable applied +/// configuration reports no posture (the launch route answers 500 for it). +fn wrap_postures( + config: &DaemonConfig, + state_dir: &std::path::Path, + ledger: &std::sync::Mutex, +) -> std::collections::BTreeMap { + let effective = match load_effective_config(config, state_dir) { + Ok(effective) => effective, + Err(error) => { + tracing::warn!( + error = %format!("{error:#}"), + "wrap posture skipped: the applied configuration is unreadable" + ); + return std::collections::BTreeMap::new(); + } + }; + let Some(policy) = effective.containment else { + return std::collections::BTreeMap::new(); + }; + let ledger = ledger + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + crate::launch::wrap::program_postures(&policy, &ledger) } async fn config(State(state): State) -> Json { @@ -367,7 +441,17 @@ pub(crate) fn load_effective_config( match std::fs::read_to_string(&path) { Ok(contents) => agentdesktop_core::config::parse_daemon(&contents) .map_err(|error| error.context("parse applied remote configuration")), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(config.clone()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // Before the first remote configuration is applied, the local + // file still backs the gateway and program fields, but with a + // controller configured only the controller-delivered + // `containment` block ever applies: a local block must not make + // wrapped launches (or the violation scan) behave differently + // before and after the first push. + let mut config = config.clone(); + config.containment = None; + Ok(config) + } Err(error) => Err(error.into()), } } @@ -464,6 +548,20 @@ async fn llm_gateway_credential( .map(Json) } +/// With the device grant selected, a missing or unrefreshable OIDC token is a +/// `401` instead of a browser sign-in: a background refresh never prompts. +pub(crate) fn non_interactive(mut effective: DaemonConfig) -> DaemonConfig { + if let Some(gateway) = effective.llm_gateway.as_mut() + && let Some(LlmGatewayAuthentication::Oidc { + device_authorization, + .. + }) = gateway.authentication.as_mut() + { + *device_authorization = true; + } + effective +} + pub(crate) async fn gateway_credential( state: &AppState, effective: &DaemonConfig, @@ -694,7 +792,7 @@ async fn llm_gateway_login( .map_err(|error| (StatusCode::BAD_GATEWAY, format!("{error:#}"))) } -fn program_uses_subscription(config: &DaemonConfig, client_id: &str) -> bool { +pub(crate) fn program_uses_subscription(config: &DaemonConfig, client_id: &str) -> bool { match client_id { "claude-code" => config .programs @@ -759,6 +857,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + containment: None, }, discovery, enrollment: crate::enrollment::EnrollmentState::new(false), @@ -769,6 +868,7 @@ mod tests { logout: None, usage: None, programs: None, + violation_ledger: None, }; let cache = Arc::new(crate::llm_proxy::CredentialCache::default()); let expiry = SystemTime::now() @@ -1023,6 +1123,137 @@ llmGateway: fs::remove_dir_all(root).unwrap(); } + // PR 6 (AC-1, N1): applied-config wiring for the `containment` block. + + fn temp_state_dir(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "agentdesktop-api-containment-{name}-{}-{}", + std::process::id(), + rand::random::() + )) + } + + #[test] + fn a_controller_delivered_containment_block_applies_over_a_local_file_without_one() { + let root = temp_state_dir("remote-wraps"); + fs::create_dir_all(&root).unwrap(); + let local = parse_daemon( + r#" +controller: + address: https://controller.example.com +"#, + ) + .unwrap(); + fs::write( + root.join("remote-config.yaml"), + r#" +containment: + programs: + claudeCode: + executable: 'C:\Program Files\Anthropic\ClaudeCode\claude.exe' +"#, + ) + .unwrap(); + + let effective = load_effective_config(&local, &root).unwrap(); + + let claude = effective + .containment + .expect("the controller-delivered block applies") + .programs + .claude_code + .expect("claudeCode present"); + assert_eq!( + claude.mode, + agentdesktop_core::config::ContainmentMode::Wrap + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_local_containment_block_is_ignored_once_a_controller_is_configured() { + let root = temp_state_dir("local-ignored"); + fs::create_dir_all(&root).unwrap(); + // N1: a controller-configured daemon's local `containment` block is + // never consulted; `load_effective_config` reads only the + // controller-delivered `remote-config.yaml`. + let local = parse_daemon( + r#" +controller: + address: https://controller.example.com +containment: + programs: + claudeCode: + executable: 'C:\should\not\apply\claude.exe' +"#, + ); + // The local file itself is accepted (containment is a valid + // top-level field); what matters is that it never reaches `effective` + // once a controller is configured and a remote config is applied. + let local = local.unwrap_or_else(|error| { + panic!("a local containment block parses before any controller applies: {error:#}") + }); + fs::write( + root.join("remote-config.yaml"), + r#" +llmGateway: + url: https://gateway.example.com + authentication: + type: controllerJwt + audience: agentgateway + allowedClientIds: [claude-code] +"#, + ) + .unwrap(); + + let effective = load_effective_config(&local, &root).unwrap(); + + assert!( + effective.containment.is_none(), + "the local containment block is ignored once a controller applies a remote config" + ); + fs::remove_dir_all(root).unwrap(); + } + + /// Adversarial m4: with a controller configured, the local `containment` + /// block must not apply BEFORE the first `remote-config.yaml` exists + /// either; the other local fields keep backing the fallback as before. + #[test] + fn a_local_containment_block_does_not_apply_before_the_first_remote_config() { + let root = temp_state_dir("local-ignored-before-first-push"); + fs::create_dir_all(&root).unwrap(); + let local = parse_daemon( + r#" +controller: + address: https://controller.example.com +llmGateway: + url: http://127.0.0.1:4001 + authentication: + type: oidc + issuer: https://login.example.com + clientId: local-dev +containment: + programs: + claudeCode: + executable: 'C:\should\not\apply\claude.exe' +"#, + ) + .unwrap(); + // No remote-config.yaml in `root`. + + let effective = load_effective_config(&local, &root).unwrap(); + + assert!( + effective.containment.is_none(), + "no containment applies before the first controller push" + ); + assert!( + effective.llm_gateway.is_some(), + "the pre-existing fallback for the other local fields is unchanged" + ); + fs::remove_dir_all(root).unwrap(); + } + mod programs { use std::{path::Path, sync::Arc}; @@ -1156,4 +1387,76 @@ programs: assert!(message.contains("cursor"), "{message}"); } } + + #[test] + fn non_interactive_forces_device_authorization_for_oidc() { + use agentdesktop_core::config::LlmGatewayAuthentication; + + let original = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com + authentication: + type: oidc + issuer: https://login.example.com + clientId: agentdesktop + deviceAuthorization: false +"#, + ) + .unwrap(); + + let transformed = super::non_interactive(original.clone()); + + // Original has deviceAuthorization: false + if let Some(LlmGatewayAuthentication::Oidc { + device_authorization, + .. + }) = &original.llm_gateway.as_ref().unwrap().authentication + { + assert!(!device_authorization); + } else { + panic!("expected OIDC authentication"); + } + + // Transformed has deviceAuthorization: true + if let Some(LlmGatewayAuthentication::Oidc { + device_authorization, + .. + }) = &transformed.llm_gateway.as_ref().unwrap().authentication + { + assert!(device_authorization); + } else { + panic!("expected OIDC authentication"); + } + } + + #[test] + fn non_interactive_leaves_controller_jwt_unchanged() { + let original = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com + authentication: + type: controllerJwt + audience: agentgateway + allowedClientIds: [claude-code] +"#, + ) + .unwrap(); + + let transformed = super::non_interactive(original.clone()); + + // ControllerJwt should be unchanged + assert!(matches!( + transformed.llm_gateway.as_ref().unwrap().authentication, + Some(agentdesktop_core::config::LlmGatewayAuthentication::ControllerJwt { .. }) + )); + } + + #[test] + fn non_interactive_leaves_no_gateway_unchanged() { + let original = parse_daemon("{}").unwrap(); + let transformed = super::non_interactive(original.clone()); + assert!(transformed.llm_gateway.is_none()); + } } diff --git a/crates/agent/src/bin/agentdesktop-headless.rs b/crates/agent/src/bin/agentdesktop-headless.rs index 182688bc..e3e7da3d 100644 --- a/crates/agent/src/bin/agentdesktop-headless.rs +++ b/crates/agent/src/bin/agentdesktop-headless.rs @@ -29,6 +29,12 @@ enum Command { #[tokio::main] async fn main() -> anyhow::Result<()> { + // Inside a sandboxed launch, git starts this executable as the + // interpreter of its per-launch ssh launcher script; that call only runs + // ssh (Windows only, never a CLI command line). + if let Some(code) = agentdesktop_agent::launch::git_ssh::run_if_invoked() { + std::process::exit(code); + } let args = Args::parse(); match args.command { Command::Daemon(daemon) => { diff --git a/crates/agent/src/cli.rs b/crates/agent/src/cli.rs index a9cbc363..ae508304 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -1,13 +1,19 @@ -use std::{io::Read, path::PathBuf}; +use std::{ + io::Read, + path::{Path, PathBuf}, + time::Duration, +}; use agentdesktop_client as client; use agentdesktop_core::{ config::DaemonConfig, model::{ - Discovery, EnrollmentStatus, Health, LlmGatewayCredential, LlmGatewayLoginStatus, - ProgramChoice, ProgramManagement, TelemetryEventKind, + ConsoleAttach, Discovery, EnrollmentStatus, Health, Launch, LaunchConsole, LaunchRequest, + LaunchStatus, LlmGatewayCredential, LlmGatewayLoginStatus, ProgramChoice, + ProgramManagement, SessionCreated, SessionInfo, SessionRequest, TelemetryEventKind, }, }; +use anyhow::Context; use clap::Subcommand; use serde::Deserialize; @@ -38,11 +44,148 @@ pub enum ClientCommand { #[command(subcommand)] command: Option, }, + /// Run a program in a sandbox (Windows AppContainer) with the current + /// directory as its workspace. Arguments after `--` go to the program. + Run { + /// Program to run: `claude-code` (alias `claude`), `opencode` or + /// `copilot` (Copilot CLI). + program: String, + /// Wait for the box to exit. From a console the program runs in that + /// console (same-terminal hosting) and its own exit code is returned; + /// callers without a console get the windowed launch and the + /// executor's exit code. In same-terminal mode Ctrl+C reaches the + /// program; in windowed mode it stops the waiter only. + #[arg(long)] + wait: bool, + /// Arguments passed to the program. + #[arg(last = true)] + args: Vec, + }, + /// Internal: attach the box to a client's console and run the executor. + #[command(hide = true)] + ConsoleAttach { + /// The requesting client process whose console to attach to. + #[arg(long)] + client_pid: u32, + /// The executor binary (`wxc-exec.exe`). + #[arg(long)] + executor: PathBuf, + /// The launch's request.json. + #[arg(long)] + request: PathBuf, + /// The launch's log file for helper diagnostics. + #[arg(long)] + log: PathBuf, + }, /// Handle an event emitted by a managed developer-tool hook. Hook { #[command(subcommand)] hook: HookCommand, }, + /// Manage the local LLM proxy. + LlmProxy { + #[command(subcommand)] + command: LlmProxyCommand, + }, +} + +#[derive(Subcommand)] +pub enum LlmProxyCommand { + /// Manage sandbox sessions on the proxy. + Session { + #[command(subcommand)] + command: SessionCommand, + }, +} + +#[derive(Subcommand)] +pub enum SessionCommand { + /// Register a session and print its pairing as JSON. + Create { + /// Credential policy client ID the session presents to the gateway. + #[arg(long)] + client_id: String, + /// Human-readable name for the session. + #[arg(long)] + label: String, + /// Seconds without a request after which the session expires. + #[arg(long)] + ttl_seconds: Option, + }, + /// List registered sessions. + List, + /// Revoke a session. + Revoke { + /// Session ID from `session create` or `session list`. + id: String, + }, +} + +/// The message printed for a failed `llm-proxy session` command: the daemon's +/// own text for a missing session, a hint at the bound, else the status and +/// body as the client reports them. +fn session_error_message(error: &anyhow::Error) -> String { + match error.downcast_ref::() { + Some(status) if status.status == 404 && !status.body.is_empty() => status.body.clone(), + Some(status) if status.status == 404 => { + "this daemon has no session API (upgrade agentdesktop)".to_owned() + } + Some(status) if status.status == 429 => { + crate::llm_proxy::sessions::too_many_sessions_message() + } + Some(status) => status.to_string(), + None => format!("{error:#}"), + } +} + +const SESSIONS_PATH: &str = "/v1/llm-proxy/sessions"; + +/// A session id as the daemon mints it: 16 lowercase hexadecimal characters. +fn valid_session_id(id: &str) -> bool { + id.len() == 16 + && id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +async fn run_session_command(command: SessionCommand, socket: &Path) -> anyhow::Result<()> { + match command { + SessionCommand::Create { + client_id, + label, + ttl_seconds, + } => { + // The only place the pairing is shown: the caller passes it into + // the sandboxed program's environment. + let created: SessionCreated = client::post_json_response( + socket, + SESSIONS_PATH, + &SessionRequest { + client_id, + label, + ttl_seconds, + }, + ) + .await?; + println!("{}", serde_json::to_string_pretty(&created)?); + } + SessionCommand::List => { + let sessions: Vec = client::get(socket, SESSIONS_PATH).await?; + for session in sessions { + println!( + "{}\t{}\t{}\t{}", + session.id, session.client_id, session.label, session.expires_at_unix_seconds + ); + } + } + SessionCommand::Revoke { id } => { + if !valid_session_id(&id) { + anyhow::bail!("session id must be 16 lowercase hexadecimal characters"); + } + client::delete(socket, &format!("{SESSIONS_PATH}/{id}")).await?; + } + } + Ok(()) } #[derive(Subcommand)] @@ -159,6 +302,52 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()> .await?; println!("{}", response.credential); } + ClientCommand::Run { + program, + wait, + args, + } => { + let cwd = std::env::current_dir().context("read the current directory")?; + let mut request = run_request(&program, cwd, args); + // Same-terminal hosting: a waiting caller that owns a console hands + // it to the daemon; the boxed program runs there and its own exit + // code comes back. Callers without a console keep the window. + if wait && crate::launch::console::client_console_available() { + request.console = Some(ConsoleAttach { + client_pid: std::process::id(), + }); + } + if wait { + let (code, line) = run_and_wait(&socket, &request, RUN_WAIT_POLL).await; + if let Some(line) = line { + eprintln!("{line}"); + } + std::process::exit(code); + } + match client::post_json_response::<_, Launch>(&socket, "/v1/launch", &request).await { + Ok(launch) => { + println!("{}", run_success_line(&launch)); + } + Err(error) => { + eprintln!("{}", run_error_message(&error)); + std::process::exit(1); + } + } + } + ClientCommand::ConsoleAttach { + client_pid, + executor, + request, + log, + } => match crate::launch::console::run_attach(client_pid, &executor, &request, &log) { + Ok(code) => std::process::exit(code), + Err(_) => std::process::exit(crate::launch::console::ATTACH_FAILED_EXIT), + }, + ClientCommand::LlmProxy { + command: LlmProxyCommand::Session { command }, + } => run_session_command(command, &socket) + .await + .map_err(|error| anyhow::anyhow!(session_error_message(&error)))?, ClientCommand::Hook { hook: HookCommand::ClaudePreToolUse { include_input }, } => { @@ -229,6 +418,103 @@ fn print_programs(management: &ProgramManagement) { } } +/// The launch request for `agentdesktop run `; the alias `claude` +/// maps to the provider id `claude-code`. `opencode` and `copilot` are their +/// own IDs (the boxed Copilot CLI is `copilot`, distinct from the unboxed +/// route identity `copilot-cli`). +pub fn run_request(program: &str, cwd: PathBuf, args: Vec) -> LaunchRequest { + let program = if program == "claude" { + "claude-code" + } else { + program + }; + LaunchRequest { + program: program.to_owned(), + cwd, + args, + console: None, + } +} + +/// The line `agentdesktop run` prints on stdout for a successful launch. +pub fn run_success_line(launch: &Launch) -> String { + format!( + "launched {} (container {})", + launch.launch_id, launch.container_id + ) +} + +/// The line `agentdesktop run` prints on stderr for a failed launch request. +pub fn run_error_message(error: &anyhow::Error) -> String { + match error.downcast_ref::() { + Some(status) if status.status == 404 => { + "this daemon does not support `run` (upgrade agentdesktop)".to_owned() + } + Some(status) => status.to_string(), + None => format!("{error:#}"), + } +} + +/// Poll cadence of `agentdesktop run --wait`. +pub(crate) const RUN_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500); + +/// Posts `request`, then polls `GET /v1/launches` every `poll` and +/// selects the launch id client-side (the route has no filter) until it +/// exits, is evicted, or the daemon becomes unreachable. Returns the exit +/// code and, except on a clean exit, exactly one stderr line (newlines in the +/// underlying error joined with `; `; only `--wait` joins, default `run` +/// output is unchanged): +/// - the launch reaches `exited`: no output, exit `exitCode`, or 1 when null; +/// - the POST is refused (transport error, any non-2xx): exit 1, one line; +/// - the launch id is absent from a later poll (evicted, or the daemon +/// restarted): `"launch is no longer known to the daemon"`, exit 1; +/// - the daemon becomes unreachable while waiting: exit 1. +pub(crate) async fn run_and_wait( + socket: &Path, + request: &LaunchRequest, + poll: Duration, +) -> (i32, Option) { + let launch = match client::post_json_response::<_, Launch>(socket, "/v1/launch", request).await + { + Ok(launch) => launch, + Err(error) => return (1, Some(one_line(&run_error_message(&error)))), + }; + // Same-terminal hosting: Ctrl+C belongs to the boxed program; the waiter + // must stay so the shell keeps waiting for `claude` like stock. In windowed + // mode the documented behaviour stays (Ctrl+C ends the waiter only). + if launch.console == LaunchConsole::Attached { + crate::launch::console::ignore_ctrl_c(); + } + loop { + tokio::time::sleep(poll).await; + let launches: Vec = match client::get(socket, "/v1/launches").await { + Ok(launches) => launches, + Err(error) => return (1, Some(one_line(&run_error_message(&error)))), + }; + match launches.iter().find(|l| l.launch_id == launch.launch_id) { + Some(found) if found.status == LaunchStatus::Exited => { + return (found.exit_code.unwrap_or(1), None); + } + Some(_) => {} + None => { + return ( + 1, + Some(format!( + "launch {} is no longer known to the daemon", + launch.launch_id + )), + ); + } + } + } +} + +/// Joins the lines of an error message into one, for `--wait`'s single +/// stderr line. +fn one_line(message: &str) -> String { + message.lines().collect::>().join("; ") +} + async fn report_claude_pre_tool_use( socket: &std::path::Path, include_input: bool, @@ -311,9 +597,16 @@ fn claude_client_id_for_entrypoint(entrypoint: Option<&str>) -> &'static str { #[cfg(test)] mod tests { - use agentdesktop_core::model::{ProgramChoice, TelemetryEventKind}; + use std::path::PathBuf; + + use agentdesktop_client::StatusError; + use agentdesktop_core::model::{LaunchRequest, ProgramChoice, TelemetryEventKind}; + use clap::Parser; - use super::{parse_claude_pre_tool_use, parse_program_choice}; + use super::{ + ClientCommand, LlmProxyCommand, SessionCommand, parse_claude_pre_tool_use, + parse_program_choice, run_error_message, run_request, session_error_message, + }; fn args(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() @@ -338,6 +631,525 @@ mod tests { assert!(parse_program_choice(&args(&["none", "codex"])).is_err()); } + /// Mirrors the binaries: a global `--socket` plus the flattened client commands. + #[derive(Parser)] + struct TestCli { + #[arg(long, global = true, default_value = "default.sock")] + socket: PathBuf, + #[command(subcommand)] + command: ClientCommand, + } + + fn parse(args: &[&str]) -> TestCli { + TestCli::try_parse_from(std::iter::once("agentdesktop").chain(args.iter().copied())) + .unwrap_or_else(|error| panic!("{error}")) + } + + #[test] + fn run_maps_the_claude_alias_to_the_provider_id() { + let cwd = PathBuf::from("/work/app"); + assert_eq!( + run_request("claude", cwd.clone(), vec!["--model".into(), "x".into()]), + LaunchRequest { + program: "claude-code".into(), + cwd: cwd.clone(), + args: vec!["--model".into(), "x".into()], + console: None, + } + ); + assert_eq!( + run_request("claude-code", cwd.clone(), vec![]), + LaunchRequest { + program: "claude-code".into(), + cwd, + args: vec![], + console: None, + } + ); + } + + #[test] + fn run_passes_everything_after_the_separator_to_the_program() { + let cli = parse(&["run", "claude", "--", "--socket", "x.sock", "--model", "x"]); + let ClientCommand::Run { program, args, .. } = cli.command else { + panic!("expected the run command"); + }; + assert_eq!(program, "claude"); + assert_eq!(args, ["--socket", "x.sock", "--model", "x"]); + assert_eq!( + cli.socket, + PathBuf::from("default.sock"), + "--socket after -- belongs to the program" + ); + } + + #[test] + fn run_honours_the_socket_flag_before_the_separator_and_allows_no_arguments() { + let cli = parse(&["--socket", "mine.sock", "run", "claude", "--", "--debug"]); + assert_eq!(cli.socket, PathBuf::from("mine.sock")); + let ClientCommand::Run { args, .. } = cli.command else { + panic!("expected the run command"); + }; + assert_eq!(args, ["--debug"]); + + let cli = parse(&["run", "claude", "--socket", "late.sock"]); + assert_eq!(cli.socket, PathBuf::from("late.sock")); + let ClientCommand::Run { args, .. } = cli.command else { + panic!("expected the run command"); + }; + assert!(args.is_empty()); + } + + #[test] + fn run_error_message_maps_404_to_an_upgrade_hint() { + let error = anyhow::Error::new(StatusError { + status: 404, + body: String::new(), + }); + assert_eq!( + run_error_message(&error), + "this daemon does not support `run` (upgrade agentdesktop)" + ); + } + + #[test] + fn run_error_message_prints_the_body_after_the_status() { + let error = anyhow::Error::new(StatusError { + status: 409, + body: "executor not found: C:\\missing\\wxc-exec.exe".into(), + }); + assert_eq!( + run_error_message(&error), + "daemon returned 409 Conflict: executor not found: C:\\missing\\wxc-exec.exe" + ); + } + + #[test] + fn run_error_message_keeps_the_text_of_a_transport_error() { + let error = anyhow::anyhow!("connect to /tmp/x.sock\nCheck that the daemon is running."); + assert!(run_error_message(&error).contains("connect to /tmp/x.sock")); + } + + // AC 4: run_success_line test + #[test] + fn run_success_line_formats_launch_and_container_id() { + use agentdesktop_core::model::{Launch, LaunchStatus}; + let launch = Launch { + launch_id: "0123456789abcdef".into(), + program: "claude-code".into(), + cwd: "/work/app".into(), + container_id: "agentdesktop-0123456789abcdef".into(), + status: LaunchStatus::Running, + exit_code: None, + started_at_unix_seconds: 1_700_000_000, + ended_at_unix_seconds: None, + integrity_drift: false, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + session_id: "test-session-id".into(), + console: agentdesktop_core::model::LaunchConsole::Window, + }; + let line = super::run_success_line(&launch); + assert_eq!( + line, + "launched 0123456789abcdef (container agentdesktop-0123456789abcdef)" + ); + } + + // PR 6 (AC-4): `run --wait`. + + #[test] + fn run_wait_is_off_by_default_and_parses_as_a_flag() { + let cli = parse(&["run", "claude"]); + let ClientCommand::Run { wait, .. } = cli.command else { + panic!("expected the run command"); + }; + assert!(!wait, "default run behaviour is unchanged without --wait"); + + let cli = parse(&["run", "claude", "--wait"]); + let ClientCommand::Run { wait, .. } = cli.command else { + panic!("expected the run command"); + }; + assert!(wait); + } + + #[cfg(target_os = "linux")] + mod run_and_wait_tests { + use std::{ + convert::Infallible, + path::PathBuf, + sync::{Arc, Mutex}, + time::Duration, + }; + + use agentdesktop_core::model::{Launch, LaunchRequest, LaunchStatus}; + use bytes::Bytes; + use http_body_util::{BodyExt, Full}; + use hyper::{Request, Response, service::service_fn}; + use hyper_util::rt::TokioIo; + use tokio::net::UnixListener; + + use super::super::run_and_wait; + + /// One canned HTTP response: status and body. + type Canned = (u16, String); + + /// A daemon stand-in on a Unix socket. `/v1/launch` always answers + /// `post_launch`; `/v1/launches` answers the next entry of + /// `launches_sequence` (each GET consumes one; the last entry repeats). + struct FakeDaemon { + socket: PathBuf, + task: tokio::task::JoinHandle<()>, + } + + impl Drop for FakeDaemon { + fn drop(&mut self) { + self.task.abort(); + let _ = std::fs::remove_file(&self.socket); + } + } + + fn fake_daemon( + name: &str, + post_launch: Canned, + launches_sequence: Vec, + ) -> FakeDaemon { + let socket = std::env::temp_dir().join(format!( + "agentdesktop-cli-test-{}-{name}.sock", + std::process::id() + )); + let _ = std::fs::remove_file(&socket); + let listener = UnixListener::bind(&socket).unwrap(); + let post_launch = Arc::new(post_launch); + let launches_sequence = Arc::new(Mutex::new(launches_sequence)); + let task = tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let post_launch = post_launch.clone(); + let launches_sequence = launches_sequence.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(stream), + service_fn(move |request: Request| { + let post_launch = post_launch.clone(); + let launches_sequence = launches_sequence.clone(); + async move { + let path = request.uri().path().to_owned(); + let _ = request.into_body().collect().await; + let (status, body) = if path == "/v1/launch" { + (*post_launch).clone() + } else { + let mut sequence = launches_sequence.lock().unwrap(); + if sequence.len() > 1 { + sequence.remove(0) + } else { + sequence[0].clone() + } + }; + Ok::<_, Infallible>( + Response::builder() + .status(status) + .body(Full::new(Bytes::from(body))) + .unwrap(), + ) + } + }), + ) + .await; + }); + } + }); + FakeDaemon { socket, task } + } + + fn request() -> LaunchRequest { + LaunchRequest { + program: "claude-code".into(), + cwd: "/work/app".into(), + args: vec![], + console: None, + } + } + + fn launch_json(status: LaunchStatus, exit_code: Option) -> String { + serde_json::to_string(&Launch { + launch_id: "0123456789abcdef".into(), + program: "claude-code".into(), + cwd: "/work/app".into(), + container_id: "agentdesktop-0123456789abcdef".into(), + status, + exit_code, + started_at_unix_seconds: 1_700_000_000, + ended_at_unix_seconds: None, + integrity_drift: false, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + session_id: "test-session-id".into(), + console: agentdesktop_core::model::LaunchConsole::Window, + }) + .unwrap() + } + + #[tokio::test] + async fn an_exited_launch_with_a_code_prints_nothing_and_returns_it() { + let running = launch_json(LaunchStatus::Running, None); + let exited = launch_json(LaunchStatus::Exited, Some(7)); + let daemon = fake_daemon( + "exit-code", + (201, running.clone()), + vec![(200, format!("[{running}]")), (200, format!("[{exited}]"))], + ); + let (code, line) = + run_and_wait(&daemon.socket, &request(), Duration::from_millis(1)).await; + assert_eq!(code, 7); + assert_eq!(line, None); + } + + #[tokio::test] + async fn an_exited_launch_with_no_code_returns_one() { + let exited = launch_json(LaunchStatus::Exited, None); + let daemon = fake_daemon( + "exit-null", + (201, exited.clone()), + vec![(200, format!("[{exited}]"))], + ); + let (code, line) = + run_and_wait(&daemon.socket, &request(), Duration::from_millis(1)).await; + assert_eq!(code, 1); + assert_eq!(line, None); + } + + #[tokio::test] + async fn a_refused_post_answers_exit_1_with_one_stderr_line() { + for status in [400u16, 403, 404, 409, 500] { + let daemon = fake_daemon( + &format!("post-refused-{status}"), + (status, "refused".to_owned()), + vec![(200, "[]".to_owned())], + ); + let (code, line) = + run_and_wait(&daemon.socket, &request(), Duration::from_millis(1)).await; + assert_eq!(code, 1, "status {status}"); + let line = line.unwrap_or_else(|| panic!("status {status} needs a stderr line")); + assert_eq!(line.lines().count(), 1, "{line}"); + } + } + + /// AC-4: no failure mode of `run --wait` falls back to running a + /// native `claude.exe`. A sentinel executable sits beside the test's + /// socket; had anything executed it, the marker file would exist. + #[tokio::test] + async fn a_sentinel_claude_exe_beside_the_test_never_runs() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let sentinel = dir.path().join("claude.exe"); + let marker = dir.path().join("marker"); + std::fs::write( + &sentinel, + format!("#!/bin/sh\ntouch '{}'\n", marker.display()), + ) + .unwrap(); + std::fs::set_permissions(&sentinel, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // A refused POST and a vanished id both end the wait with exit 1; + // neither may start anything natively. + let daemon = fake_daemon( + "sentinel", + (409, "refused".to_owned()), + vec![(200, "[]".to_owned())], + ); + let (code, line) = + run_and_wait(&daemon.socket, &request(), Duration::from_millis(1)).await; + assert_eq!(code, 1); + assert!(line.is_some()); + assert!(!marker.exists(), "the sentinel claude.exe was executed"); + } + + #[tokio::test] + async fn an_id_that_vanishes_mid_wait_is_reported_and_exits_1() { + let running = launch_json(LaunchStatus::Running, None); + let daemon = fake_daemon( + "evicted", + (201, running.clone()), + vec![(200, format!("[{running}]")), (200, "[]".to_owned())], + ); + let (code, line) = + run_and_wait(&daemon.socket, &request(), Duration::from_millis(1)).await; + assert_eq!(code, 1); + assert_eq!( + line, + Some("launch 0123456789abcdef is no longer known to the daemon".to_owned()) + ); + } + + /// Serves exactly the POST `/v1/launch`, then stops: the socket file + /// is removed, so the first poll's connect fails (unreachable mid-wait). + #[tokio::test] + async fn an_unreachable_daemon_while_waiting_exits_1_with_one_line() { + let running = launch_json(LaunchStatus::Running, None); + let socket = std::env::temp_dir().join(format!( + "agentdesktop-cli-test-{}-midwait.sock", + std::process::id() + )); + let _ = std::fs::remove_file(&socket); + let listener = UnixListener::bind(&socket).unwrap(); + let task = tokio::spawn({ + let socket = socket.clone(); + async move { + let (stream, _) = listener.accept().await.unwrap(); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(stream), + service_fn(move |request: Request| { + let running = running.clone(); + async move { + let _ = request.into_body().collect().await; + Ok::<_, Infallible>( + Response::builder() + .status(201) + .body(Full::new(Bytes::from(running))) + .unwrap(), + ) + } + }), + ) + .await; + drop(listener); + let _ = std::fs::remove_file(&socket); + } + }); + let (code, line) = run_and_wait(&socket, &request(), Duration::from_millis(1)).await; + task.abort(); + assert_eq!(code, 1); + let line = line.unwrap(); + assert_eq!(line.lines().count(), 1, "{line}"); + } + } + + fn session_command(args: &[&str]) -> Result { + let mut argv = vec!["agentdesktop", "llm-proxy", "session"]; + argv.extend_from_slice(args); + match TestCli::try_parse_from(argv)?.command { + ClientCommand::LlmProxy { + command: LlmProxyCommand::Session { command }, + } => Ok(command), + _ => panic!("expected an llm-proxy session command"), + } + } + + #[test] + fn parses_session_create_with_and_without_a_ttl() { + let SessionCommand::Create { + client_id, + label, + ttl_seconds, + } = session_command(&[ + "create", + "--client-id", + "claude-code", + "--label", + "lab session", + "--ttl-seconds", + "120", + ]) + .unwrap() + else { + panic!("expected create"); + }; + assert_eq!(client_id, "claude-code"); + assert_eq!(label, "lab session"); + assert_eq!(ttl_seconds, Some(120)); + + let SessionCommand::Create { ttl_seconds, .. } = + session_command(&["create", "--client-id", "my_tool", "--label", "x"]).unwrap() + else { + panic!("expected create"); + }; + assert_eq!(ttl_seconds, None); + } + + #[test] + fn session_create_requires_a_client_id_a_label_and_a_numeric_ttl() { + assert!(session_command(&["create", "--label", "x"]).is_err()); + assert!(session_command(&["create", "--client-id", "claude-code"]).is_err()); + assert!( + session_command(&[ + "create", + "--client-id", + "claude-code", + "--label", + "x", + "--ttl-seconds", + "soon", + ]) + .is_err() + ); + } + + #[test] + fn parses_session_list_and_revoke() { + assert!(matches!( + session_command(&["list"]).unwrap(), + SessionCommand::List + )); + let SessionCommand::Revoke { id } = + session_command(&["revoke", "0123456789abcdef"]).unwrap() + else { + panic!("expected revoke"); + }; + assert_eq!(id, "0123456789abcdef"); + assert!(session_command(&["revoke"]).is_err()); + assert!(session_command(&[]).is_err()); + assert!(session_command(&["show"]).is_err()); + } + + fn status_error(status: u16, body: &str) -> anyhow::Error { + anyhow::Error::new(agentdesktop_client::StatusError { + status, + body: body.to_owned(), + }) + } + + #[test] + fn session_error_message_names_the_session_for_404() { + assert_eq!( + session_error_message(&status_error(404, "no session 0123456789abcdef")), + "no session 0123456789abcdef" + ); + } + + #[test] + fn session_error_message_explains_429() { + assert_eq!( + session_error_message(&status_error( + 429, + "too many sessions (64); revoke unused ones" + )), + "too many sessions (64); revoke unused ones" + ); + // The message is the CLI's own, whatever body the daemon sent. + assert_eq!( + session_error_message(&status_error(429, "")), + "too many sessions (64); revoke unused ones" + ); + } + + #[test] + fn session_error_message_reports_other_statuses_with_reason_and_body() { + assert_eq!( + session_error_message(&status_error(409, "the local LLM proxy is not running")), + "daemon returned 409 Conflict: the local LLM proxy is not running" + ); + assert_eq!( + session_error_message(&status_error(500, "boom")), + "daemon returned 500 Internal Server Error: boom" + ); + } + #[test] fn parses_claude_pre_tool_use_input_without_unrelated_fields() { let event = parse_claude_pre_tool_use( @@ -371,3 +1183,35 @@ mod tests { assert!(!serialized.contains("transcript")); } } + +#[cfg(test)] +mod session_cli_tests { + use super::{session_error_message, valid_session_id}; + + #[test] + fn a_session_id_is_sixteen_lowercase_hex_characters() { + assert!(valid_session_id("0123456789abcdef")); + for bad in [ + "0123456789ABCDEF", + "0123456789abcde", + "0123456789abcdef0", + "a?b", + "", + "a b", + ] { + assert!(!valid_session_id(bad), "{bad:?}"); + } + } + + #[test] + fn an_empty_404_means_an_older_daemon_without_the_session_api() { + let error = anyhow::Error::new(agentdesktop_client::StatusError { + status: 404, + body: String::new(), + }); + assert_eq!( + session_error_message(&error), + "this daemon has no session API (upgrade agentdesktop)" + ); + } +} diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index bbb8a35c..aaa8740f 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -37,8 +37,8 @@ use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; #[cfg(windows)] use crate::windows_security::SecurityDescriptor; use crate::{ - api, enrollment::EnrollmentState, gateway_oidc, inventory_watch::InventoryWatch, llm_proxy, - reconcile, remote, secure_fs, + api, enrollment::EnrollmentState, gateway_oidc, inventory_watch::InventoryWatch, launch, + llm_proxy, reconcile, remote, secure_fs, }; #[cfg(unix)] @@ -69,6 +69,13 @@ pub struct DaemonArgs { /// Path to the local YAML configuration file. #[arg(long)] config: Option, + + /// Start the user daemon without a console window (Windows). The daemon + /// re-executes itself detached with its standard handles inherited, so + /// the scheduled task that started it completes and no console window + /// (and no taskbar entry) exists. Requires --user. + #[arg(long)] + hidden: bool, } struct ResolvedDaemonArgs { @@ -97,6 +104,14 @@ struct ResolvedDaemonArgs { reconcile_interval: Option, once: bool, dry_run: bool, + /// `daemon.containment`; `None` for `--once` and `--dry-run`. + containment: Option, + /// Whether `daemon.claudeCode.config` was set: a box reads the default path. + claude_settings_overridden: bool, + /// Resolved copy of `daemon.containment.egress` for the launch routes. + containment_egress: config::ContainmentEgress, + /// Resolved copy of `daemon.containment.engine`; selects the sandbox engine. + containment_engine: config::ContainmentEngine, } struct ResolvedToolConfigPath { @@ -126,7 +141,17 @@ impl DaemonArgs { socket: PathBuf, ) -> anyhow::Result { let user = self.user || startup.user; - let socket = startup.socket.clone().unwrap_or(socket); + // A non-default socket only arrives here from `daemon --user --socket` + // (run_until_shutdown refuses it for the system daemon); the flag wins + // over `daemon.socket` so one shared, non-env-expanded config file can + // coexist with per-user pipe names from the task line (DV6-8). A + // `--socket` equal to the default is indistinguishable from no flag, so + // the config value wins then (harmless for per-user names; not fixed). + let socket = if socket != Path::new(DEFAULT_SOCKET_PATH) { + socket + } else { + startup.socket.clone().unwrap_or(socket) + }; let llm_proxy_listen = startup.llm_proxy.listen; // The proxy issues the current user's gateway credential to whatever // connects on loopback; a system daemon has no single user to act for. @@ -154,6 +179,16 @@ impl DaemonArgs { .usage .clone() .filter(|_| !(self.once || self.dry_run)); + // A sandboxed launch runs as the daemon's user with that user's gateway + // credential; like the proxy, it has no meaning in system mode. + let containment = startup + .containment + .clone() + .filter(|_| !(self.once || self.dry_run)); + if containment.is_some() && !user { + bail!("daemon.containment requires --user (or daemon.user: true)"); + } + let claude_settings_overridden = startup.claude_code.config.is_some(); // VS Code's chatLanguageModels.json and settings.json live in the // user's own profile, like the Copilot CLI providers file: a system // daemon has no user file to manage, so an explicit override is @@ -238,6 +273,10 @@ impl DaemonArgs { reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, + containment, + claude_settings_overridden, + containment_egress: config::ContainmentEgress::default(), + containment_engine: config::ContainmentEngine::default(), }); } @@ -334,6 +373,18 @@ impl DaemonArgs { reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, + containment, + claude_settings_overridden, + containment_egress: startup + .containment + .as_ref() + .map(|c| c.egress) + .unwrap_or_default(), + containment_engine: startup + .containment + .as_ref() + .map(|c| c.engine) + .unwrap_or_default(), }) } } @@ -371,6 +422,19 @@ fn user_claude_desktop_settings(_home: &Path, _config_home: &Path) -> PathBuf { } pub async fn run(args: DaemonArgs, socket: PathBuf) -> anyhow::Result<()> { + // `--hidden` (Windows user daemon): re-execute without a console and let + // this process exit. The child has no console object at all, so there is + // no window, no minimized state and no taskbar entry; it inherits the + // standard handles, so a task's log redirection keeps receiving output. + #[cfg(windows)] + if args.hidden && args.user { + detach_windowless()?; + return Ok(()); + } + #[cfg(not(windows))] + if args.hidden && args.user { + eprintln!("agentdesktop: --hidden is only supported on Windows; starting normally"); + } run_until_shutdown(args, socket, async { tokio::signal::ctrl_c() .await @@ -379,6 +443,44 @@ pub async fn run(args: DaemonArgs, socket: PathBuf) -> anyhow::Result<()> { .await } +/// The child command line for `--hidden`: the original arguments without +/// `--hidden`, so the re-executed daemon starts normally (no second detach). +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn detached_argv(args: I) -> Vec +where + I: IntoIterator, +{ + args.into_iter() + .filter(|arg| arg.as_os_str() != std::ffi::OsStr::new("--hidden")) + .collect() +} + +/// Re-executes this daemon without a console, keeping the standard handles. +#[cfg(windows)] +fn detach_windowless() -> anyhow::Result<()> { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let executable = std::env::current_exe().context("resolve the daemon executable")?; + let mut command = Command::new(executable); + command + .args(detached_argv(std::env::args_os().skip(1))) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .creation_flags(CREATE_NO_WINDOW); + command.spawn().context("start the windowless daemon")?; + Ok(()) +} + +fn check_socket_flag(socket: &Path, user: bool) -> anyhow::Result<()> { + if socket != Path::new(DEFAULT_SOCKET_PATH) && !user { + bail!("set daemon.socket in the configuration file; --socket is for client commands"); + } + Ok(()) +} + /// Serves the local API until the `shutdown` future resolves or fails. pub async fn run_until_shutdown( args: DaemonArgs, @@ -388,9 +490,12 @@ pub async fn run_until_shutdown( where F: Future> + Send, { - if socket != Path::new(DEFAULT_SOCKET_PATH) { - bail!("set daemon.socket in the configuration file; --socket is for client commands"); - } + // `daemon --user` accepts `--socket`: the per-user pipe name on Windows + // comes from one shared scheduled-task line (`%USERNAME%` expanded at + // start), and config `daemon.socket` values are not env-expanded, so a + // shared config file cannot carry per-user names (DV6-8). The system + // daemon keeps refusing the flag. + check_socket_flag(&socket, args.user)?; let config_path = match args.config.clone() { Some(path) => path, None if args.user => std::env::var_os("XDG_CONFIG_HOME") @@ -492,6 +597,56 @@ where } secure_fs::ensure_private_dir(&args.state_dir)?; + #[cfg(windows)] + let daemon_sid = if args.user { + // The user-mode pipe admits this SID only; without it the daemon must + // not fall back to the interactive-users descriptor. + let identity = + launch::peer_identity(std::process::id()).context("read the daemon's own user SID")?; + Some( + identity + .user_sid + .context("the daemon's token has no user SID")?, + ) + } else { + None + }; + #[cfg(not(windows))] + let daemon_sid: Option = None; + // On Windows user mode, bind the first pipe instance now so a duplicate + // daemon fails here before mutating any state. The bound instance is + // handed to serve_named_pipe later. + #[cfg(windows)] + let first_pipe_server = if args.user { + let sddl = launch::user_mode_pipe_sddl( + daemon_sid + .as_ref() + .expect("user mode has a daemon SID above"), + ); + // Launch directories a previous daemon left behind: secrets go now, the + // directory of a box that still runs stays until it exits. The sweep + // runs only after bind succeeds, so a duplicate daemon mutates nothing. + let state_dir = args.state_dir.clone(); + Some(bind_then_sweep(&socket, &sddl, || { + if let Err(error) = launch::sweep(&state_dir, &launch::host::process_alive) { + tracing::warn!(error = %format!("{error:#}"), "sweep of previous sandboxed launches failed"); + } + })?) + } else { + None + }; + // The sandbox engine is read at startup only; `mxc` is the only engine. + let engine: Arc = match args.containment_engine { + config::ContainmentEngine::Mxc => Arc::new(launch::engine::mxc::MxcEngine), + }; + let containment = launch::containment_info( + args.containment.as_ref(), + args.user, + cfg!(windows), + args.claude_settings_overridden, + &|path| path.exists(), + engine.name(), + ); let (usage_store, usage_receiver) = match &args.usage { Some(usage) => { let (store, receiver) = start_usage(usage, &args.state_dir).await; @@ -501,6 +656,7 @@ where }; let daemon_info = DaemonInfo { usage_receiver, + containment: containment.clone(), ..describe_daemon(&config, &args.config, &args.state_dir, args.user, llm_proxy) }; // A program the device user opted out of does not ask for a sign-in, and @@ -627,6 +783,38 @@ where )); } let has_controller = config.controller.is_some(); + // The violation ledger exists only in a Windows user-mode daemon whose + // `daemon.containment` is present: the same daemons whose launch routes + // work. The system daemon receives the same policy and must not scan. + let violation_ledger = (cfg!(windows) && args.user && args.containment.is_some()) + .then(|| std::sync::Arc::new(std::sync::Mutex::new(launch::wrap::ViolationLedger::new()))); + // A device that receives a `containment` policy block without the local + // `daemon.containment` prerequisite reports no posture and refuses + // launches with the existing 409; warn so the gap is visible, at startup + // (the initial watch value) and on each applied configuration. Windows + // only: launches, the scan and the posture exist only there, so a macOS + // or Linux user daemon receiving the same fleet policy stays quiet. + if cfg!(windows) && args.user && args.containment.is_none() { + let mut configs = current_config.subscribe(); + tokio::spawn(async move { + loop { + { + let current = configs.borrow_and_update(); + if current + .as_ref() + .is_some_and(|current| current.config.containment.is_some()) + { + tracing::warn!( + "the applied configuration carries a containment policy, but daemon.containment is not configured on this device: launches are refused and no wrap posture is reported" + ); + } + } + if configs.changed().await.is_err() { + return; + } + } + }); + } let state = api::AppState { config, daemon_info, @@ -639,8 +827,80 @@ where logout, usage: usage_store, programs: Some(program_control), + violation_ledger: violation_ledger.clone(), }; - let app = api::router(state.clone()); + // Native runs of a wrap-mode program's managed executable become posture + // violations: a report-only scan every VIOLATION_SCAN_INTERVAL, reading + // the applied configuration once per tick. + #[cfg(windows)] + if let Some(ledger) = violation_ledger { + let daemon_user_sid = daemon_sid + .clone() + .expect("a Windows user-mode daemon read its own SID above"); + tokio::spawn(launch::wrap::scan_loop( + state.config.clone(), + state.state_dir.clone(), + daemon_user_sid, + ledger, + launch::wrap::native_process_snapshot, + || { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0) + }, + )); + } + #[cfg(not(windows))] + drop(violation_ledger); + // Sandbox sessions (`/sandbox` route): one registry shared by the proxy, + // the local API that mints and revokes sessions, and the sweep. + let sessions = llm_proxy::sessions::SessionRegistry::default(); + let session_policy = std::sync::Arc::new(llm_proxy::sessions::ConfigPolicy { + config: state.config.clone(), + state_dir: state.state_dir.clone(), + }); + let session_api = llm_proxy::sessions::SessionApiState { + registry: sessions.clone(), + proxy_address: proxy_listener + .as_ref() + .and_then(|listener| listener.local_addr().ok()), + policy: session_policy.clone(), + }; + let launch_state = launch::LaunchState { + app: state.clone(), + executor: Arc::new(launch::host::ProcessExecutor), + credentials: Arc::new(launch::host::GatewayCredentialSource { app: state.clone() }), + host: Arc::new(launch::host::HostEnvironment::new( + home_directory().unwrap_or_default(), + state.state_dir.clone(), + &agentdesktop_client_executable()?, + )), + registry: launch::LaunchRegistry::new(), + availability: containment, + daemon_sid: daemon_sid.clone(), + sessions: sessions.clone(), + proxy_address: proxy_listener + .as_ref() + .and_then(|listener| listener.local_addr().ok()), + session_policy, + egress: args.containment_egress, + git: args.containment.as_ref().and_then(|c| c.git.clone()), + claude_settings_overridden: args.claude_settings_overridden, + engine, + }; + let app = api::router(state.clone()) + .merge(llm_proxy::sessions::session_router(session_api)) + .merge(launch::router(launch_state)); + let session_sweep = tokio::spawn(llm_proxy::sessions::sweep_forever(sessions.clone())); + // The git allowlists/credentials and the central egress upstream the git + // routes serve, read once at startup from daemon.containment (PR 4). + let containment_git = args.containment.as_ref().and_then(|c| c.git.clone()); + let containment_egress_upstream = args + .containment + .as_ref() + .and_then(|c| c.egress_upstream.as_ref()) + .map(|upstream| upstream.url.clone()); // The proxy is optional at runtime too: if serving fails (for example the // TLS root store cannot be built), log it and keep the daemon running // without the proxy rather than dropping the device out of the fleet. @@ -652,6 +912,9 @@ where llm_proxy::ProxyConfig { default_client_id: args.llm_proxy_client_id, pairing: pairing.expect("pairing exists whenever the listener does"), + sessions, + git: containment_git, + egress_upstream: containment_egress_upstream, }, ) .await @@ -668,11 +931,13 @@ where #[cfg(unix)] let local_api = serve_unix(&socket, local_api_access()?, app, shutdown); #[cfg(windows)] - let local_api = serve_named_pipe(&socket, app, shutdown); - tokio::select! { - result = local_api => result?, - result = proxy => result?, - } + let local_api = serve_named_pipe(&socket, app, shutdown, daemon_sid, first_pipe_server); + let result = tokio::select! { + result = local_api => result, + result = proxy => result, + }; + session_sweep.abort(); + result?; Ok(()) } @@ -847,6 +1112,7 @@ fn describe_daemon( controller, llm_proxy, usage_receiver: None, + containment: None, } } @@ -1165,7 +1431,10 @@ fn validate_one_shot( Ok(()) } -async fn serve_local_connection(stream: I, app: axum::Router) -> Result<(), hyper::Error> +pub(crate) async fn serve_local_connection( + stream: I, + app: axum::Router, +) -> Result<(), hyper::Error> where I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { @@ -1220,8 +1489,20 @@ async fn serve_named_pipe( socket: &std::path::Path, app: axum::Router, shutdown: impl Future> + Send, + peer_check: Option, + first_instance: Option, ) -> anyhow::Result<()> { - let mut server = bind_named_pipe(socket, true)?; + // Full access for SYSTEM and Administrators, read/write for interactive + // users: the system daemon serves every user of the device. A user-mode + // daemon admits its own user only (`peer_check` is that user's SID). + let sddl = match &peer_check { + Some(sid) => launch::user_mode_pipe_sddl(sid), + None => launch::SYSTEM_PIPE_SDDL.to_owned(), + }; + let mut server = match first_instance { + Some(s) => s, + None => bind_named_pipe(socket, true, &sddl)?, + }; tokio::pin!(shutdown); loop { tokio::select! { @@ -1231,8 +1512,13 @@ async fn serve_named_pipe( // Keep an unconnected instance available while the accepted // connection is being served. Without this, clients can see a // transient pipe-not-found error between connections. - server = bind_named_pipe(socket, false)?; - let app = app.clone(); + server = bind_named_pipe(socket, false, &sddl)?; + let app = match &peer_check { + // The launch routes check the caller: its identity rides + // on this connection's router as an extension. + Some(_) => app.clone().layer(axum::Extension(pipe_peer(&connected))), + None => app.clone(), + }; tokio::spawn(async move { if let Err(error) = serve_local_connection(connected, app).await { tracing::warn!(%error, "failed to serve local API connection"); @@ -1248,16 +1534,44 @@ async fn serve_named_pipe( Ok(()) } +/// The identity of the process at the client end of a freshly accepted pipe; +/// an unreadable identity is a peer with no SID, which the launch routes refuse. #[cfg(windows)] -fn bind_named_pipe(path: &std::path::Path, first: bool) -> anyhow::Result { - // Full access for SYSTEM and Administrators, read/write for interactive users. - const PIPE_SDDL: &str = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"; +fn pipe_peer(connected: &NamedPipeServer) -> launch::PipePeer { + use std::os::windows::io::AsRawHandle; + + use windows_sys::Win32::System::Pipes::GetNamedPipeClientProcessId; + + let mut pid = 0u32; + // SAFETY: the handle is a connected pipe instance; `pid` is a valid out-parameter. + let ok = unsafe { GetNamedPipeClientProcessId(connected.as_raw_handle() as _, &mut pid) }; + if ok == 0 { + tracing::debug!(error = %std::io::Error::last_os_error(), "pipe client pid unavailable"); + return launch::PipePeer { + user_sid: None, + app_container: false, + }; + } + launch::peer_identity(pid).unwrap_or_else(|error| { + tracing::debug!(pid, error = %format!("{error:#}"), "pipe client identity unavailable"); + launch::PipePeer { + user_sid: None, + app_container: false, + } + }) +} +#[cfg(windows)] +fn bind_named_pipe( + path: &std::path::Path, + first: bool, + sddl: &str, +) -> anyhow::Result { let mut options = ServerOptions::new(); options .first_pipe_instance(first) .reject_remote_clients(true); - let descriptor = SecurityDescriptor::from_sddl(PIPE_SDDL)?; + let descriptor = SecurityDescriptor::from_sddl(sddl)?; let mut attributes = SECURITY_ATTRIBUTES { nLength: std::mem::size_of::() as u32, lpSecurityDescriptor: descriptor.as_ptr(), @@ -1274,6 +1588,20 @@ fn bind_named_pipe(path: &std::path::Path, first: bool) -> anyhow::Result anyhow::Result { + let server = bind_named_pipe(socket, true, sddl)?; + sweep(); + Ok(server) +} + fn agentdesktop_client_executable() -> anyhow::Result { let executable = std::env::current_exe().context("locate agentdesktop executable")?; Ok(client_executable_for_daemon(&executable)) @@ -1402,6 +1730,10 @@ mod tests { }; use super::{DaemonArgs, bind_llm_proxy}; + #[cfg(windows)] + use super::{bind_named_pipe, bind_then_sweep}; + #[cfg(windows)] + use crate::launch; use agentdesktop_core::DEFAULT_SOCKET_PATH; use agentdesktop_core::config::{self, parse_daemon}; use agentdesktop_core::model::{Agent, Discovery, LlmProxyInfo}; @@ -1424,6 +1756,7 @@ mod tests { once: false, dry_run: false, config: None, + hidden: false, }; let result = args.resolve( config.daemon.unwrap(), @@ -1712,6 +2045,7 @@ programs: logout: None, usage: None, programs: None, + violation_ledger: None, }); for (method, expected_status) in [("GET", "200 OK"), ("POST", "405 Method Not Allowed")] { @@ -1851,6 +2185,7 @@ programs: logout: None, usage: store, programs: None, + violation_ledger: None, }); let (mut client, server) = tokio::io::duplex(64 * 1024); let connection = tokio::spawn(serve_local_connection(server, app)); @@ -1937,6 +2272,7 @@ programs: logout: None, usage: None, programs: None, + violation_ledger: None, }); for endpoint in ["/v1/health", "/v1/config", "/v1/daemon-info"] { @@ -2266,9 +2602,29 @@ programs: once: false, dry_run: false, config: None, + hidden: false, } } + #[test] + fn the_windowless_reexec_drops_only_hidden() { + use std::ffi::OsString; + + let argv: Vec = ["--hidden", "daemon", "--user", "--socket", r"\\.\pipe\x"] + .iter() + .map(OsString::from) + .collect(); + assert_eq!( + super::detached_argv(argv), + ["daemon", "--user", "--socket", r"\\.\pipe\x"] + .iter() + .map(OsString::from) + .collect::>() + ); + let plain: Vec = ["daemon", "--user"].iter().map(OsString::from).collect(); + assert_eq!(super::detached_argv(plain.clone()), plain); + } + fn resolve_error(args: DaemonArgs, startup: config::DaemonStartupConfig) -> anyhow::Error { match args.resolve( startup, @@ -2280,6 +2636,184 @@ programs: } } + #[test] + fn socket_flag_gate_decisions() { + let custom = Path::new(r"\\.\pipe\agentdesktop-user-alice"); + let error = crate::daemon::check_socket_flag(custom, false).unwrap_err(); + assert_eq!( + error.to_string(), + "set daemon.socket in the configuration file; --socket is for client commands" + ); + crate::daemon::check_socket_flag(custom, true).unwrap(); + crate::daemon::check_socket_flag(Path::new(DEFAULT_SOCKET_PATH), false).unwrap(); + } + + #[test] + fn user_mode_socket_flag_overrides_the_config_socket() { + // DV6-8: the scheduled-task wrapper passes the per-user pipe name on + // the command line; it must win over a shared config's daemon.socket. + let flag = PathBuf::from(r"\\.\pipe\agentdesktop-user-alice"); + let startup = config::DaemonStartupConfig { + socket: Some(PathBuf::from(r"\\.\pipe\agentdesktop-from-config")), + ..Default::default() + }; + let resolved = daemon_args(true) + .resolve(startup, PathBuf::from("config.yaml"), flag.clone()) + .unwrap(); + assert_eq!(resolved.socket, flag); + + // Without a config value the flag is used as given, not replaced by + // the user-mode default socket path. + let resolved = daemon_args(true) + .resolve( + config::DaemonStartupConfig::default(), + PathBuf::from("config.yaml"), + flag.clone(), + ) + .unwrap(); + assert_eq!(resolved.socket, flag); + } + + #[test] + fn user_mode_without_the_flag_keeps_the_config_socket() { + let startup = config::DaemonStartupConfig { + socket: Some(PathBuf::from(r"\\.\pipe\agentdesktop-from-config")), + ..Default::default() + }; + let resolved = daemon_args(true) + .resolve( + startup, + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert_eq!( + resolved.socket, + PathBuf::from(r"\\.\pipe\agentdesktop-from-config") + ); + } + + #[tokio::test] + async fn the_system_daemon_still_refuses_a_socket_flag() { + // The refusal precedes config loading, so no config file is needed. + let error = super::run_until_shutdown( + daemon_args(false), + PathBuf::from(r"\\.\pipe\agentdesktop-user-alice"), + async { anyhow::Ok(()) }, + ) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "set daemon.socket in the configuration file; --socket is for client commands" + ); + } + + fn startup_with_containment() -> config::DaemonStartupConfig { + config::DaemonStartupConfig { + containment: Some(config::ContainmentStartupConfig { + executor: PathBuf::from(r"C:\mxc\x64\wxc-exec.exe"), + egress: config::ContainmentEgress::default(), + engine: config::ContainmentEngine::default(), + egress_upstream: None, + git: None, + }), + ..Default::default() + } + } + + #[test] + fn system_mode_rejects_containment() { + let error = resolve_error(daemon_args(false), startup_with_containment()); + assert!( + error + .to_string() + .contains("daemon.containment requires --user (or daemon.user: true)"), + "{error:#}" + ); + } + + #[test] + fn user_mode_resolves_containment_and_records_a_claude_settings_override() { + let resolved = daemon_args(true) + .resolve( + startup_with_containment(), + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert_eq!( + resolved + .containment + .as_ref() + .map(|cfg| cfg.executor.clone()), + Some(PathBuf::from(r"C:\mxc\x64\wxc-exec.exe")) + ); + assert!(!resolved.claude_settings_overridden); + + let mut startup = startup_with_containment(); + startup.claude_code.config = Some(PathBuf::from("/tmp/settings.json")); + let resolved = daemon_args(true) + .resolve( + startup, + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert!(resolved.claude_settings_overridden); + } + + #[test] + fn one_shot_runs_drop_containment() { + for (once, dry_run) in [(true, false), (true, true)] { + let args = DaemonArgs { + user: true, + once, + dry_run, + config: None, + hidden: false, + }; + let resolved = args + .resolve( + startup_with_containment(), + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert!( + resolved.containment.is_none(), + "once={once} dry_run={dry_run}" + ); + } + } + + #[test] + fn containment_egress_deny_is_resolved_from_config() { + let mut startup = startup_with_containment(); + startup.containment.as_mut().unwrap().egress = config::ContainmentEgress::Deny; + let resolved = daemon_args(true) + .resolve( + startup, + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert_eq!(resolved.containment_egress, config::ContainmentEgress::Deny); + } + + #[test] + fn containment_egress_defaults_to_deny_when_absent() { + // PR 4: default flipped from Allow to Deny + let resolved = daemon_args(true) + .resolve( + config::DaemonStartupConfig::default(), + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert_eq!(resolved.containment_egress, config::ContainmentEgress::Deny); + } + fn startup_with_llm_proxy(listen: &str) -> config::DaemonStartupConfig { let mut startup = config::DaemonStartupConfig::default(); startup.llm_proxy.listen = Some(listen.parse().unwrap()); @@ -2543,4 +3077,66 @@ programs: .is_none() ); } + + #[tokio::test] + #[cfg(windows)] + async fn duplicate_user_daemon_fails_at_pipe_bind_before_mutating_state() { + use std::sync::atomic::{AtomicBool, Ordering}; + + // The user-mode startup path binds the first pipe instance, then runs + // the sweep closure. A duplicate daemon's bind fails before the sweep, + // leaving launch directories untouched. This test pins the ordering by + // pre-binding with the production descriptor, calling bind_then_sweep + // with a flag-setting closure, and asserting both error and flag=false. + let pipe_name = format!("\\\\.\\pipe\\agentdesktop-test-{}", std::process::id()); + let pipe_path = std::path::Path::new(&pipe_name); + + let identity = launch::peer_identity(std::process::id()).expect("read test process SID"); + let user_sid = identity.user_sid.expect("test has a user SID"); + let sddl = launch::user_mode_pipe_sddl(&user_sid); + + // Pre-bind with the production user-mode descriptor. + let _first = bind_named_pipe(pipe_path, true, &sddl).expect("first instance binds"); + + // Attempt bind_then_sweep; the sweep should never run. + let sweep_ran = AtomicBool::new(false); + let duplicate = bind_then_sweep(pipe_path, &sddl, || { + sweep_ran.store(true, Ordering::Relaxed); + }); + + assert!(duplicate.is_err(), "second bind must fail"); + assert!( + !sweep_ran.load(Ordering::Relaxed), + "sweep must not run when bind fails" + ); + assert!( + duplicate + .unwrap_err() + .to_string() + .to_lowercase() + .contains("pipe"), + "error mentions pipe" + ); + } + + #[tokio::test] + #[cfg(windows)] + async fn second_pipe_instance_succeeds_with_first_false() { + // Control for the duplicate test: the first_pipe_instance=true flag is + // what rejects duplicates, not the SDDL access rights alone. A second + // bind with first=false succeeds. + let pipe_name = format!( + "\\\\.\\pipe\\agentdesktop-test-control-{}", + std::process::id() + ); + let pipe_path = std::path::Path::new(&pipe_name); + + let identity = launch::peer_identity(std::process::id()).expect("read test process SID"); + let user_sid = identity.user_sid.expect("test has a user SID"); + let sddl = launch::user_mode_pipe_sddl(&user_sid); + + let _first = bind_named_pipe(pipe_path, true, &sddl).expect("first instance binds"); + let _second = bind_named_pipe(pipe_path, false, &sddl) + .expect("second instance with first=false succeeds"); + } } diff --git a/crates/agent/src/launch/console.rs b/crates/agent/src/launch/console.rs new file mode 100644 index 00000000..52547e82 --- /dev/null +++ b/crates/agent/src/launch/console.rs @@ -0,0 +1,438 @@ +//! Same-terminal console hosting (Windows). +//! +//! `run --wait` started from a console asks the daemon to run the boxed +//! program on that console. The daemon validates the requesting pid (same +//! user, same session) and starts this crate's hidden `console-attach` +//! subcommand, which attaches to the client's console and spawns the +//! executor with the console's handles inherited. The boxed program then +//! runs in the caller's terminal and `cmd /c` (no `start`) returns the +//! program's own exit code. + +/// The `console-attach` helper's exit code when its own setup fails (attach +/// refused, job or spawn failure) before the executor runs. The daemon does +/// not key on this value — a program may legitimately exit with it — it +/// distinguishes a real setup failure from the program's exit by the +/// `ATTACH_STARTED_MARKER`, written only once the executor has been spawned. +pub(crate) const ATTACH_FAILED_EXIT: i32 = 125; + +/// Marker file the helper writes into the launch directory once the executor +/// is spawned and in the job. Its presence means a subsequent exit is the +/// program's own result, not an attach-setup failure. +pub(crate) const ATTACH_STARTED_MARKER: &str = "attach-started"; + +#[cfg(windows)] +pub(crate) use imp::*; + +#[cfg(not(windows))] +pub(crate) fn client_console_available() -> bool { + false +} + +#[cfg(not(windows))] +pub(crate) fn ignore_ctrl_c() {} + +#[cfg(not(windows))] +pub(crate) fn run_attach( + _client_pid: u32, + _executor: &std::path::Path, + _request: &std::path::Path, + _log: &std::path::Path, +) -> anyhow::Result { + anyhow::bail!("same-terminal hosting is only supported on Windows") +} + +#[cfg(windows)] +mod imp { + use std::{ + ffi::c_void, + io::{self, Write}, + path::Path, + process::{Child, Command, Stdio}, + }; + + use anyhow::{Context, bail}; + use windows_sys::Win32::{ + Foundation::{CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE}, + Security::{ + GetSidSubAuthority, GetSidSubAuthorityCount, GetTokenInformation, SECURITY_ATTRIBUTES, + TOKEN_MANDATORY_LABEL, TOKEN_QUERY, TokenIntegrityLevel, + }, + Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }, + System::{ + Console::{ + AttachConsole, CTRL_C_EVENT, FreeConsole, GetConsoleMode, GetStdHandle, + STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleCtrlHandler, + }, + JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }, + RemoteDesktop::ProcessIdToSessionId, + Threading::{ + CREATE_SUSPENDED, CreateProcessW, DETACHED_PROCESS, GetExitCodeProcess, INFINITE, + OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOW, TerminateProcess, WaitForSingleObject, + }, + }, + }; + + use super::super::host::peer_identity; + + /// `TRUE` for Ctrl+C, `FALSE` for everything else (close, logoff, break). + unsafe extern "system" fn ignore_ctrl_c_handler(event: u32) -> windows_sys::core::BOOL { + if event == CTRL_C_EVENT { 1 } else { 0 } + } + + /// Whether the calling process owns a console on stdin and stdout + /// (Windows Terminal / ConPTY included): only then does `run --wait` + /// ask for same-terminal hosting. + pub(crate) fn client_console_available() -> bool { + // SAFETY: plain console queries on the process's own handles. + unsafe { + let mut mode = 0u32; + let input = GetStdHandle(STD_INPUT_HANDLE); + let output = GetStdHandle(STD_OUTPUT_HANDLE); + !input.is_null() + && input != INVALID_HANDLE_VALUE + && GetConsoleMode(input, &mut mode) != 0 + && !output.is_null() + && output != INVALID_HANDLE_VALUE + && GetConsoleMode(output, &mut mode) != 0 + } + } + + /// Ignore Ctrl+C in this process so the attached box receives it and the + /// waiter (client or helper) stays alive. + pub(crate) fn ignore_ctrl_c() { + // SAFETY: the handler is 'static and returns per the documented contract. + unsafe { + SetConsoleCtrlHandler(Some(ignore_ctrl_c_handler), 1); + } + } + + fn session_of(pid: u32) -> Option { + let mut session = 0u32; + // SAFETY: valid out-parameter. + let ok = unsafe { ProcessIdToSessionId(pid, &mut session) }; + (ok != 0 && session != 0).then_some(session) + } + + /// The integrity-level RID of process `pid`'s token (e.g. 0x2000 medium, + /// 0x3000 high). `AttachConsole` from the medium-integrity daemon is + /// refused against a higher-integrity client (an elevated terminal), so + /// the daemon compares levels and downgrades such a client to windowed + /// rather than failing the launch after the helper's attach is denied + /// (lab AC-1: the first run's terminals were elevated). + fn integrity_level(pid: u32) -> anyhow::Result { + // SAFETY: plain system calls; every handle is checked and closed. + unsafe { + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if process.is_null() { + return Err(io::Error::last_os_error()) + .with_context(|| format!("open process {pid}")); + } + let mut token: HANDLE = std::ptr::null_mut(); + let opened = OpenProcessToken(process, TOKEN_QUERY, &mut token); + CloseHandle(process); + if opened == 0 { + return Err(io::Error::last_os_error()) + .with_context(|| format!("open process {pid}'s token")); + } + let mut size = 0u32; + GetTokenInformation( + token, + TokenIntegrityLevel, + std::ptr::null_mut(), + 0, + &mut size, + ); + if size == 0 { + CloseHandle(token); + bail!("integrity-level size query failed for process {pid}"); + } + let mut buffer = vec![0u8; size as usize]; + let ok = GetTokenInformation( + token, + TokenIntegrityLevel, + buffer.as_mut_ptr().cast::(), + size, + &mut size, + ); + CloseHandle(token); + if ok == 0 { + return Err(io::Error::last_os_error()) + .with_context(|| format!("read process {pid}'s integrity level")); + } + let label = &*buffer.as_ptr().cast::(); + let sid = label.Label.Sid; + let count = *GetSidSubAuthorityCount(sid); + if count == 0 { + bail!("integrity SID of process {pid} has no sub-authority"); + } + Ok(*GetSidSubAuthority(sid, (count - 1) as u32)) + } + } + + /// Whether `client_pid` may host the box's console: alive, in the daemon's + /// session, of the daemon's own user, and no higher integrity than the + /// daemon (an elevated terminal is refused). Any failure is downgraded to + /// a windowed launch by the caller. + pub(crate) fn validate_client(client_pid: u32) -> anyhow::Result<()> { + if client_pid == std::process::id() { + bail!("the daemon cannot attach to its own console"); + } + let ours = + session_of(std::process::id()).context("the daemon's session could not be read")?; + let theirs = session_of(client_pid) + .with_context(|| format!("process {client_pid} is not running in a session"))?; + if ours != theirs { + bail!("process {client_pid} runs in another session"); + } + let daemon = peer_identity(std::process::id()).context("read the daemon's identity")?; + let client = peer_identity(client_pid) + .with_context(|| format!("read process {client_pid}'s identity"))?; + if daemon.user_sid.is_none() || daemon.user_sid != client.user_sid { + bail!("process {client_pid} runs as another user"); + } + let our_integrity = + integrity_level(std::process::id()).context("read the daemon's integrity level")?; + let client_integrity = integrity_level(client_pid)?; + if client_integrity > our_integrity { + bail!( + "process {client_pid} runs at a higher integrity level \ + (0x{client_integrity:x} > 0x{our_integrity:x}); AttachConsole would be refused" + ); + } + Ok(()) + } + + fn wide(value: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + value.as_os_str().encode_wide().chain(Some(0)).collect() + } + + fn append_log(log: &Path, line: &str) { + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + { + let _ = writeln!(file, "console-attach: {line}"); + } + } + + /// The hidden `console-attach` subcommand: attach to `client_pid`'s + /// console, run the executor there, and return the executor's exit code + /// (which `cmd /c` makes the boxed program's own code). + pub(crate) fn run_attach( + client_pid: u32, + executor: &Path, + request: &Path, + log: &Path, + ) -> anyhow::Result { + ignore_ctrl_c(); + // AttachConsole refuses (ERROR_ACCESS_DENIED) while the caller holds + // any console, including the windowless one CREATE_NO_WINDOW allocates + // (lab AC-2, first run). The helper is spawned console-free + // (DETACHED_PROCESS), and this guards any other way of starting it. + // SAFETY: plain system call; failure (no console held) is fine. + unsafe { FreeConsole() }; + // SAFETY: plain system call. + if unsafe { AttachConsole(client_pid) } == 0 { + let error = io::Error::last_os_error(); + append_log( + log, + &format!("attach to process {client_pid} failed: {error}"), + ); + return Err(error).with_context(|| format!("attach to process {client_pid}'s console")); + } + // FreeConsole and AttachConsole reset the process's console-control + // handler table, so re-install the ignore-Ctrl+C handler now that we + // hold the client's console; otherwise a CTRL_C_EVENT would reach the + // default handler and the kill-on-close job would take the box down + // instead of letting the program handle the interrupt (Copilot review). + ignore_ctrl_c(); + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: 1, + }; + // SAFETY: the name is a static UTF-16 literal; the attributes are valid + // for the call's duration. + let (input, output) = unsafe { + ( + CreateFileW( + wide(Path::new("CONIN$")).as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &attributes, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ), + CreateFileW( + wide(Path::new("CONOUT$")).as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &attributes, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ), + ) + }; + if input == INVALID_HANDLE_VALUE || output == INVALID_HANDLE_VALUE { + let error = io::Error::last_os_error(); + append_log(log, &format!("open the attached console: {error}")); + return Err(error).context("open the attached console"); + } + // SAFETY: no attributes and no name. + let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if job.is_null() { + let error = io::Error::last_os_error(); + append_log(log, &format!("create the launch job: {error}")); + return Err(error).context("create the launch job"); + } + // Killing or losing the helper kills the executor and the box with it. + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: the info struct is valid for the documented size. + let configured = unsafe { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast::(), + std::mem::size_of::() as u32, + ) + }; + if configured == 0 { + // Fail closed: without kill-on-close the job cannot clean up the box + // (Copilot review: a silently unconfigured job disables cleanup). + let error = io::Error::last_os_error(); + append_log(log, &format!("configure the launch job: {error}")); + // SAFETY: valid handles owned here. + unsafe { + CloseHandle(job); + CloseHandle(input); + CloseHandle(output); + } + return Err(error).context("configure the launch job"); + } + let mut command_line: Vec = + format!("\"{}\" \"{}\"", executor.display(), request.display()) + .encode_utf16() + .chain(Some(0)) + .collect(); + let application = wide(executor); + let mut startup: STARTUPINFOW = unsafe { std::mem::zeroed() }; + startup.cb = std::mem::size_of::() as u32; + startup.dwFlags = STARTF_USESTDHANDLES; + startup.hStdInput = input; + startup.hStdOutput = output; + startup.hStdError = output; + let mut process: windows_sys::Win32::System::Threading::PROCESS_INFORMATION = + unsafe { std::mem::zeroed() }; + // SAFETY: all pointers are valid for the call's duration and the + // handles are inheritable. + let started = unsafe { + CreateProcessW( + application.as_ptr(), + command_line.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + CREATE_SUSPENDED, + std::ptr::null(), + std::ptr::null(), + &startup, + &mut process, + ) + }; + if started == 0 { + let error = io::Error::last_os_error(); + append_log(log, &format!("start the executor: {error}")); + return Err(error).context("start the executor"); + } + // Assign to the job before resuming, so no descendant can be created in + // the window between spawn and assignment and survive the kill-on-close + // cleanup (Copilot review). The executor was created suspended. + // SAFETY: the process/thread handles are valid until closed below. + let assigned = unsafe { AssignProcessToJobObject(job, process.hProcess) }; + if assigned == 0 { + let error = io::Error::last_os_error(); + append_log( + log, + &format!("assign the executor to the launch job: {error}"), + ); + // SAFETY: valid handles owned here; terminate the still-suspended executor. + unsafe { + TerminateProcess(process.hProcess, 1); + CloseHandle(process.hProcess); + CloseHandle(process.hThread); + CloseHandle(input); + CloseHandle(output); + CloseHandle(job); + } + return Err(error).context("assign the executor to the launch job"); + } + // The executor is spawned and in the job: from here any exit is the + // program's own result, so mark the launch started before resuming. + // The daemon reads this marker instead of overloading an exit code + // (Copilot review: exit 125 is a valid program result too). + if let Some(dir) = log.parent() + && let Err(error) = std::fs::write(dir.join(super::ATTACH_STARTED_MARKER), []) + { + append_log(log, &format!("write the attach-started marker: {error}")); + } + // SAFETY: valid thread/process handles; resume now that the job is in place. + unsafe { + ResumeThread(process.hThread); + WaitForSingleObject(process.hProcess, INFINITE); + } + let mut code = 1u32; + // SAFETY: valid process handle and out-parameter. + unsafe { + GetExitCodeProcess(process.hProcess, &mut code); + CloseHandle(process.hProcess); + CloseHandle(process.hThread); + CloseHandle(input); + CloseHandle(output); + CloseHandle(job); + } + Ok(code as i32) + } + + /// Starts the hidden `console-attach` subcommand for a launch. The + /// daemon keeps a normal executor child (pid + start time) and kills it + /// through the helper; the helper's job kills the executor and the box. + pub(crate) fn spawn_helper( + client_pid: u32, + executor: &Path, + request: &Path, + log: &Path, + ) -> io::Result { + use std::os::windows::process::CommandExt; + let mut command = Command::new(std::env::current_exe()?); + command + .arg("console-attach") + .arg("--client-pid") + .arg(client_pid.to_string()) + .arg("--executor") + .arg(executor) + .arg("--request") + .arg(request) + .arg("--log") + .arg(log) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + // Console-free, not CREATE_NO_WINDOW: AttachConsole only works + // from a process without a console, and CREATE_NO_WINDOW would + // allocate a windowless one (lab AC-2, first run). + .creation_flags(DETACHED_PROCESS); + command.spawn() + } +} diff --git a/crates/agent/src/launch/engine/mod.rs b/crates/agent/src/launch/engine/mod.rs new file mode 100644 index 00000000..49bb66e5 --- /dev/null +++ b/crates/agent/src/launch/engine/mod.rs @@ -0,0 +1,19 @@ +//! Sandbox engine interface. + +use anyhow::Result; + +use super::plan::LaunchPlan; + +pub mod mxc; + +/// A sandbox engine that accepts a resolved launch plan and produces a request file. +pub(crate) trait Engine: Send + Sync { + /// The engine name as it appears in the config and posture. + fn name(&self) -> &'static str; + + /// The container id for a given launch id. + fn container_id(&self, launch_id: &str) -> String; + + /// Build the request bytes from a plan. + fn request(&self, plan: &LaunchPlan, launch_id: &str) -> Result>; +} diff --git a/crates/agent/src/launch/engine/mxc.rs b/crates/agent/src/launch/engine/mxc.rs new file mode 100644 index 00000000..585d34e7 --- /dev/null +++ b/crates/agent/src/launch/engine/mxc.rs @@ -0,0 +1,160 @@ +//! MXC engine: Windows AppContainer request generation. + +use anyhow::{Context, Result}; +use serde::Serialize; + +use super::Engine; +use crate::launch::plan::{ConsolePlan, LaunchPlan}; +use crate::launch::win; + +/// MXC sandbox engine. +pub(crate) struct MxcEngine; + +impl Engine for MxcEngine { + fn name(&self) -> &'static str { + "mxc" + } + + fn container_id(&self, launch_id: &str) -> String { + format!("agentdesktop-{}", launch_id) + } + + fn request(&self, plan: &LaunchPlan, launch_id: &str) -> Result> { + serde_json::to_vec_pretty(&self.to_request(plan, launch_id)).context("encode MXC request") + } +} + +impl MxcEngine { + /// Wraps a resolved plan in the MXC request envelope. Pure: the plan is + /// final, nothing is read from the host. + pub(crate) fn to_request(&self, plan: &LaunchPlan, launch_id: &str) -> MxcRequest { + let cwd = win(&plan.cwd); + let mut command_line = match plan.console { + ConsolePlan::Window => format!( + "cmd.exe /d /v:off /c start \"{}\" /WAIT /D \"{cwd}\" \"{}\"", + plan.window_title, + win(&plan.program) + ), + // Same-terminal hosting: no `start`, so the boxed program runs on + // the executor's console (the client's) and cmd returns the + // program's own exit code. + ConsolePlan::Attach { .. } => { + format!("cmd.exe /d /v:off /c \"\"{}\"", win(&plan.program)) + } + }; + for arg in &plan.args { + command_line.push(' '); + command_line.push_str(arg); + } + if matches!(plan.console, ConsolePlan::Attach { .. }) { + command_line.push('"'); + } + MxcRequest { + version: "1.0.0".into(), + containment: "processcontainer".into(), + container_id: self.container_id(launch_id), + ui: MxcUi { + disable: false, + clipboard: "none".into(), + injection: false, + }, + process: MxcProcess { + command_line, + cwd, + inherit_default_env: false, + env: plan + .env + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(), + }, + filesystem: MxcFilesystem { + readwrite_paths: plan.readwrite.iter().map(|path| win(path)).collect(), + readonly_paths: plan.readonly.iter().map(|path| win(path)).collect(), + denied_paths: plan.denied.iter().map(|path| win(path)).collect(), + }, + network: MxcNetwork { + egress: MxcEgress { + default: match plan.egress { + agentdesktop_core::config::ContainmentEgress::Allow => "allow".into(), + agentdesktop_core::config::ContainmentEgress::Deny => "deny".into(), + }, + }, + ingress: MxcIngress { + default: "allow".into(), + host_loopback: "allow".into(), + }, + }, + } + } +} + +// No Debug: the request's environment carries the session pairing. +#[derive(Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MxcRequest { + pub version: String, + pub containment: String, + pub container_id: String, + pub ui: MxcUi, + pub process: MxcProcess, + pub filesystem: MxcFilesystem, + pub network: MxcNetwork, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MxcUi { + pub disable: bool, + pub clipboard: String, + pub injection: bool, +} + +// No Debug: the env list carries the session pairing. +#[derive(Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MxcProcess { + pub command_line: String, + pub cwd: String, + pub inherit_default_env: bool, + /// `KEY=VALUE` entries: MXC 1.0.0 rejects an object here (lab-verified). + pub env: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct MxcFilesystem { + #[serde(rename = "readwritePaths")] + pub readwrite_paths: Vec, + #[serde(rename = "readonlyPaths")] + pub readonly_paths: Vec, + #[serde(rename = "deniedPaths")] + pub denied_paths: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct MxcNetwork { + pub egress: MxcEgress, + pub ingress: MxcIngress, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct MxcEgress { + pub default: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MxcIngress { + pub default: String, + pub host_loopback: String, +} + +/// Builds the MXC request from a launch input: the resolved plan through the +/// MXC envelope as one call. +#[cfg(test)] +pub(crate) fn build_request(input: &crate::launch::LaunchInput) -> MxcRequest { + MxcEngine.to_request( + &crate::launch::plan(input, &crate::launch::profile::CLAUDE_CODE), + &input.launch_id, + ) +} diff --git a/crates/agent/src/launch/git_ssh.rs b/crates/agent/src/launch/git_ssh.rs new file mode 100644 index 00000000..fa732cfa --- /dev/null +++ b/crates/agent/src/launch/git_ssh.rs @@ -0,0 +1,164 @@ +//! Git's ssh launcher inside the box: the agentdesktop executable started as +//! the `#!` interpreter of the per-launch `GIT_SSH` script. +//! +//! Git runs `GIT_SSH` without a shell but passes it no arguments of ours, and +//! Windows OpenSSH takes its client configuration only from `-F` or the +//! user's profile. Git for Windows starts a `GIT_SSH` script as +//! `