From 394cccbca79f6e9ebf33b1b23866294bccd8cd1b Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Tue, 20 Oct 2026 18:32:16 +0200 Subject: [PATCH 01/19] launch Claude Code in a Windows AppContainer from the user-mode daemon `agentdesktop run claude [-- args]` asks the user-mode daemon to start Claude Code inside a Windows AppContainer (Microsoft MXC base-container tier) with the current directory as its workspace. The box sees the workspace, the user's ~\.claude (secrets denied, executable configuration read-only against in-place writes), the user's git configuration read-only (with HOME set to the daemon's home, so git works in the box) and a private tmp; it gets a short-lived gateway credential in a file instead of a pipe or a sign-in, and the daemon refreshes that credential while the box runs. - New `daemon.containment.executor` startup setting (local only, user mode; schema regenerated). - `POST /v1/launch`, `GET /v1/launches` on the local API; `DaemonInfo.containment` with availability and reason; desktop panel row. - `agentdesktop run -- `; `agentdesktop credential` reads `AGENTDESKTOP_CREDENTIAL_FILE` inside the box. - Behaviour change (Windows, user mode): the daemon's pipe admits only its own user (SID) and SYSTEM, no longer every interactive user, and every accepted connection carries the caller's identity for the launch routes. System mode unchanged. - The daemon's standard handles are no longer inheritable, so they do not travel into the box. - Client crate: `post_json_response`, `StatusError`. - README "Sandboxed launch (Windows, preview)" with the threat model. Residual risks, named in the README: read-only grants do not stop replacement (a read-only file inside a writable folder can be replaced from the box via temp file + rename, reaching `~\.claude\settings.json`, the workspace `.claude` settings, `.mcp.json`, `.git\config` and the hook folders, which the next unboxed session reads); outbound network is open; files the agent creates in the workspace are not gated. Windows only; the MXC executor must be installed by the operator. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 + Cargo.toml | 1 + README.md | 127 + crates/agent/Cargo.toml | 4 + crates/agent/src/api.rs | 3 +- crates/agent/src/cli.rs | 244 +- crates/agent/src/daemon.rs | 302 +- crates/agent/src/launch/host.rs | 518 ++++ crates/agent/src/launch/mod.rs | 1277 ++++++++ .../src/launch/testdata/request-claude.json | 85 + crates/agent/src/launch/tests.rs | 2715 +++++++++++++++++ crates/agent/src/launch/writer_tests.rs | 593 ++++ crates/agent/src/lib.rs | 1 + crates/agent/src/llm_proxy.rs | 6 +- crates/agent/src/provider/README.md | 1 + crates/client/Cargo.toml | 3 + crates/client/src/lib.rs | 213 +- crates/core/src/config.rs | 180 ++ crates/core/src/model.rs | 138 +- .../src/components/DaemonInformation.tsx | 8 + frontend/desktop/src/stories/fixtures.ts | 9 + frontend/desktop/src/types.ts | 6 + .../desktop/src/views/StatusView.stories.tsx | 27 + schema/daemon-config.json | 25 + schema/daemon-config.md | 2 + 25 files changed, 6470 insertions(+), 20 deletions(-) create mode 100644 crates/agent/src/launch/host.rs create mode 100644 crates/agent/src/launch/mod.rs create mode 100644 crates/agent/src/launch/testdata/request-claude.json create mode 100644 crates/agent/src/launch/tests.rs create mode 100644 crates/agent/src/launch/writer_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 5ef7fa12..c2e521a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,7 @@ dependencies = [ "glob", "hostname", "http-body-util", + "humantime", "hyper", "hyper-rustls", "hyper-util", @@ -107,6 +108,7 @@ dependencies = [ "hyper-util", "serde", "serde_json", + "tempfile", "tokio", "tracing", ] 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..a0e75709 100644 --- a/README.md +++ b/README.md @@ -681,6 +681,133 @@ 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. 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 + containment: + executor: C:\Program Files\mxc\x64\wxc-exec.exe +``` + +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`). `daemon.claudeCode.config` must stay unset: the box reads +`~\.claude\settings.json`. 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, `~\.gitconfig` and `~\.config\git\config` when they exist (git reads them +before anything else; open egress means a git config can carry secrets, for +example `http..extraHeader` with a bearer PAT, or credentials in +`url.*.insteadOf` URLs; `[include]` and `[includeIf]` targets of the git configuration +are not granted, so git fails closed in the box when a config uses them), `~\.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). +Each file or folder in that list is denied when it does not exist, so the box +cannot create it in place, except for the git configuration files (granted when +present, not emitted otherwise); 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`. 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`, the daemon's state directory or a denied path (`%ProgramFiles(x86)%` +counts as a system directory). Outbound network is open in this preview; whether the box can reach +loopback services on the host, and whether it accepts inbound loopback +connections, follows the request's network block (`egress` open, host loopback +ingress denied) and is not yet verified in the lab; 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 and no sign-in: the daemon writes the +gateway credential to a private file named by `AGENTDESKTOP_CREDENTIAL_FILE`, +which `agentdesktop credential --client-id claude-code` reads inside the box. +That token is the box's single secret. Inside the box the agent can read a +valid gateway token and send it anywhere, because outbound traffic is open in +this preview; the local relay route removes the token from the box. The daemon +rewrites the file shortly before the credential expires (about 50 s before), +without ever opening a browser; if the gateway refuses the refresh the file +stays until the credential expires, and if the configuration no longer allows +the launch the file is removed at that next refresh. After logout or a policy +change a running box therefore keeps model access for at most the remaining +credential lifetime. Restarting the daemon ends model access for running +sandboxed sessions within the credential lifetime; start a new `run` with +`--resume`. The box's own `TEMP` lives under the launch directory. 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, workspace or argument, `403` when +another user calls a user-mode daemon on Windows, `409` for the configuration +rules above, `401`/`424`/`502` straight from the credential request, and `500` +when preparing the launch fails (creating `~\.claude`, the credential directory, +or writing the request), when the executor cannot be started, when recording the +launch pid fails, or when it exits within three seconds (its last log lines are +in the message). Launches are +listed by `GET /v1/launches` +(running ones plus the last 32 that exited). A daemon restart keeps running +boxes alive but stops refreshing their credential: at startup the daemon removes +the credential and 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'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, 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; managed `allowManaged*Only` settings arrive with the policy profile. + +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); this preview adds no +pipe discovery or Windows logon autostart. + ## 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..96fa497c 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,8 @@ windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + "Win32_System_Console", + "Win32_System_Pipes", "Win32_System_Registry", + "Win32_System_Threading", ] } diff --git a/crates/agent/src/api.rs b/crates/agent/src/api.rs index 662ceb34..7ba7d940 100644 --- a/crates/agent/src/api.rs +++ b/crates/agent/src/api.rs @@ -694,7 +694,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 +759,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + containment: None, }, discovery, enrollment: crate::enrollment::EnrollmentState::new(false), diff --git a/crates/agent/src/cli.rs b/crates/agent/src/cli.rs index a9cbc363..18a1b1db 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -1,13 +1,14 @@ -use std::{io::Read, path::PathBuf}; +use std::{ffi::OsStr, io::Read, path::PathBuf}; use agentdesktop_client as client; use agentdesktop_core::{ config::DaemonConfig, model::{ - Discovery, EnrollmentStatus, Health, LlmGatewayCredential, LlmGatewayLoginStatus, - ProgramChoice, ProgramManagement, TelemetryEventKind, + Discovery, EnrollmentStatus, Health, Launch, LaunchRequest, LlmGatewayCredential, + LlmGatewayLoginStatus, ProgramChoice, ProgramManagement, TelemetryEventKind, }, }; +use anyhow::Context; use clap::Subcommand; use serde::Deserialize; @@ -38,6 +39,15 @@ 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`). + program: String, + /// Arguments passed to the program. + #[arg(last = true)] + args: Vec, + }, /// Handle an event emitted by a managed developer-tool hook. Hook { #[command(subcommand)] @@ -150,6 +160,15 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()> ); } ClientCommand::Credential { client_id } => { + // Inside a sandboxed launch the daemon's pipe is out of reach; the + // launch directory carries the credential instead. + if let Some(credential) = file_credential( + &client_id, + std::env::var_os("AGENTDESKTOP_CREDENTIAL_FILE").as_deref(), + ) { + println!("{credential}"); + return Ok(()); + } let client_id: String = url::form_urlencoded::byte_serialize(client_id.as_bytes()).collect(); let response: LlmGatewayCredential = client::get( @@ -159,6 +178,31 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()> .await?; println!("{}", response.credential); } + ClientCommand::Run { program, args } => { + let cwd = std::env::current_dir().context("read the current directory")?; + let request = run_request(&program, cwd, args); + match client::post_json_response::<_, Launch>(&socket, "/v1/launch", &request).await { + Ok(launch) => { + let valid_until = launch + .credential_expires_at_unix_seconds + .map(|seconds| { + humantime::format_rfc3339_seconds( + std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds), + ) + .to_string() + }) + .unwrap_or_else(|| "unknown".to_owned()); + println!( + "launched {} (container {}, credential valid until {valid_until})", + launch.launch_id, launch.container_id + ); + } + Err(error) => { + eprintln!("{}", run_error_message(&error)); + std::process::exit(1); + } + } + } ClientCommand::Hook { hook: HookCommand::ClaudePreToolUse { include_input }, } => { @@ -229,6 +273,46 @@ fn print_programs(management: &ProgramManagement) { } } +/// The credential a sandboxed launch reads from `AGENTDESKTOP_CREDENTIAL_FILE` +/// (the value of that variable is `var`). `None` unless `client_id` is +/// `claude-code`, and `None` when the variable is unset or the file is +/// unreadable or empty after trimming. +pub fn file_credential(client_id: &str, var: Option<&OsStr>) -> Option { + if client_id != crate::provider::claude_code::ClaudeCode::ID { + return None; + } + let path = var.filter(|value| !value.is_empty())?; + let contents = std::fs::read_to_string(path).ok()?; + let credential = contents.trim(); + (!credential.is_empty()).then(|| credential.to_owned()) +} + +/// The launch request for `agentdesktop run `; the alias `claude` +/// maps to the provider id `claude-code`. +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, + } +} + +/// 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:#}"), + } +} + async fn report_claude_pre_tool_use( socket: &std::path::Path, include_input: bool, @@ -311,9 +395,16 @@ fn claude_client_id_for_entrypoint(entrypoint: Option<&str>) -> &'static str { #[cfg(test)] mod tests { - use agentdesktop_core::model::{ProgramChoice, TelemetryEventKind}; + use std::{ffi::OsStr, 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, file_credential, parse_claude_pre_tool_use, parse_program_choice, + run_error_message, run_request, + }; fn args(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() @@ -338,6 +429,149 @@ 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()], + } + ); + assert_eq!( + run_request("claude-code", cwd.clone(), vec![]), + LaunchRequest { + program: "claude-code".into(), + cwd, + args: vec![], + } + ); + } + + #[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")); + } + + fn credential_file(contents: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + std::fs::write(&path, contents).unwrap(); + (dir, path) + } + + #[test] + fn file_credential_reads_and_trims_the_file_named_by_the_variable() { + let (_dir, path) = credential_file("gateway-token\r\n"); + assert_eq!( + file_credential("claude-code", Some(path.as_os_str())), + Some("gateway-token".to_owned()) + ); + } + + #[test] + fn file_credential_is_none_for_an_empty_file() { + let (_dir, path) = credential_file(" \n"); + assert_eq!(file_credential("claude-code", Some(path.as_os_str())), None); + } + + #[test] + fn file_credential_is_none_when_the_variable_is_unset_or_the_file_is_unreadable() { + assert_eq!(file_credential("claude-code", None), None); + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing"); + assert_eq!( + file_credential("claude-code", Some(missing.as_os_str())), + None + ); + assert_eq!(file_credential("claude-code", Some(OsStr::new(""))), None); + } + + #[test] + fn file_credential_is_only_for_claude_code() { + let (_dir, path) = credential_file("gateway-token"); + for client_id in ["codex", "vscode", "claude-desktop", "agentdesktop", ""] { + assert_eq!( + file_credential(client_id, Some(path.as_os_str())), + None, + "{client_id}" + ); + } + } + #[test] fn parses_claude_pre_tool_use_input_without_unrelated_fields() { let event = parse_claude_pre_tool_use( diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index bbb8a35c..dc37ab96 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)] @@ -97,6 +97,10 @@ 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, } struct ResolvedToolConfigPath { @@ -154,6 +158,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 +252,8 @@ impl DaemonArgs { reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, + containment, + claude_settings_overridden, }); } @@ -334,6 +350,8 @@ impl DaemonArgs { reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, + containment, + claude_settings_overridden, }) } } @@ -492,6 +510,51 @@ 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 + }; + let containment = launch::containment_info( + args.containment.as_ref(), + args.user, + cfg!(windows), + args.claude_settings_overridden, + &|path| path.exists(), + ); let (usage_store, usage_receiver) = match &args.usage { Some(usage) => { let (store, receiver) = start_usage(usage, &args.state_dir).await; @@ -501,6 +564,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 @@ -640,7 +704,23 @@ where usage: usage_store, programs: Some(program_control), }; - let app = api::router(state.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()? + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(), + )), + registry: launch::LaunchRegistry::new(), + availability: containment, + daemon_sid: daemon_sid.clone(), + }; + let app = api::router(state.clone()).merge(launch::router(launch_state)); // 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. @@ -668,7 +748,7 @@ 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); + let local_api = serve_named_pipe(&socket, app, shutdown, daemon_sid, first_pipe_server); tokio::select! { result = local_api => result?, result = proxy => result?, @@ -847,6 +927,7 @@ fn describe_daemon( controller, llm_proxy, usage_receiver: None, + containment: None, } } @@ -1220,8 +1301,21 @@ 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). + const SYSTEM_PIPE_SDDL: &str = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"; + let sddl = match &peer_check { + Some(sid) => launch::user_mode_pipe_sddl(sid), + None => 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 +1325,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 +1347,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 +1401,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 +1543,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}; @@ -2280,6 +2425,79 @@ programs: } } + fn startup_with_containment() -> config::DaemonStartupConfig { + config::DaemonStartupConfig { + containment: Some(config::ContainmentStartupConfig { + executor: PathBuf::from(r"C:\mxc\x64\wxc-exec.exe"), + }), + ..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, + }; + 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}" + ); + } + } + 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 +2761,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/host.rs b/crates/agent/src/launch/host.rs new file mode 100644 index 00000000..f28ec12c --- /dev/null +++ b/crates/agent/src/launch/host.rs @@ -0,0 +1,518 @@ +//! Production implementations of the launch traits: the daemon's own +//! environment and filesystem, `wxc-exec.exe` as a child process, the gateway +//! credential through the local API's own path, and the Windows identity calls. + +use std::{ + collections::BTreeMap, + io, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use agentdesktop_core::{ + config::{DaemonConfig, LlmGatewayAuthentication}, + model::LlmGatewayCredential, +}; +use async_trait::async_trait; +use axum::http::StatusCode; + +use super::{ + CredentialSource, CwdContext, CwdFs, Executor, LaunchChild, LaunchHost, PathKind, ProgramFs, + preconditions, +}; +use crate::api; + +/// Environment variables copied from the daemon into the box. +const BASE_ENV: [&str; 13] = [ + "SystemRoot", + "windir", + "SystemDrive", + "ComSpec", + "PATHEXT", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "ProgramFiles", + "ProgramData", + "USERNAME", +]; + +/// Directories a workspace may never be under, by environment variable. +const SYSTEM_DIRS: [&str; 4] = [ + "SystemRoot", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramData", +]; + +/// Directories under the home the box must never see; the workspace may not +/// be under them either. +const DENIED_HOME_DIRS: [&str; 4] = [".ssh", ".aws", ".azure", ".kube"]; + +/// The daemon's user, as the process environment describes it. +pub struct HostEnvironment { + home: PathBuf, + state_dir: PathBuf, + client_exe_dir: PathBuf, +} + +impl HostEnvironment { + pub fn new(home: PathBuf, state_dir: PathBuf, client_exe_dir: PathBuf) -> Self { + Self { + home, + state_dir, + client_exe_dir, + } + } +} + +struct RealFs; + +impl CwdFs for RealFs { + fn canonicalize(&self, path: &Path) -> io::Result { + std::fs::canonicalize(path) + } + + fn kind(&self, path: &Path) -> io::Result { + Ok(PathKind { + is_dir: std::fs::metadata(path)?.is_dir(), + }) + } +} + +impl ProgramFs for RealFs { + fn canonicalize(&self, path: &Path) -> io::Result { + std::fs::canonicalize(path) + } + + fn is_file(&self, path: &Path) -> bool { + path.is_file() + } +} + +static REAL_FS: RealFs = RealFs; + +impl LaunchHost for HostEnvironment { + fn home(&self) -> PathBuf { + self.home.clone() + } + + fn path_dirs(&self) -> Vec { + std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default() + } + + fn base_env(&self) -> BTreeMap { + BASE_ENV + .iter() + .filter_map(|name| { + std::env::var(name) + .ok() + .map(|value| ((*name).to_owned(), value)) + }) + .collect() + } + + fn cwd_context(&self) -> CwdContext { + CwdContext { + home: self.home.clone(), + state_dir: self.state_dir.clone(), + system_dirs: SYSTEM_DIRS + .iter() + .filter_map(|name| std::env::var_os(name).map(PathBuf::from)) + .collect(), + denied: DENIED_HOME_DIRS + .iter() + .map(|name| self.home.join(name)) + .collect(), + app_data: ["APPDATA", "LOCALAPPDATA"] + .iter() + .filter_map(|name| std::env::var_os(name).map(PathBuf::from)) + .collect(), + } + } + + fn cwd_fs(&self) -> &dyn CwdFs { + &REAL_FS + } + + fn program_fs(&self) -> &dyn ProgramFs { + &REAL_FS + } + + fn exists(&self, path: &Path) -> bool { + path.exists() + } + + fn is_dir(&self, path: &Path) -> bool { + std::fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false) + } + + /// The Git root above the first `git.exe` on the daemon's PATH, when that + /// directory is one of Git for Windows' launcher directories. + fn git_dir(&self) -> Option { + self.path_dirs().into_iter().find_map(|dir| { + if !dir.join("git.exe").is_file() { + return None; + } + let name = dir.file_name()?.to_str()?.to_ascii_lowercase(); + let parent = dir.parent()?; + match name.as_str() { + "bin" + if parent + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("mingw64")) => + { + parent.parent().map(Path::to_path_buf) + } + "cmd" | "bin" => Some(parent.to_path_buf()), + _ => None, + } + }) + } + + fn program_files_claude(&self) -> Option { + let dir = PathBuf::from(std::env::var_os("ProgramFiles")?).join("ClaudeCode"); + dir.is_dir().then_some(dir) + } + + fn client_exe_dir(&self) -> PathBuf { + self.client_exe_dir.clone() + } + + fn now(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default() + } +} + +/// Runs `wxc-exec.exe ` with its output appended to the launch's +/// log file; no console window of its own. +pub struct ProcessExecutor; + +struct ProcessChild { + child: Child, + pid: u32, + start_time: u64, +} + +impl LaunchChild for ProcessChild { + fn try_wait(&mut self) -> io::Result> { + Ok(self + .child + .try_wait()? + .map(|status| status.code().unwrap_or(-1))) + } + + fn pid(&self) -> u32 { + self.pid + } + + fn start_time(&self) -> u64 { + self.start_time + } +} + +impl Executor for ProcessExecutor { + fn spawn( + &self, + _launch_id: &str, + executor: &Path, + request: &Path, + log: &Path, + ) -> io::Result> { + // The daemon opens the log file itself (it inherits the launch + // directory's private DACL); the executor only inherits the handle, so + // a daemon stop later never breaks the box's output. + let stdout = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log)?; + let stderr = stdout.try_clone()?; + let mut command = Command::new(executor); + command + .arg(request) + .stdin(Stdio::null()) + .stdout(stdout) + .stderr(stderr); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + // The child gets its own stdio; the daemon's own standard handles + // (a log redirect under a scheduled task) must not travel with it + // into the box (lab F4: the box kept the daemon log open). + windows::stop_inheriting_standard_handles(); + } + let mut child = command.spawn()?; + let pid = child.id(); + // Without its creation time the sweep could not tell this box from a + // reused pid later; better not to start it at all. + let start_time = match process_start_time(&child) { + Ok(start_time) => start_time, + Err(error) => { + let _ = child.kill(); + return Err(io::Error::other(format!( + "read process start time: {error:#}" + ))); + } + }; + Ok(Box::new(ProcessChild { + child, + pid, + start_time, + })) + } +} + +#[cfg(windows)] +fn process_start_time(child: &Child) -> anyhow::Result { + use std::os::windows::io::AsRawHandle; + windows::creation_time(child.as_raw_handle() as _) +} + +#[cfg(not(windows))] +fn process_start_time(_child: &Child) -> anyhow::Result { + Ok(0) +} + +/// The gateway credential for a launch: the local API's own path, with the +/// subscription never included and no browser sign-in for a refresh. +pub struct GatewayCredentialSource { + pub app: api::AppState, +} + +#[async_trait] +impl CredentialSource for GatewayCredentialSource { + async fn credential( + &self, + client_id: &str, + interactive: bool, + ) -> Result { + let effective = + api::load_effective_config(&self.app.config, &self.app.state_dir).map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("read applied configuration: {error:#}"), + ) + })?; + preconditions(&effective).map_err(|message| (StatusCode::CONFLICT, message))?; + let effective = if interactive { + effective + } else { + non_interactive(effective) + }; + // `include_subscription` stays false whatever the configuration says: + // no subscription token enters the box. + let app = self.app.clone(); + let client_id = client_id.to_owned(); + tokio::spawn( + async move { api::gateway_credential(&app, &effective, &client_id, false).await }, + ) + .await + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("join gateway credential task: {error:#}"), + ) + })? + } +} + +/// 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. +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 +} + +/// Whether the process `pid` with creation time `start_time` is still alive: +/// the pid alone is not an identity, Windows reuses it. +#[cfg(windows)] +pub fn process_alive(pid: u32, start_time: u64) -> bool { + windows::process_alive(pid, start_time) +} + +#[cfg(windows)] +pub use windows::peer_identity; + +#[cfg(windows)] +pub(crate) mod windows { + use std::ffi::c_void; + + use anyhow::{Context, bail}; + use windows_sys::Win32::{ + Foundation::{ + CloseHandle, FILETIME, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, LocalFree, + STILL_ACTIVE, SetHandleInformation, + }, + Security::{ + Authorization::ConvertSidToStringSidW, GetTokenInformation, TOKEN_QUERY, TOKEN_USER, + TokenIsAppContainer, TokenUser, + }, + System::Console::{GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE}, + System::Threading::{ + GetExitCodeProcess, GetProcessTimes, OpenProcess, OpenProcessToken, + PROCESS_QUERY_LIMITED_INFORMATION, + }, + }; + + use super::super::PipePeer; + + /// Clears the inherit flag on the daemon's standard handles, so a child + /// started with explicit stdio cannot receive them. + pub(super) fn stop_inheriting_standard_handles() { + for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { + // SAFETY: plain system calls; a missing handle is skipped. + unsafe { + let handle = GetStdHandle(which); + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + continue; + } + if SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) == 0 { + tracing::debug!("SetHandleInformation failed for handle {which:?}"); + } + } + } + } + + /// A handle closed on drop. + struct Handle(HANDLE); + + impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: the handle was returned by an Open* call and is closed once. + unsafe { + CloseHandle(self.0); + } + } + } + + fn open_process(pid: u32) -> anyhow::Result { + // SAFETY: plain system call with valid arguments. + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("open process {pid}")); + } + Ok(Handle(handle)) + } + + fn filetime(time: FILETIME) -> u64 { + (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime) + } + + /// The creation FILETIME of an open process handle. + pub(crate) fn creation_time(handle: HANDLE) -> anyhow::Result { + let empty = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let (mut creation, mut exit, mut kernel, mut user) = (empty, empty, empty, empty); + // SAFETY: the out-parameters are valid for writes for the call's duration. + let ok = + unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) }; + if ok == 0 { + return Err(std::io::Error::last_os_error()).context("GetProcessTimes"); + } + Ok(filetime(creation)) + } + + pub(super) fn process_alive(pid: u32, start_time: u64) -> bool { + let Ok(process) = open_process(pid) else { + return false; + }; + if creation_time(process.0).ok() != Some(start_time) { + return false; + } + let mut code = 0u32; + // SAFETY: valid handle and out-parameter. + let ok = unsafe { GetExitCodeProcess(process.0, &mut code) }; + ok != 0 && code == STILL_ACTIVE as u32 + } + + /// Reads the identity of process `pid` (`TokenUser`, `TokenIsAppContainer`). + pub fn peer_identity(pid: u32) -> anyhow::Result { + let process = open_process(pid)?; + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: valid process handle and out-parameter. + if unsafe { OpenProcessToken(process.0, TOKEN_QUERY, &mut token) } == 0 { + return Err(std::io::Error::last_os_error()).context("open process token"); + } + let token = Handle(token); + + let mut length = 0u32; + // SAFETY: a size query; the null buffer is allowed with length 0. + unsafe { + GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut length); + } + if length == 0 { + bail!("TokenUser size query failed"); + } + let mut buffer = vec![0u8; length as usize]; + // SAFETY: the buffer is `length` bytes, as the size query asked for. + let ok = unsafe { + GetTokenInformation( + token.0, + TokenUser, + buffer.as_mut_ptr().cast::(), + length, + &mut length, + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()).context("read token user"); + } + // SAFETY: GetTokenInformation(TokenUser) fills a TOKEN_USER at the buffer start. + let sid = unsafe { (*buffer.as_ptr().cast::()).User.Sid }; + let mut text: *mut u16 = std::ptr::null_mut(); + // SAFETY: `sid` points into `buffer`, which outlives the call. + if unsafe { ConvertSidToStringSidW(sid, &mut text) } == 0 { + return Err(std::io::Error::last_os_error()).context("convert SID to string"); + } + // SAFETY: a null-terminated UTF-16 string allocated by the system. + let user_sid = unsafe { + let mut len = 0usize; + while *text.add(len) != 0 { + len += 1; + } + let sid = String::from_utf16_lossy(std::slice::from_raw_parts(text, len)); + LocalFree(text.cast::()); + sid + }; + + let mut app_container = 0u32; + let mut returned = 0u32; + // SAFETY: a u32 out-parameter of the documented size. + let ok = unsafe { + GetTokenInformation( + token.0, + TokenIsAppContainer, + (&mut app_container as *mut u32).cast::(), + std::mem::size_of::() as u32, + &mut returned, + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()).context("read token AppContainer flag"); + } + Ok(PipePeer { + user_sid: Some(user_sid), + app_container: app_container != 0, + }) + } +} diff --git a/crates/agent/src/launch/mod.rs b/crates/agent/src/launch/mod.rs new file mode 100644 index 00000000..67888fff --- /dev/null +++ b/crates/agent/src/launch/mod.rs @@ -0,0 +1,1277 @@ +//! Sandboxed launch of a managed program in a Windows AppContainer (MXC). +//! +//! `POST /v1/launch` writes an MXC request and a short-lived gateway credential +//! into a private launch directory, starts `wxc-exec.exe` on it and keeps the +//! credential fresh while the box runs. Everything that touches the host +//! (environment, filesystem, process creation, credentials) goes through the +//! traits below, so the behaviour is testable on every platform; the +//! production implementations live in [`host`]. + +use std::{ + collections::BTreeMap, + io, + path::{Component, Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use agentdesktop_core::{ + config::{ContainmentStartupConfig, DaemonConfig}, + model::{ + ContainmentInfo, DaemonScope, Launch, LaunchRequest, LaunchStatus, LlmGatewayCredential, + }, +}; +use anyhow::Context; +use async_trait::async_trait; +use axum::{ + Json, Router, + extract::{Request, State}, + http::StatusCode, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde::Serialize; + +use crate::{api, secure_fs}; + +pub mod host; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod writer_tests; + +/// Provider id of the only program the launcher can start. +const PROGRAM: &str = crate::provider::claude_code::ClaudeCode::ID; +/// Exited launches kept in the registry after the running ones. +const KEPT_EXITED: usize = 32; +/// How long the handler watches a fresh box for an early exit. +const EARLY_EXIT_WATCH: Duration = Duration::from_secs(3); +/// Poll cadence for the exit of a running launch. +const POLL_INTERVAL: Duration = Duration::from_secs(1); +/// How long one credential refresh may take. +const REFRESH_TIMEOUT: Duration = Duration::from_secs(30); +/// Bytes of `wxc-exec.log` returned with an early-exit error. +const LOG_TAIL: usize = 4096; + +/// Startup snapshot for `daemon-info`. `None` when `daemon.containment` is +/// unconfigured; otherwise `available` only with +/// `user && windows && exists(executor) && !claude_settings_overridden`. +pub fn containment_info( + cfg: Option<&ContainmentStartupConfig>, + user: bool, + windows: bool, + claude_settings_overridden: bool, + exists: &dyn Fn(&Path) -> bool, +) -> Option { + let cfg = cfg?; + let executor = cfg.executor.display().to_string(); + let error = if !user { + Some("requires user mode".to_owned()) + } else if !windows { + Some("Windows only".to_owned()) + } else if !exists(&cfg.executor) { + Some(format!("executor not found: {executor}")) + } else if claude_settings_overridden { + Some( + "daemon.claudeCode.config must be unset for run (the box reads \\.claude\\settings.json)" + .to_owned(), + ) + } else { + None + }; + Some(ContainmentInfo { + executor, + available: error.is_none(), + error, + }) +} + +/// Identity of the process at the other end of a pipe connection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PipePeer { + /// SID string of the peer's token user; `None` when it could not be read. + pub user_sid: Option, + /// Whether the peer runs in an AppContainer. + pub app_container: bool, +} + +/// `user_sid == daemon_sid` and `!app_container`; a missing peer or an +/// unreadable SID is never allowed. +pub fn peer_allowed(peer: Option<&PipePeer>, daemon_sid: Option<&str>) -> bool { + match (peer, daemon_sid) { + (Some(peer), Some(daemon_sid)) => { + !peer.app_container && peer.user_sid.as_deref() == Some(daemon_sid) + } + _ => false, + } +} + +/// Reads the identity of process `pid` (`TokenUser`, `TokenIsAppContainer`). +#[cfg(windows)] +pub use host::peer_identity; + +/// `D:P(A;;GA;;;SY)(A;;GA;;;)`: the user-mode pipe admits SYSTEM and the +/// daemon's own user only. +pub fn user_mode_pipe_sddl(sid: &str) -> String { + format!("D:P(A;;GA;;;SY)(A;;GA;;;{sid})") +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PathKind { + pub is_dir: bool, +} + +pub trait CwdFs { + fn canonicalize(&self, p: &Path) -> io::Result; + fn kind(&self, p: &Path) -> io::Result; +} + +/// Raw (uncanonicalised) directories the workspace must not be, or be under. +#[derive(Clone, Debug)] +pub struct CwdContext { + pub home: PathBuf, + pub state_dir: PathBuf, + pub system_dirs: Vec, + pub denied: Vec, + pub app_data: Vec, +} + +/// Characters `cmd.exe` interprets inside a quoted path. +const PATH_FORBIDDEN: [char; 3] = ['"', '%', '!']; + +/// Removes the `\\?\` (and `\\?\UNC\`) prefix `canonicalize` adds on Windows. +fn strip_verbatim(path: PathBuf) -> PathBuf { + let Some(text) = path.to_str() else { + return path; + }; + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{rest}")) + } else if let Some(rest) = text.strip_prefix(r"\\?\") { + PathBuf::from(rest) + } else { + path + } +} + +/// The form two paths are compared in: as they are on Unix, lower-cased on +/// Windows (NTFS is case-insensitive by default). +fn comparable(path: &Path) -> PathBuf { + if cfg!(windows) { + PathBuf::from(path.to_string_lossy().to_lowercase()) + } else { + path.to_owned() + } +} + +fn is_under(path: &Path, directory: &Path) -> bool { + comparable(path).starts_with(comparable(directory)) +} + +/// A drive, volume or UNC share root: no normal component at all. +fn is_root(path: &Path) -> bool { + !path + .components() + .any(|component| matches!(component, Component::Normal(_))) +} + +/// Canonicalises `cwd` and applies the workspace rules; returns the canonical +/// path (`\\?\` stripped) or the reason for the rejection. +pub fn validate_cwd(cwd: &Path, ctx: &CwdContext, fs: &dyn CwdFs) -> Result { + let text = cwd.to_string_lossy(); + if text.contains(PATH_FORBIDDEN) { + return Err("workspace path contains a character cmd.exe interprets".to_owned()); + } + let canonical = fs + .canonicalize(cwd) + .map(strip_verbatim) + .map_err(|error| format!("workspace {}: {error}", cwd.display()))?; + match fs.kind(&canonical) { + Ok(PathKind { is_dir: true }) => {} + Ok(PathKind { is_dir: false }) => { + return Err(format!("workspace {} is not a directory", cwd.display())); + } + Err(error) => return Err(format!("workspace {}: {error}", cwd.display())), + } + if canonical.to_string_lossy().contains(PATH_FORBIDDEN) { + return Err("workspace path contains a character cmd.exe interprets".to_owned()); + } + if is_root(&canonical) { + return Err("workspace may not be a drive or share root".to_owned()); + } + // Context directories are compared in their canonical form too, so a + // junction or symlink to one of them is caught; a directory that does not + // exist (such as `%ProgramFiles(x86)%` on ARM) still guards by its raw path. + let canonical_dir = |raw: &Path| { + fs.canonicalize(raw) + .map(strip_verbatim) + .unwrap_or_else(|_| raw.to_owned()) + }; + let home = canonical_dir(&ctx.home); + if is_under(&home, &canonical) { + return Err("workspace may not be the home directory or one of its ancestors".to_owned()); + } + let mut guarded: Vec<(PathBuf, &str)> = vec![ + (canonical_dir(&ctx.state_dir), "the daemon state directory"), + (canonical_dir(&home.join("AppData")), "AppData"), + ( + canonical_dir(&home.join(".claude")), + "the Claude Code directory", + ), + ]; + guarded.extend( + ctx.system_dirs + .iter() + .map(|dir| (canonical_dir(dir), "a system directory")), + ); + guarded.extend( + ctx.denied + .iter() + .map(|dir| (canonical_dir(dir), "a denied directory")), + ); + guarded.extend( + ctx.app_data + .iter() + .map(|dir| (canonical_dir(dir), "AppData")), + ); + for (directory, what) in guarded { + if is_under(&canonical, &directory) { + return Err(format!( + "workspace may not be under {what} ({})", + directory.display() + )); + } + if is_under(&directory, &canonical) { + return Err(format!( + "workspace may not contain {what} ({})", + directory.display() + )); + } + } + Ok(canonical) +} + +pub trait ProgramFs { + fn canonicalize(&self, p: &Path) -> io::Result; + fn is_file(&self, p: &Path) -> bool; +} + +const PROGRAM_NOT_FOUND: &str = "Claude Code native install (claude.exe) not found for this user; npm installs are not supported in the box"; + +/// Finds the native `claude.exe` for the daemon's user: `\.local\bin` +/// first, then each daemon `PATH` directory. +pub fn resolve_program( + home: &Path, + path_dirs: &[PathBuf], + fs: &dyn ProgramFs, +) -> Result { + // An empty entry (a trailing `;` in PATH) would make every path "under" it. + let path_dirs: Vec<&Path> = path_dirs + .iter() + .map(PathBuf::as_path) + .filter(|dir| !dir.as_os_str().is_empty()) + .collect(); + let mut candidates = vec![home.join(".local").join("bin").join("claude.exe")]; + candidates.extend(path_dirs.iter().map(|dir| dir.join("claude.exe"))); + // A candidate counts only when it really lives under the user's home or a + // PATH directory: both sides canonical, so a junction elsewhere is caught. + let mut roots: Vec = Vec::new(); + for root in std::iter::once(home).chain(path_dirs.iter().copied()) { + roots.push(root.to_owned()); + if let Ok(canonical) = fs.canonicalize(root) { + roots.push(strip_verbatim(canonical)); + } + } + for candidate in candidates { + if !fs.is_file(&candidate) { + continue; + } + let Ok(canonical) = fs.canonicalize(&candidate).map(strip_verbatim) else { + continue; + }; + if roots.iter().any(|root| is_under(&canonical, root)) { + return Ok(canonical); + } + } + Err(PROGRAM_NOT_FOUND.to_owned()) +} + +/// Characters `cmd.exe` interprets even inside double quotes (`%`, `!`) or that +/// end or split the command (`" ^ & | < >`). +const ARG_FORBIDDEN: [char; 8] = ['"', '%', '^', '&', '|', '<', '>', '!']; + +/// Rejects arguments `cmd.exe` would interpret and quotes the others. +pub fn check_args(args: &[String]) -> Result, String> { + args.iter() + .map(|arg| { + if arg.contains(ARG_FORBIDDEN) + || arg + .chars() + .any(|character| character.is_control() && character != '\t') + { + return Err("argument contains a character cmd.exe interprets".to_owned()); + } + if arg.is_empty() { + return Ok("\"\"".to_owned()); + } + if !arg.contains([' ', '\t']) { + return Ok(arg.clone()); + } + // Inside quotes a trailing backslash would escape the closing + // quote; the Windows argument parser wants each one doubled. + let trailing = arg.len() - arg.trim_end_matches('\\').len(); + Ok(format!( + "\"{}{}\"", + &arg[..arg.len() - trailing], + "\\".repeat(trailing * 2) + )) + }) + .collect() +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub 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 struct MxcUi { + pub disable: bool, + pub clipboard: String, + pub injection: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub 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 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 struct MxcNetwork { + pub egress: MxcEgress, + pub ingress: MxcIngress, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct MxcEgress { + pub default: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MxcIngress { + pub default: String, + pub host_loopback: String, +} + +/// Everything `build_request` may use. Paths are Windows-style strings in a +/// `PathBuf`; `exists` is called with `\`-joined paths. +pub struct LaunchInput<'a> { + pub launch_id: String, + pub executable: PathBuf, + pub client_exe_dir: PathBuf, + pub cwd: PathBuf, + /// Already quoted by `check_args`. + pub args: Vec, + pub home: PathBuf, + pub state_dir: PathBuf, + pub launch_dir: PathBuf, + pub git_dir: Option, + pub program_files_claude: Option, + pub gateway_url: String, + pub base_env: BTreeMap, + pub exists: &'a dyn Fn(&Path) -> bool, + pub is_dir: &'a dyn Fn(&Path) -> bool, +} + +/// A path as the request carries it: `\` separators only. +fn win(path: &Path) -> String { + path.to_string_lossy().replace('/', "\\") +} + +fn join(base: &str, rest: &str) -> String { + format!("{}\\{rest}", base.trim_end_matches('\\')) +} + +/// Builds the MXC request. Pure: no host environment or filesystem reads +/// beyond `input.exists` and `input.is_dir`. +pub fn build_request(input: &LaunchInput) -> MxcRequest { + let exists = |path: &str| (input.exists)(Path::new(path)); + let is_dir = |path: &str| (input.is_dir)(Path::new(path)); + let cwd = win(&input.cwd); + let home = win(&input.home); + let launch_dir = win(&input.launch_dir); + let executable = win(&input.executable); + let client_exe_dir = win(&input.client_exe_dir); + let exe_dir = executable + .rsplit_once('\\') + .map(|(dir, _)| dir.to_owned()) + .unwrap_or_default(); + let git_dir = input.git_dir.as_deref().map(win); + let program_files_claude = input.program_files_claude.as_deref().map(win); + let claude_dir = join(&home, ".claude"); + let tmp = join(&launch_dir, "tmp"); + let credential_dir = join(&launch_dir, "credential"); + + let system_root = input + .base_env + .get("SystemRoot") + .cloned() + .unwrap_or_else(|| r"C:\Windows".to_owned()); + let mut path = vec![ + join(&system_root, "System32"), + system_root.clone(), + join(&system_root, r"System32\WindowsPowerShell\v1.0"), + ]; + if let Some(git_dir) = &git_dir { + path.push(join(git_dir, "cmd")); + } + path.push(exe_dir.clone()); + path.push(client_exe_dir.clone()); + + let mut env = input.base_env.clone(); + env.insert("TERM".into(), "xterm-256color".into()); + env.insert("PATH".into(), path.join(";")); + env.insert("TEMP".into(), tmp.clone()); + env.insert("TMP".into(), tmp.clone()); + env.insert("CLAUDE_CONFIG_DIR".into(), claude_dir.clone()); + env.insert("CLAUDE_CODE_USE_POWERSHELL_TOOL".into(), "1".into()); + env.insert("DISABLE_AUTOUPDATER".into(), "1".into()); + env.insert( + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".into(), + "1".into(), + ); + env.insert("ANTHROPIC_BASE_URL".into(), input.gateway_url.clone()); + env.insert( + "AGENTDESKTOP_CREDENTIAL_FILE".into(), + join(&credential_dir, "token"), + ); + env.insert("HOME".into(), home.clone()); + + let mut command_line = format!( + "cmd.exe /d /v:off /c start \"agentdesktop: claude\" /WAIT /D \"{cwd}\" \"{executable}\"" + ); + for arg in &input.args { + command_line.push(' '); + command_line.push_str(arg); + } + + let mut readonly: Vec = Vec::new(); + let mut denied: Vec = Vec::new(); + // Runtime directories: granted read-only when they exist, simply absent + // otherwise (nothing the box could create there matters). + let runtime = [ + Some(exe_dir), + Some(join(&home, r".local\share\claude")), + git_dir, + Some(client_exe_dir), + program_files_claude, + Some(credential_dir), + // git reads the user's configuration before anything else; unreadable + // means every git command fails in the box (lab L4e). + Some(join(&home, ".gitconfig")), + Some(join(&home, r".config\git\config")), + ]; + for candidate in runtime.into_iter().flatten() { + if exists(&candidate) && !readonly.contains(&candidate) { + readonly.push(candidate); + } + } + // Executable configuration: read-only when present, denied when absent, so + // the box can neither change nor create it. + let mut executable_config = vec![ + join(&claude_dir, "settings.json"), + join(&claude_dir, "hooks"), + join(&claude_dir, "plugins"), + join(&cwd, ".mcp.json"), + // A `HEAD` file at the workspace root would let git treat the + // workspace itself as a bare repository with box-written config. + join(&cwd, "HEAD"), + ]; + // MXC refuses a denied path whose parent folder does not exist + // (lab-verified, 0x80070003), so a workspace without `.claude` gets the + // folder itself denied instead of its three executable-config entries. + let workspace_claude = join(&cwd, ".claude"); + let workspace_claude_entries = [ + join(&workspace_claude, "settings.json"), + join(&workspace_claude, "settings.local.json"), + join(&workspace_claude, "hooks"), + ]; + if exists(&workspace_claude) || workspace_claude_entries.iter().any(|path| exists(path)) { + executable_config.extend(workspace_claude_entries); + } else { + denied.push(workspace_claude); + } + let git = join(&cwd, ".git"); + if !exists(&git) { + // No repository: deny `.git` so the box cannot `git init` one that + // the user's tools later trust. + denied.push(git); + } else if is_dir(&git) { + // A `.git` directory: emit config, hooks and commondir guards whether + // or not they exist. An empty directory gets the same guards as a + // populated repository (prevents `git init` into an existing empty + // folder). `.git\HEAD` stays writable: git replaces it by rename on + // every branch switch, and the root `HEAD` rule already blocks a + // bare-repo fall-through. + executable_config.push(join(&git, "config")); + executable_config.push(join(&git, "hooks")); + executable_config.push(join(&git, "commondir")); + } + // A `.git` file (worktree, submodule) is left as it is. + for path in executable_config { + if exists(&path) { + readonly.push(path); + } else { + denied.push(path); + } + } + for secret in [ + join(&claude_dir, ".credentials.json"), + join(&claude_dir, "history.jsonl"), + // IDE lock files carry auth tokens for the editor bridge. + join(&claude_dir, "ide"), + join(&home, ".ssh"), + join(&home, ".aws"), + join(&home, ".azure"), + join(&home, ".kube"), + ] { + denied.push(secret); + } + + MxcRequest { + version: "1.0.0".into(), + containment: "processcontainer".into(), + container_id: format!("agentdesktop-{}", input.launch_id), + ui: MxcUi { + disable: false, + clipboard: "none".into(), + injection: false, + }, + process: MxcProcess { + command_line, + cwd: cwd.clone(), + inherit_default_env: false, + env: env + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(), + }, + filesystem: MxcFilesystem { + readwrite_paths: vec![cwd, claude_dir, tmp], + readonly_paths: readonly, + denied_paths: denied, + }, + network: MxcNetwork { + egress: MxcEgress { + default: "allow".into(), + }, + ingress: MxcIngress { + default: "allow".into(), + host_loopback: "deny".into(), + }, + }, + } +} + +/// Creates `launch_dir` (private) and writes `request.json` (UTF-8, no BOM, +/// mode 0600) into it; returns the file's path. +pub fn write_request(launch_dir: &Path, request: &MxcRequest) -> anyhow::Result { + secure_fs::ensure_private_dir(launch_dir)?; + let path = launch_dir.join("request.json"); + let contents = serde_json::to_vec_pretty(request).context("encode MXC request")?; + secure_fs::atomic_write(&path, &contents, 0o600)?; + Ok(path) +} + +/// When the credential refresh next runs: `max(expires_at - 50, now + 10)`. +pub fn refresh_at(expires_at: u64, now: u64) -> u64 { + expires_at.saturating_sub(50).max(now + 10) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RefreshInput { + /// A credential with this (new) `expires_at`. + Ok(u64), + Status(StatusCode), + Timeout, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RefreshAction { + Keep, + Rewrite, + Delete, +} + +pub fn next_action(input: &RefreshInput, expires_at: u64, now: u64) -> RefreshAction { + match input { + RefreshInput::Ok(new_expiry) if *new_expiry > expires_at => RefreshAction::Rewrite, + RefreshInput::Ok(_) => RefreshAction::Keep, + // A policy or precondition failure: the box must lose the token now. + RefreshInput::Status(StatusCode::CONFLICT | StatusCode::FAILED_DEPENDENCY) => { + RefreshAction::Delete + } + // A transient failure: the still-valid token stays until it expires. + RefreshInput::Status(_) | RefreshInput::Timeout => { + if now > expires_at { + RefreshAction::Delete + } else { + RefreshAction::Keep + } + } + } +} + +#[async_trait] +pub trait CredentialSource: Send + Sync { + async fn credential( + &self, + client_id: &str, + interactive: bool, + ) -> Result; +} + +/// The effective-configuration rules a launch needs; each failure names the +/// field. +pub(crate) fn preconditions(effective: &DaemonConfig) -> Result<(), String> { + let Some(gateway) = effective.llm_gateway.as_ref() else { + return Err("llmGateway (with authentication) must be configured for run".to_owned()); + }; + if gateway.authentication.is_none() { + return Err("llmGateway.authentication must be configured for run".to_owned()); + } + let Some(claude_code) = effective.programs.claude_code.as_ref() else { + return Err("programs.claudeCode must be configured for run".to_owned()); + }; + if !claude_code.use_llm_gateway { + return Err("programs.claudeCode.useLlmGateway must be true for run".to_owned()); + } + if api::program_uses_subscription(effective, PROGRAM) { + return Err( + "programs.claudeCode.auth: subscription sign-in is not available inside the box in PR 1" + .to_owned(), + ); + } + if effective.sandbox.is_some() { + return Err( + "Claude Code's own sandbox is not available inside the box; remove `sandbox` or launch without `run`" + .to_owned(), + ); + } + Ok(()) +} + +pub trait LaunchChild: Send { + fn try_wait(&mut self) -> io::Result>; + fn pid(&self) -> u32; + /// Creation time of the process (FILETIME on Windows). + fn start_time(&self) -> u64; +} + +pub trait Executor: Send + Sync { + fn spawn( + &self, + launch_id: &str, + executor: &Path, + request: &Path, + log: &Path, + ) -> io::Result>; +} + +pub trait LaunchHost: Send + Sync { + fn home(&self) -> PathBuf; + fn path_dirs(&self) -> Vec; + fn base_env(&self) -> BTreeMap; + fn cwd_context(&self) -> CwdContext; + fn cwd_fs(&self) -> &dyn CwdFs; + fn program_fs(&self) -> &dyn ProgramFs; + fn exists(&self, path: &Path) -> bool; + fn is_dir(&self, path: &Path) -> bool; + fn git_dir(&self) -> Option; + fn program_files_claude(&self) -> Option; + fn client_exe_dir(&self) -> PathBuf; + fn now(&self) -> u64; +} + +/// Running launches plus the last 32 exited ones. Clones share state. +#[derive(Clone)] +pub struct LaunchRegistry { + launches: Arc>>, +} + +impl LaunchRegistry { + pub fn new() -> Self { + Self { + launches: Arc::new(Mutex::new(Vec::new())), + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { + self.launches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn insert(&self, launch: Launch) { + self.lock().push(launch); + } + + pub fn mark_exited(&self, launch_id: &str, exit_code: Option, ended_at: u64) { + let mut launches = self.lock(); + let Some(launch) = launches + .iter_mut() + .find(|launch| launch.launch_id == launch_id) + else { + return; + }; + launch.status = LaunchStatus::Exited; + launch.exit_code = exit_code; + launch.ended_at_unix_seconds = Some(ended_at); + // Keep every running launch and the most recently ended exits. + let mut exited: Vec = launches + .iter() + .enumerate() + .filter(|(_, launch)| launch.status == LaunchStatus::Exited) + .map(|(index, _)| index) + .collect(); + if exited.len() > KEPT_EXITED { + exited.sort_by_key(|&index| { + ( + launches[index].ended_at_unix_seconds, + launches[index].started_at_unix_seconds, + ) + }); + let mut drop: Vec = exited[..exited.len() - KEPT_EXITED].to_vec(); + drop.sort_unstable_by(|a, b| b.cmp(a)); + for index in drop { + launches.remove(index); + } + } + } + + pub fn set_credential_expiry(&self, launch_id: &str, expires_at: u64) { + if let Some(launch) = self + .lock() + .iter_mut() + .find(|launch| launch.launch_id == launch_id) + { + launch.credential_expires_at_unix_seconds = Some(expires_at); + } + } + + /// Ordered by `started_at_unix_seconds` ascending. + pub fn list(&self) -> Vec { + let mut launches = self.lock().clone(); + launches.sort_by_key(|launch| launch.started_at_unix_seconds); + launches + } +} + +impl Default for LaunchRegistry { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +pub struct LaunchState { + pub app: api::AppState, + pub executor: Arc, + pub credentials: Arc, + pub host: Arc, + pub registry: LaunchRegistry, + pub availability: Option, + /// The daemon's own SID; `Some` only for a Windows user-mode daemon. + pub daemon_sid: Option, +} + +/// `POST /v1/launch` and `GET /v1/launches`, behind the peer check. +pub fn router(state: LaunchState) -> Router { + Router::new() + .route("/v1/launch", post(launch)) + .route("/v1/launches", get(launches)) + .layer(middleware::from_fn_with_state(state.clone(), guard)) + .with_state(state) +} + +/// System mode has no user to launch for; in Windows user mode only the +/// daemon's own user outside an AppContainer may use the launch routes. +async fn guard(State(state): State, request: Request, next: Next) -> Response { + if state.app.daemon_info.scope == DaemonScope::System { + return ( + StatusCode::CONFLICT, + "containment needs the user-mode daemon (`agentdesktop daemon --user`); this is the system daemon", + ) + .into_response(); + } + if let Some(daemon_sid) = state.daemon_sid.as_deref() + && !peer_allowed(request.extensions().get::(), Some(daemon_sid)) + { + return ( + StatusCode::FORBIDDEN, + "launch is available to the daemon's own user only", + ) + .into_response(); + } + next.run(request).await +} + +async fn launches(State(state): State) -> Json> { + Json(state.registry.list()) +} + +type LaunchError = (StatusCode, String); + +fn bad_request(message: String) -> LaunchError { + (StatusCode::BAD_REQUEST, message) +} + +async fn launch( + State(state): State, + Json(request): Json, +) -> Result<(StatusCode, Json), LaunchError> { + let executor = match state.availability.as_ref() { + Some(info) if info.available => PathBuf::from(&info.executor), + Some(info) => { + return Err(( + StatusCode::CONFLICT, + info.error + .clone() + .unwrap_or_else(|| "containment is unavailable".to_owned()), + )); + } + None => { + return Err(( + StatusCode::CONFLICT, + "containment is not configured (daemon.containment.executor)".to_owned(), + )); + } + }; + if request.program != PROGRAM { + return Err(bad_request(format!("program must be {PROGRAM}"))); + } + let host = state.host.clone(); + let cwd = + validate_cwd(&request.cwd, &host.cwd_context(), host.cwd_fs()).map_err(bad_request)?; + let args = check_args(&request.args).map_err(bad_request)?; + let executable = + resolve_program(&host.home(), &host.path_dirs(), host.program_fs()).map_err(bad_request)?; + if win(&executable).contains(PATH_FORBIDDEN) { + return Err(bad_request( + "program path contains a character cmd.exe interprets".to_owned(), + )); + } + let effective = + api::load_effective_config(&state.app.config, &state.app.state_dir).map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("read applied configuration: {error:#}"), + ) + })?; + preconditions(&effective).map_err(|message| (StatusCode::CONFLICT, message))?; + let gateway_url = effective + .llm_gateway + .as_ref() + .map(|gateway| gateway.url.as_str().to_owned()) + .unwrap_or_default(); + let credential = state.credentials.credential(PROGRAM, true).await?; + + // From here on the work runs in its own task: a client that disconnects + // during the early-exit watch must not leave a running box unregistered, + // unpolled and with a token nobody refreshes or removes. + let started = tokio::spawn(start_launch( + state, + StartInput { + executor, + executable, + cwd, + args, + gateway_url, + credential, + }, + )); + match started.await { + Ok(result) => result, + Err(error) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("launch task failed: {error}"), + )), + } +} + +/// What `start_launch` needs beyond the state: the checked request. +struct StartInput { + executor: PathBuf, + executable: PathBuf, + cwd: PathBuf, + args: Vec, + gateway_url: String, + credential: LlmGatewayCredential, +} + +/// Creates the launch directory, starts the executor, watches it for an early +/// exit and registers the launch with its poller and refresh task. +async fn start_launch( + state: LaunchState, + input: StartInput, +) -> Result<(StatusCode, Json), LaunchError> { + let host = state.host.clone(); + let launch_id = format!("{:016x}", rand::random::()); + let state_dir = std::path::absolute(&state.app.state_dir).map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("resolve the daemon state directory: {error}"), + ) + })?; + let launch_dir = state_dir.join("launches").join(&launch_id); + let log_path = launch_dir.join("wxc-exec.log"); + let fail = |launch_dir: &Path, message: String| { + tracing::warn!(launch_id = %launch_id, %message, "sandboxed launch failed"); + let _ = std::fs::remove_dir_all(launch_dir); + Err((StatusCode::INTERNAL_SERVER_ERROR, message)) + }; + // `~\.claude` is granted read-write and its entries are denied or + // read-only: every one of those needs the folder to exist. + if let Err(error) = std::fs::create_dir_all(host.home().join(".claude")) { + return fail( + &launch_dir, + format!("prepare launch: create ~\\.claude: {error}"), + ); + } + // The credential directory exists before the request is built: a + // read-only grant is only honoured for a path that exists. + if let Err(error) = create_launch_dir(&launch_dir, &input.credential.credential) { + return fail(&launch_dir, format!("prepare launch: {error:#}")); + } + let exists = |path: &Path| host.exists(path); + let is_dir = |path: &Path| host.is_dir(path); + let mxc = build_request(&LaunchInput { + launch_id: launch_id.clone(), + executable: input.executable, + client_exe_dir: host.client_exe_dir(), + cwd: input.cwd.clone(), + args: input.args, + home: host.home(), + state_dir, + launch_dir: launch_dir.clone(), + git_dir: host.git_dir(), + program_files_claude: host.program_files_claude(), + gateway_url: input.gateway_url, + base_env: host.base_env(), + exists: &exists, + is_dir: &is_dir, + }); + let request_path = match write_request(&launch_dir, &mxc) { + Ok(path) => path, + Err(error) => return fail(&launch_dir, format!("prepare launch: {error:#}")), + }; + let mut child = + match state + .executor + .spawn(&launch_id, &input.executor, &request_path, &log_path) + { + Ok(child) => child, + Err(error) => return fail(&launch_dir, format!("start wxc-exec: {error}")), + }; + let started_at = host.now(); + // The pid file is the sweep's only way to tell a live box from a stale + // directory; without it the launch is not started. + if let Err(error) = std::fs::write( + launch_dir.join("pid"), + format!("{} {}", child.pid(), child.start_time()), + ) { + return fail(&launch_dir, format!("record the launch pid: {error}")); + } + + // A box that dies within seconds (bad request, missing tier, MXC refusal) + // is reported to the caller instead of lingering as a running launch. + let deadline = tokio::time::Instant::now() + EARLY_EXIT_WATCH; + loop { + match child.try_wait() { + Ok(None) => {} + Ok(Some(code)) => { + let tail = log_tail(&log_path); + return fail( + &launch_dir, + format!( + "wxc-exec exited with code {code} within {}s: {tail}", + EARLY_EXIT_WATCH.as_secs() + ), + ); + } + Err(error) => return fail(&launch_dir, format!("watch wxc-exec: {error}")), + } + if tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + let launch = Launch { + launch_id: launch_id.clone(), + program: PROGRAM.to_owned(), + cwd: input.cwd, + container_id: format!("agentdesktop-{launch_id}"), + status: LaunchStatus::Running, + exit_code: None, + started_at_unix_seconds: started_at, + ended_at_unix_seconds: None, + credential_expires_at_unix_seconds: Some(input.credential.expires_at_unix_seconds), + }; + state.registry.insert(launch.clone()); + tracing::info!(launch_id, cwd = %launch.cwd.display(), "sandboxed launch started"); + let refresh = tokio::spawn(refresh_task( + state.credentials.clone(), + host.clone(), + state.registry.clone(), + launch_id.clone(), + launch_dir.join("credential").join("token"), + input.credential.expires_at_unix_seconds, + )); + tokio::spawn(poll_task( + child, + refresh.abort_handle(), + state.registry.clone(), + host, + launch_id, + launch_dir, + log_path, + )); + Ok((StatusCode::CREATED, Json(launch))) +} + +/// Creates the private launch directory with `credential\token` and the box's +/// `tmp`, before the request that grants them is built. +pub(crate) fn create_launch_dir(launch_dir: &Path, credential: &str) -> anyhow::Result<()> { + secure_fs::ensure_private_dir(launch_dir)?; + let credential_dir = launch_dir.join("credential"); + secure_fs::ensure_private_dir(&credential_dir)?; + secure_fs::atomic_write(&credential_dir.join("token"), credential.as_bytes(), 0o600)?; + std::fs::create_dir_all(launch_dir.join("tmp")).context("create launch tmp")?; + Ok(()) +} + +/// The last 4 KiB of the executor's log, for an early-exit error. +fn log_tail(log: &Path) -> String { + let bytes = std::fs::read(log).unwrap_or_default(); + let start = bytes.len().saturating_sub(LOG_TAIL); + String::from_utf8_lossy(&bytes[start..]).into_owned() +} + +/// Watches the executor: copies new log lines to the daemon log, and on exit +/// records it, stops the refresh and removes the launch directory. +async fn poll_task( + mut child: Box, + refresh: tokio::task::AbortHandle, + registry: LaunchRegistry, + host: Arc, + launch_id: String, + launch_dir: PathBuf, + log_path: PathBuf, +) { + let mut log_offset = 0u64; + loop { + tokio::time::sleep(POLL_INTERVAL).await; + log_offset = copy_log_lines(&launch_id, &log_path, log_offset); + let exit = match child.try_wait() { + Ok(None) => continue, + Ok(Some(code)) => Some(code), + Err(error) => { + tracing::warn!(launch_id, %error, "lost track of the launch; treating it as exited"); + None + } + }; + refresh.abort(); + registry.mark_exited(&launch_id, exit, host.now()); + if let Err(error) = std::fs::remove_dir_all(&launch_dir) { + tracing::warn!(launch_id, %error, "failed to remove the launch directory"); + } + tracing::info!(launch_id, exit_code = ?exit, "sandboxed launch exited"); + return; + } +} + +/// Logs the complete lines `wxc-exec.log` gained since `offset`; returns the +/// new offset. +fn copy_log_lines(launch_id: &str, log_path: &Path, offset: u64) -> u64 { + use std::io::{Read, Seek, SeekFrom}; + + let Ok(mut file) = std::fs::File::open(log_path) else { + return offset; + }; + if file.seek(SeekFrom::Start(offset)).is_err() { + return offset; + } + let mut bytes = Vec::new(); + if file.read_to_end(&mut bytes).is_err() { + return offset; + } + // Find the last newline in the raw bytes (not in the lossy string) so + // non-UTF-8 bytes don't shift the file offset. + let Some(complete) = bytes.iter().rposition(|&byte| byte == b'\n') else { + return offset; + }; + let text = String::from_utf8_lossy(&bytes[..complete]); + for line in text.lines() { + tracing::info!(launch_id, "wxc-exec: {line}"); + } + offset + complete as u64 + 1 +} + +/// Re-fetches the credential shortly before it expires and rewrites the token +/// file; a policy refusal removes the token, a transient failure keeps it +/// until it expires. +async fn refresh_task( + credentials: Arc, + host: Arc, + registry: LaunchRegistry, + launch_id: String, + token_path: PathBuf, + mut expires_at: u64, +) { + let mut next_at = refresh_at(expires_at, host.now()); + loop { + let now = host.now(); + if next_at > now { + tokio::time::sleep(Duration::from_secs(next_at - now)).await; + } + let now = host.now(); + let (input, fresh) = + match tokio::time::timeout(REFRESH_TIMEOUT, credentials.credential(PROGRAM, false)) + .await + { + Ok(Ok(credential)) => ( + RefreshInput::Ok(credential.expires_at_unix_seconds), + Some(credential), + ), + Ok(Err((status, body))) => { + tracing::warn!(launch_id, %status, body, "credential refresh refused"); + (RefreshInput::Status(status), None) + } + Err(_) => { + tracing::warn!(launch_id, "credential refresh timed out"); + (RefreshInput::Timeout, None) + } + }; + match next_action(&input, expires_at, now) { + RefreshAction::Rewrite => { + let credential = fresh.expect("a rewrite follows a fetched credential"); + match secure_fs::atomic_write(&token_path, credential.credential.as_bytes(), 0o600) + { + Ok(()) => { + expires_at = credential.expires_at_unix_seconds; + registry.set_credential_expiry(&launch_id, expires_at); + next_at = refresh_at(expires_at, host.now()); + } + Err(error) => { + tracing::warn!(launch_id, error = %format!("{error:#}"), "failed to rewrite the credential"); + next_at = host.now() + 30; + } + } + } + RefreshAction::Keep => { + next_at = host.now() + + if matches!(input, RefreshInput::Ok(_)) { + 10 + } else { + 30 + }; + } + RefreshAction::Delete => { + let _ = std::fs::remove_file(&token_path); + tracing::warn!( + launch_id, + "credential removed; the box can no longer reach the gateway" + ); + return; + } + } + } +} + +/// Removes what a previous daemon left behind in `\launches`: +/// `credential\` and `request.json` of every launch, and the whole launch +/// directory when `\pid` (`" "`) no longer names a +/// live process according to `alive(pid, start_time)`. +pub fn sweep(state_dir: &Path, alive: &dyn Fn(u32, u64) -> bool) -> anyhow::Result<()> { + let launches = state_dir.join("launches"); + let entries = match std::fs::read_dir(&launches) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(error).with_context(|| format!("read {}", launches.display())); + } + }; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + tracing::warn!(%error, "skipping an unreadable launch directory entry"); + continue; + } + }; + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let launch_id = entry.file_name().to_string_lossy().into_owned(); + let live = std::fs::read_to_string(dir.join("pid")) + .ok() + .and_then(|text| parse_pid_file(&text)) + .is_some_and(|(pid, start_time)| alive(pid, start_time)); + // Secrets go first either way, so a directory that cannot be removed + // as a whole never keeps a token behind. + for secret in [dir.join("credential"), dir.join("request.json")] { + let removed = if secret.is_dir() { + std::fs::remove_dir_all(&secret) + } else { + std::fs::remove_file(&secret) + }; + if let Err(error) = removed + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!(launch_id, %error, path = %secret.display(), "failed to remove a launch secret"); + } + } + if live { + tracing::info!( + launch_id, + "launch {launch_id} still running; its credential is no longer refreshed" + ); + } else if let Err(error) = std::fs::remove_dir_all(&dir) { + tracing::warn!(launch_id, %error, "failed to remove a stale launch directory"); + } + } + Ok(()) +} + +fn parse_pid_file(text: &str) -> Option<(u32, u64)> { + let mut parts = text.split_whitespace(); + let pid = parts.next()?.parse().ok()?; + let start_time = parts.next()?.parse().ok()?; + Some((pid, start_time)) +} diff --git a/crates/agent/src/launch/testdata/request-claude.json b/crates/agent/src/launch/testdata/request-claude.json new file mode 100644 index 00000000..323d5aa2 --- /dev/null +++ b/crates/agent/src/launch/testdata/request-claude.json @@ -0,0 +1,85 @@ +{ + "version": "1.0.0", + "containment": "processcontainer", + "containerId": "agentdesktop-0123456789abcdef", + "ui": { + "disable": false, + "clipboard": "none", + "injection": false + }, + "process": { + "commandLine": "cmd.exe /d /v:off /c start \"agentdesktop: claude\" /WAIT /D \"C:\\lab\\ws pr1\" \"C:\\Users\\dev\\.local\\bin\\claude.exe\" --debug-file \"C:\\lab\\ws pr1\\pr1-l2.log\" --allowedTools PowerShell", + "cwd": "C:\\lab\\ws pr1", + "inheritDefaultEnv": false, + "env": [ + "AGENTDESKTOP_CREDENTIAL_FILE=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\credential\\token", + "ANTHROPIC_BASE_URL=https://alpha-dev.aks.solo.sademo.de", + "APPDATA=C:\\Users\\dev\\AppData\\Roaming", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", + "CLAUDE_CODE_USE_POWERSHELL_TOOL=1", + "CLAUDE_CONFIG_DIR=C:\\Users\\dev\\.claude", + "ComSpec=C:\\Windows\\system32\\cmd.exe", + "DISABLE_AUTOUPDATER=1", + "HOME=C:\\Users\\dev", + "HOMEDRIVE=C:", + "HOMEPATH=\\Users\\dev", + "LOCALAPPDATA=C:\\Users\\dev\\AppData\\Local", + "PATH=C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\WindowsPowerShell\\v1.0;C:\\Program Files\\Git\\cmd;C:\\Users\\dev\\.local\\bin;C:\\lab\\agentdesktop", + "PATHEXT=.COM;.EXE;.BAT;.CMD", + "ProgramData=C:\\ProgramData", + "ProgramFiles=C:\\Program Files", + "SystemDrive=C:", + "SystemRoot=C:\\Windows", + "TEMP=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp", + "TERM=xterm-256color", + "TMP=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp", + "USERNAME=dev", + "USERPROFILE=C:\\Users\\dev", + "windir=C:\\Windows" + ] + }, + "filesystem": { + "readwritePaths": [ + "C:\\lab\\ws pr1", + "C:\\Users\\dev\\.claude", + "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp" + ], + "readonlyPaths": [ + "C:\\Users\\dev\\.local\\bin", + "C:\\Users\\dev\\.local\\share\\claude", + "C:\\Program Files\\Git", + "C:\\lab\\agentdesktop", + "C:\\Program Files\\ClaudeCode", + "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\credential", + "C:\\Users\\dev\\.claude\\settings.json", + "C:\\Users\\dev\\.claude\\plugins", + "C:\\lab\\ws pr1\\.claude\\settings.local.json", + "C:\\lab\\ws pr1\\.mcp.json", + "C:\\lab\\ws pr1\\.git\\config", + "C:\\lab\\ws pr1\\.git\\hooks" + ], + "deniedPaths": [ + "C:\\Users\\dev\\.claude\\hooks", + "C:\\lab\\ws pr1\\.claude\\settings.json", + "C:\\lab\\ws pr1\\.claude\\hooks", + "C:\\Users\\dev\\.claude\\.credentials.json", + "C:\\Users\\dev\\.claude\\history.jsonl", + "C:\\Users\\dev\\.ssh", + "C:\\Users\\dev\\.aws", + "C:\\Users\\dev\\.azure", + "C:\\Users\\dev\\.kube", + "C:\\lab\\ws pr1\\.git\\commondir", + "C:\\Users\\dev\\.claude\\ide", + "C:\\lab\\ws pr1\\HEAD" + ] + }, + "network": { + "egress": { + "default": "allow" + }, + "ingress": { + "default": "allow", + "hostLoopback": "deny" + } + } +} diff --git a/crates/agent/src/launch/tests.rs b/crates/agent/src/launch/tests.rs new file mode 100644 index 00000000..351f9293 --- /dev/null +++ b/crates/agent/src/launch/tests.rs @@ -0,0 +1,2715 @@ +//! Spec-derived tests for PR 1 (`specs/PR-1.md`). The implementation may not +//! weaken or delete them without a ledger entry. +//! +//! Tests that need Windows semantics are under `#[cfg(windows)]` and run only +//! in CI's Windows job. Everything else runs on both platforms: Windows-style +//! paths are plain strings in a `PathBuf` where the code under test joins +//! them by string (`build_request`), and real paths are built from +//! `tempfile::TempDir` roots or with the `p()` helper below. + +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet}, + io, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicU32, Ordering}, + }, + time::Duration, +}; + +use agentdesktop_core::{ + config::ContainmentStartupConfig, + model::{ContainmentInfo, Launch, LaunchRequest, LaunchStatus, LlmGatewayCredential}, +}; +use async_trait::async_trait; +use axum::http::StatusCode; + +use super::*; + +/// A platform-appropriate absolute root: `/` or `C:\`. +fn root() -> PathBuf { + if cfg!(windows) { + PathBuf::from(r"C:\") + } else { + PathBuf::from("/") + } +} + +/// `p("home/dev/work")` is `/home/dev/work` or `C:\home\dev\work`. +fn p(relative: &str) -> PathBuf { + relative + .split('/') + .filter(|part| !part.is_empty()) + .fold(root(), |path, part| path.join(part)) +} + +// ===================================================== containment_info (AC 2) + +fn executor_cfg(path: &str) -> ContainmentStartupConfig { + ContainmentStartupConfig { + executor: PathBuf::from(path), + } +} + +#[test] +fn containment_info_is_absent_when_unconfigured() { + assert_eq!(containment_info(None, true, true, false, &|_| true), None); +} + +#[test] +fn containment_info_is_available_for_a_user_mode_windows_daemon_with_the_executor() { + let cfg = executor_cfg(r"C:\mxc\x64\wxc-exec.exe"); + let info = containment_info(Some(&cfg), true, true, false, &|_| true).unwrap(); + assert_eq!( + info, + ContainmentInfo { + executor: r"C:\mxc\x64\wxc-exec.exe".into(), + available: true, + error: None, + } + ); +} + +#[test] +fn containment_info_needs_user_mode() { + let cfg = executor_cfg(r"C:\mxc\wxc-exec.exe"); + let info = containment_info(Some(&cfg), false, true, false, &|_| true).unwrap(); + assert!(!info.available); + assert_eq!(info.error.as_deref(), Some("requires user mode")); + assert_eq!(info.executor, r"C:\mxc\wxc-exec.exe"); +} + +#[test] +fn containment_info_is_windows_only() { + let cfg = executor_cfg("/opt/mxc/wxc-exec.exe"); + let info = containment_info(Some(&cfg), true, false, false, &|_| true).unwrap(); + assert!(!info.available); + assert_eq!(info.error.as_deref(), Some("Windows only")); +} + +#[test] +fn containment_info_names_a_missing_executor() { + let cfg = executor_cfg(r"C:\missing\wxc-exec.exe"); + let seen = RefCell::new(Vec::new()); + let info = containment_info(Some(&cfg), true, true, false, &|path| { + seen.borrow_mut().push(path.to_owned()); + false + }) + .unwrap(); + assert!(!info.available); + assert_eq!( + info.error.as_deref(), + Some(r"executor not found: C:\missing\wxc-exec.exe") + ); + assert_eq!( + &*seen.borrow(), + &[PathBuf::from(r"C:\missing\wxc-exec.exe")] + ); +} + +#[test] +fn containment_info_is_unavailable_when_the_claude_settings_path_is_overridden() { + let cfg = executor_cfg(r"C:\mxc\wxc-exec.exe"); + let info = containment_info(Some(&cfg), true, true, true, &|_| true).unwrap(); + assert!(!info.available); + let error = info.error.expect("a reason"); + assert!( + error.starts_with("daemon.claudeCode.config must be unset for run"), + "{error}" + ); +} + +// ============================================================ peer_allowed (AC 4) + +fn peer(sid: Option<&str>, app_container: bool) -> PipePeer { + PipePeer { + user_sid: sid.map(str::to_owned), + app_container, + } +} + +#[test] +fn peer_allowed_admits_the_daemons_own_user_outside_an_app_container() { + assert!(peer_allowed( + Some(&peer(Some("S-1-5-21-1-2-3-1001"), false)), + Some("S-1-5-21-1-2-3-1001") + )); +} + +#[test] +fn peer_allowed_refuses_another_user() { + assert!(!peer_allowed( + Some(&peer(Some("S-1-5-21-1-2-3-1002"), false)), + Some("S-1-5-21-1-2-3-1001") + )); +} + +#[test] +fn peer_allowed_refuses_an_app_container_of_the_same_user() { + assert!(!peer_allowed( + Some(&peer(Some("S-1-5-21-1-2-3-1001"), true)), + Some("S-1-5-21-1-2-3-1001") + )); +} + +#[test] +fn peer_allowed_refuses_a_missing_peer_and_an_unreadable_sid() { + assert!(!peer_allowed(None, Some("S-1-5-21-1-2-3-1001"))); + assert!(!peer_allowed( + Some(&peer(None, false)), + Some("S-1-5-21-1-2-3-1001") + )); +} + +// ================================================================ validate_cwd (AC 5) + +#[derive(Default)] +struct FakeFs { + /// Junctions and symlinks: raw path to canonical path. + mapping: BTreeMap, + /// Paths that do not exist. + missing: BTreeSet, + /// Canonical paths that are files. + files: BTreeSet, +} + +impl FakeFs { + fn resolve(&self, path: &Path) -> io::Result { + if self.missing.contains(path) { + return Err(io::ErrorKind::NotFound.into()); + } + let resolved = self + .mapping + .get(path) + .cloned() + .unwrap_or_else(|| path.to_owned()); + if self.missing.contains(&resolved) { + return Err(io::ErrorKind::NotFound.into()); + } + Ok(resolved) + } +} + +impl CwdFs for FakeFs { + fn canonicalize(&self, path: &Path) -> io::Result { + self.resolve(path) + } + + fn kind(&self, path: &Path) -> io::Result { + let resolved = self.resolve(path)?; + Ok(PathKind { + is_dir: !self.files.contains(&resolved), + }) + } +} + +/// The real filesystem, for the `..` and symlink tests and the router tests. +struct RealFs; + +impl CwdFs for RealFs { + fn canonicalize(&self, path: &Path) -> io::Result { + std::fs::canonicalize(path) + } + + fn kind(&self, path: &Path) -> io::Result { + Ok(PathKind { + is_dir: std::fs::metadata(path)?.is_dir(), + }) + } +} + +impl ProgramFs for RealFs { + fn canonicalize(&self, path: &Path) -> io::Result { + std::fs::canonicalize(path) + } + + fn is_file(&self, path: &Path) -> bool { + path.is_file() + } +} + +fn cwd_context() -> CwdContext { + let home = p("home/dev"); + CwdContext { + state_dir: p("var/state/agentdesktop"), + system_dirs: vec![p("windows"), p("programfiles"), p("programdata")], + denied: vec![home.join(".ssh"), home.join(".aws"), home.join(".kube")], + home, + app_data: vec![], + } +} + +fn accepted(cwd: &Path) -> PathBuf { + validate_cwd(cwd, &cwd_context(), &FakeFs::default()) + .unwrap_or_else(|error| panic!("{} should be accepted: {error}", cwd.display())) +} + +fn rejected(cwd: &Path) { + let result = validate_cwd(cwd, &cwd_context(), &FakeFs::default()); + assert!(result.is_err(), "{} should be rejected", cwd.display()); +} + +#[test] +fn cwd_in_a_project_under_home_is_accepted_and_returned_canonical() { + assert_eq!(accepted(&p("home/dev/work/app")), p("home/dev/work/app")); +} + +#[test] +fn cwd_may_be_a_sibling_whose_name_merely_starts_like_a_denied_directory() { + accepted(&p("home/dev/.claude-work/app")); + accepted(&p("home/dev/AppData2/app")); + accepted(&p("home/dev-other/app")); + accepted(&p("windows-lab/app")); +} + +#[test] +fn cwd_may_not_be_a_drive_root() { + rejected(&root()); +} + +#[test] +fn cwd_may_not_be_the_home_directory_or_an_ancestor_of_it() { + rejected(&p("home/dev")); + rejected(&p("home")); +} + +#[test] +fn cwd_may_not_be_under_a_system_directory() { + rejected(&p("windows")); + rejected(&p("windows/temp/work")); + rejected(&p("programfiles/tool")); + rejected(&p("programdata/vendor")); +} + +#[test] +fn cwd_may_not_be_under_the_state_directory() { + rejected(&p("var/state/agentdesktop")); + rejected(&p("var/state/agentdesktop/launches/0123456789abcdef")); +} + +#[test] +fn cwd_may_not_be_under_appdata_or_the_claude_directory() { + rejected(&p("home/dev/AppData")); + rejected(&p("home/dev/AppData/Local/Temp")); + rejected(&p("home/dev/.claude")); + rejected(&p("home/dev/.claude/skills")); +} + +#[test] +fn cwd_may_not_be_under_a_denied_path() { + rejected(&p("home/dev/.ssh")); + rejected(&p("home/dev/.aws/sso")); + rejected(&p("home/dev/.kube")); +} + +#[test] +fn cwd_may_not_be_under_a_redirected_appdata_path() { + // Windows Folder Redirection can point %APPDATA% and %LOCALAPPDATA% outside + // the home directory. The workspace must not be under them. + let mut ctx = cwd_context(); + ctx.app_data.push(p("shares/users/dev/AppData/Roaming")); + ctx.app_data.push(p("shares/users/dev/AppData/Local")); + let rejected = |cwd: &Path| { + assert!( + validate_cwd(cwd, &ctx, &FakeFs::default()).is_err(), + "{} should be rejected", + cwd.display() + ) + }; + rejected(&p("shares/users/dev/AppData/Roaming")); + rejected(&p("shares/users/dev/AppData/Roaming/Microsoft/Windows")); + rejected(&p("shares/users/dev/AppData/Local")); + rejected(&p("shares/users/dev/AppData/Local/Temp")); + // Workspace above the redirected path is also rejected (bidirectional check). + rejected(&p("shares/users/dev")); + rejected(&p("shares/users")); +} + +#[test] +fn cwd_must_exist_and_be_a_directory() { + let mut fs = FakeFs::default(); + fs.missing.insert(p("home/dev/gone")); + fs.files.insert(p("home/dev/notes.txt")); + let ctx = cwd_context(); + assert!(validate_cwd(&p("home/dev/gone"), &ctx, &fs).is_err()); + assert!(validate_cwd(&p("home/dev/notes.txt"), &ctx, &fs).is_err()); +} + +#[test] +fn cwd_with_a_character_cmd_exe_interprets_is_rejected() { + for name in ["say\"hi", "50%off", "wow!"] { + rejected(&p("home/dev/work").join(name)); + } +} + +#[test] +fn cwd_is_judged_after_canonicalisation() { + let ctx = cwd_context(); + let mut fs = FakeFs::default(); + fs.mapping + .insert(p("home/dev/work/link"), p("windows/system32")); + fs.mapping + .insert(p("home/dev/work/ssh"), p("home/dev/.ssh")); + fs.mapping + .insert(p("home/dev/work/alias"), p("home/dev/work/real")); + assert!(validate_cwd(&p("home/dev/work/link"), &ctx, &fs).is_err()); + assert!(validate_cwd(&p("home/dev/work/ssh"), &ctx, &fs).is_err()); + assert_eq!( + validate_cwd(&p("home/dev/work/alias"), &ctx, &fs).unwrap(), + p("home/dev/work/real"), + "the request uses the canonical path" + ); +} + +#[test] +fn context_directories_are_canonicalised_too() { + // The configured home is a junction to the real home. + let mut ctx = cwd_context(); + ctx.home = p("users/dev-link"); + let mut fs = FakeFs::default(); + fs.mapping.insert(p("users/dev-link"), p("home/dev")); + assert!(validate_cwd(&p("home/dev"), &ctx, &fs).is_err()); + assert!(validate_cwd(&p("home/dev/.claude/x"), &ctx, &fs).is_err()); +} + +#[test] +fn a_context_directory_that_does_not_exist_is_used_raw() { + let mut ctx = cwd_context(); + ctx.system_dirs.push(p("programfiles-x86")); + let mut fs = FakeFs::default(); + fs.missing.insert(p("programfiles-x86")); + assert!( + validate_cwd(&p("programfiles-x86/tool"), &ctx, &fs).is_err(), + "the raw path still guards" + ); + assert!( + validate_cwd(&p("home/dev/work/app"), &ctx, &fs).is_ok(), + "a missing context directory does not fail the launch" + ); +} + +#[test] +fn cwd_with_dot_dot_is_judged_by_where_it_really_points() { + let temp = tempfile::tempdir().unwrap(); + let base = strip_verbatim(std::fs::canonicalize(temp.path()).unwrap()); + let home = base.join("home"); + std::fs::create_dir_all(home.join(".claude")).unwrap(); + std::fs::create_dir_all(home.join("projects/app")).unwrap(); + let ctx = CwdContext { + home: home.clone(), + state_dir: base.join("state"), + system_dirs: vec![], + denied: vec![], + app_data: vec![], + }; + let sneaky = home.join("projects/app/../../.claude"); + assert!(validate_cwd(&sneaky, &ctx, &RealFs).is_err()); + let fine = home.join("projects/app/../app"); + assert_eq!( + validate_cwd(&fine, &ctx, &RealFs).unwrap(), + home.join("projects/app") + ); +} + +#[cfg(unix)] +#[test] +fn cwd_through_a_real_symlink_is_judged_by_its_target() { + let temp = tempfile::tempdir().unwrap(); + let base = std::fs::canonicalize(temp.path()).unwrap(); + let home = base.join("home"); + std::fs::create_dir_all(home.join(".claude")).unwrap(); + std::fs::create_dir_all(home.join("projects/real")).unwrap(); + std::os::unix::fs::symlink(home.join(".claude"), home.join("projects/to-claude")).unwrap(); + std::os::unix::fs::symlink(home.join("projects/real"), home.join("projects/to-real")).unwrap(); + let ctx = CwdContext { + home: home.clone(), + state_dir: base.join("state"), + system_dirs: vec![], + denied: vec![], + app_data: vec![], + }; + assert!(validate_cwd(&home.join("projects/to-claude"), &ctx, &RealFs).is_err()); + assert_eq!( + validate_cwd(&home.join("projects/to-real"), &ctx, &RealFs).unwrap(), + home.join("projects/real") + ); +} + +#[cfg(windows)] +#[test] +fn cwd_rules_compare_case_insensitively_on_windows() { + let mut ctx = cwd_context(); + ctx.system_dirs = vec![PathBuf::from(r"C:\Windows")]; + ctx.home = PathBuf::from(r"C:\Users\Dev"); + let fs = FakeFs::default(); + assert!(validate_cwd(Path::new(r"c:\wInDoWs\temp\work"), &ctx, &fs).is_err()); + assert!(validate_cwd(Path::new(r"C:\USERS\DEV"), &ctx, &fs).is_err()); + assert!(validate_cwd(Path::new(r"C:\users\dev\.CLAUDE\skills"), &ctx, &fs).is_err()); + assert!(validate_cwd(Path::new(r"C:\Users\Dev\work\app"), &ctx, &fs).is_ok()); +} + +#[cfg(windows)] +#[test] +fn cwd_may_not_be_a_unc_share_root() { + let ctx = cwd_context(); + let fs = FakeFs::default(); + assert!(validate_cwd(Path::new(r"\\server\share"), &ctx, &fs).is_err()); + assert!(validate_cwd(Path::new(r"\\server\share\"), &ctx, &fs).is_err()); + assert!(validate_cwd(Path::new(r"\\server\share\team\app"), &ctx, &fs).is_ok()); +} + +#[test] +fn cwd_may_not_contain_the_state_directory() { + let mut ctx = cwd_context(); + ctx.state_dir = p("home/dev/work/app/.state"); + let fs = FakeFs::default(); + assert!(validate_cwd(&p("home/dev/work/app"), &ctx, &fs).is_err()); + assert!(validate_cwd(&p("home/dev/work"), &ctx, &fs).is_err()); + assert!(validate_cwd(&p("home/dev"), &ctx, &fs).is_err()); +} + +#[test] +fn cwd_may_not_contain_a_guarded_directory_two_levels_deep() { + let mut ctx = cwd_context(); + ctx.state_dir = p("var/local/state/agentdesktop"); + let fs = FakeFs::default(); + assert!(validate_cwd(&p("var/local"), &ctx, &fs).is_err()); + assert!(validate_cwd(&p("var"), &ctx, &fs).is_err()); +} + +#[test] +fn appdata_and_claude_are_canonicalised_before_guarding() { + // The workspace is inside the real target of home/AppData, which is a + // junction to another directory. + let mut ctx = cwd_context(); + ctx.home = p("home/dev"); + let mut fs = FakeFs::default(); + fs.mapping + .insert(p("home/dev/AppData"), p("users/dev/appdata-real")); + fs.mapping + .insert(p("home/dev/.claude"), p("users/dev/.claude-real")); + assert!(validate_cwd(&p("users/dev/appdata-real/local"), &ctx, &fs).is_err()); + assert!(validate_cwd(&p("users/dev/.claude-real/skills"), &ctx, &fs).is_err()); +} + +#[cfg(unix)] +#[test] +fn appdata_symlink_target_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let base = std::fs::canonicalize(temp.path()).unwrap(); + let home = base.join("home"); + let appdata_target = base.join("appdata-target"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&appdata_target).unwrap(); + std::fs::create_dir_all(appdata_target.join("local")).unwrap(); + std::os::unix::fs::symlink(&appdata_target, home.join("AppData")).unwrap(); + let ctx = CwdContext { + home: home.clone(), + state_dir: base.join("state"), + system_dirs: vec![], + denied: vec![], + app_data: vec![], + }; + assert!( + validate_cwd(&appdata_target.join("local"), &ctx, &RealFs).is_err(), + "workspace inside AppData symlink target should be rejected" + ); +} + +// ======================================================== resolve_program (AC 6) + +#[derive(Default)] +struct FakeProgramFs { + files: BTreeSet, + /// Raw path to canonical path. + canonical: BTreeMap, + /// Prepended to every canonical path (`\\?\` on Windows). + prefix: &'static str, +} + +impl ProgramFs for FakeProgramFs { + fn canonicalize(&self, path: &Path) -> io::Result { + let canonical = self + .canonical + .get(path) + .cloned() + .unwrap_or_else(|| path.to_owned()); + Ok(PathBuf::from(format!( + "{}{}", + self.prefix, + canonical.display() + ))) + } + + fn is_file(&self, path: &Path) -> bool { + self.files.contains(path) + } +} + +const NOT_FOUND: &str = "Claude Code native install (claude.exe) not found for this user; npm installs are not supported in the box"; + +fn program_fs(files: &[PathBuf]) -> FakeProgramFs { + FakeProgramFs { + files: files.iter().cloned().collect(), + ..Default::default() + } +} + +#[test] +fn only_a_cmd_or_extensionless_claude_is_not_found() { + let home = p("home/dev"); + let npm = p("npm"); + let fs = program_fs(&[ + npm.join("claude.cmd"), + npm.join("claude"), + home.join(".local/bin/claude.cmd"), + ]); + assert_eq!( + resolve_program(&home, &[npm], &fs), + Err(NOT_FOUND.to_owned()) + ); +} + +#[test] +fn a_claude_exe_of_another_users_home_is_not_found() { + let fs = program_fs(&[p("home/other/.local/bin/claude.exe")]); + assert_eq!( + resolve_program(&p("home/dev"), &[p("windows/system32")], &fs), + Err(NOT_FOUND.to_owned()) + ); +} + +#[test] +fn a_candidate_that_canonicalises_outside_home_and_path_is_not_used() { + let home = p("home/dev"); + let candidate = home.join(".local").join("bin").join("claude.exe"); + let mut fs = program_fs(std::slice::from_ref(&candidate)); + fs.canonical.insert(candidate, p("home/other/claude.exe")); + assert_eq!(resolve_program(&home, &[], &fs), Err(NOT_FOUND.to_owned())); +} + +#[test] +fn the_native_exe_in_home_wins_over_an_npm_claude_earlier_on_path() { + let home = p("home/dev"); + let npm = p("npm"); + let own = home.join(".local").join("bin").join("claude.exe"); + let fs = program_fs(&[npm.join("claude.cmd"), npm.join("claude"), own.clone()]); + assert_eq!( + resolve_program(&home, std::slice::from_ref(&npm), &fs), + Ok(own) + ); +} + +#[test] +fn the_home_candidate_beats_a_claude_exe_on_path() { + let home = p("home/dev"); + let own = home.join(".local").join("bin").join("claude.exe"); + let bin = p("tools"); + let fs = program_fs(&[bin.join("claude.exe"), own.clone()]); + assert_eq!(resolve_program(&home, &[bin], &fs), Ok(own)); +} + +#[test] +fn without_a_home_install_the_first_path_directory_with_claude_exe_is_used() { + let home = p("home/dev"); + let (first, second, third) = (p("a"), p("b"), p("c")); + let fs = program_fs(&[second.join("claude.exe"), third.join("claude.exe")]); + assert_eq!( + resolve_program(&home, &[first, second.clone(), third], &fs), + Ok(second.join("claude.exe")) + ); +} + +#[test] +fn a_directory_named_claude_exe_is_not_a_regular_file() { + let home = p("home/dev"); + // `is_file` is false for it: nothing is in the file set. + let fs = program_fs(&[]); + assert_eq!( + resolve_program(&home, &[p("tools")], &fs), + Err(NOT_FOUND.to_owned()) + ); +} + +#[cfg(windows)] +#[test] +fn the_extended_length_prefix_is_stripped_from_the_resolved_path() { + let home = PathBuf::from(r"C:\Users\dev"); + let own = PathBuf::from(r"C:\Users\dev\.local\bin\claude.exe"); + let mut fs = program_fs(std::slice::from_ref(&own)); + fs.prefix = r"\\?\"; + assert_eq!(resolve_program(&home, &[], &fs), Ok(own)); +} + +#[cfg(windows)] +#[test] +fn resolution_compares_case_insensitively_on_windows() { + let home = PathBuf::from(r"C:\Users\Dev"); + let own = home.join(".local").join("bin").join("claude.exe"); + let mut fs = program_fs(std::slice::from_ref(&own)); + fs.canonical.insert( + own.clone(), + PathBuf::from(r"c:\users\dev\.local\bin\claude.exe"), + ); + assert!(resolve_program(&home, &[], &fs).is_ok()); +} + +// ================================================================ check_args (AC 6) + +const REJECTED_ARG: &str = "argument contains a character cmd.exe interprets"; + +fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() +} + +#[test] +fn a_plain_argument_is_passed_as_is() { + assert_eq!( + check_args(&args(&[ + "--model", + "opus", + "--allowedTools=PowerShell", + r"C:\dir\file.txt" + ])), + Ok(args(&[ + "--model", + "opus", + "--allowedTools=PowerShell", + r"C:\dir\file.txt" + ])) + ); + assert_eq!(check_args(&[]), Ok(vec![])); +} + +#[test] +fn an_argument_with_a_space_is_wrapped_in_double_quotes() { + assert_eq!( + check_args(&args(&["hello world", r"C:\my dir\file.txt"])), + Ok(args(&["\"hello world\"", "\"C:\\my dir\\file.txt\""])) + ); +} + +#[test] +fn trailing_backslashes_before_the_closing_quote_are_doubled() { + assert_eq!( + check_args(&args(&[r"C:\my dir\", r"C:\my dir\\"])), + Ok(args(&[r#""C:\my dir\\""#, r#""C:\my dir\\\\""#])) + ); + assert_eq!( + check_args(&args(&[r"C:\dir\"])), + Ok(args(&[r"C:\dir\"])), + "an unquoted argument keeps its backslashes" + ); +} + +#[test] +fn an_empty_argument_becomes_a_pair_of_quotes() { + assert_eq!(check_args(&args(&["", "x"])), Ok(args(&["\"\"", "x"]))); +} + +#[test] +fn each_character_cmd_exe_interprets_rejects_the_argument() { + for bad in ["\"", "%", "^", "&", "|", "<", ">", "!"] { + for argument in [ + format!("a{bad}b"), + bad.to_owned(), + format!("with space {bad}"), + ] { + assert_eq!( + check_args(std::slice::from_ref(&argument)), + Err(REJECTED_ARG.to_owned()), + "{argument:?}" + ); + } + } +} + +#[test] +fn a_control_character_rejects_the_argument() { + for argument in ["a\nb", "a\rb", "a\u{0}b", "a\u{7}b", "a\u{1b}[31m"] { + assert_eq!( + check_args(&[argument.to_owned()]), + Err(REJECTED_ARG.to_owned()), + "{argument:?}" + ); + } +} + +#[test] +fn one_bad_argument_rejects_the_whole_list() { + assert_eq!( + check_args(&args(&["--model", "x", "a&b", "ok"])), + Err(REJECTED_ARG.to_owned()) + ); +} + +// ============================================================ build_request (AC 7) +// +// Windows-style paths are plain strings here; the builder joins them with `\` +// and replaces `/` with `\`, on every platform. `exists` is called with the +// `\`-joined form. + +const LAUNCH_ID: &str = "0123456789abcdef"; +const HOME: &str = r"C:\Users\dev"; +const CWD: &str = r"C:\lab\ws pr1"; +const LAUNCH_DIR: &str = r"C:\Users\dev\.local\state\agentdesktop\launches\0123456789abcdef"; + +fn base_env() -> BTreeMap { + [ + ("SystemRoot", r"C:\Windows"), + ("windir", r"C:\Windows"), + ("SystemDrive", "C:"), + ("ComSpec", r"C:\Windows\system32\cmd.exe"), + ("PATHEXT", ".COM;.EXE;.BAT;.CMD"), + ("USERPROFILE", HOME), + ("HOMEDRIVE", "C:"), + ("HOMEPATH", r"\Users\dev"), + ("APPDATA", r"C:\Users\dev\AppData\Roaming"), + ("LOCALAPPDATA", r"C:\Users\dev\AppData\Local"), + ("ProgramFiles", r"C:\Program Files"), + ("ProgramData", r"C:\ProgramData"), + ("USERNAME", "dev"), + ] + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect() +} + +/// `process.env` as a map (the request carries `KEY=VALUE` entries). +fn env_map(request: &serde_json::Value) -> BTreeMap { + list(request, "/process/env") + .into_iter() + .map(|entry| { + let (key, value) = entry.split_once('=').expect("KEY=VALUE"); + (key.to_owned(), value.to_owned()) + }) + .collect() +} + +/// The paths of the snapshot scenario that exist on the host. +fn snapshot_existing() -> BTreeSet { + [ + r"C:\lab\ws pr1\.claude", + r"C:\Users\dev\.local\bin", + r"C:\Users\dev\.local\share\claude", + r"C:\Program Files\Git", + r"C:\lab\agentdesktop", + r"C:\Program Files\ClaudeCode", + r"C:\Users\dev\.local\state\agentdesktop\launches\0123456789abcdef\credential", + r"C:\Users\dev\.claude\settings.json", + r"C:\Users\dev\.claude\plugins", + r"C:\lab\ws pr1\.claude\settings.local.json", + r"C:\lab\ws pr1\.mcp.json", + r"C:\lab\ws pr1\.git", + r"C:\lab\ws pr1\.git\HEAD", + r"C:\lab\ws pr1\.git\config", + r"C:\lab\ws pr1\.git\hooks", + ] + .into_iter() + .map(str::to_owned) + .collect() +} + +fn snapshot_directories() -> BTreeSet { + [ + r"C:\lab\ws pr1\.claude", + r"C:\Users\dev\.local\bin", + r"C:\Users\dev\.local\share\claude", + r"C:\Program Files\Git", + r"C:\lab\agentdesktop", + r"C:\Program Files\ClaudeCode", + r"C:\Users\dev\.local\state\agentdesktop\launches\0123456789abcdef\credential", + r"C:\Users\dev\.claude\plugins", + r"C:\lab\ws pr1\.git", + r"C:\lab\ws pr1\.git\hooks", + ] + .into_iter() + .map(str::to_owned) + .collect() +} + +fn launch_input<'a>( + exists: &'a dyn Fn(&Path) -> bool, + is_dir: &'a dyn Fn(&Path) -> bool, +) -> LaunchInput<'a> { + LaunchInput { + launch_id: LAUNCH_ID.into(), + executable: PathBuf::from(r"C:\Users\dev\.local\bin\claude.exe"), + client_exe_dir: PathBuf::from(r"C:\lab\agentdesktop"), + cwd: PathBuf::from(CWD), + args: args(&[ + "--debug-file", + r#""C:\lab\ws pr1\pr1-l2.log""#, + "--allowedTools", + "PowerShell", + ]), + home: PathBuf::from(HOME), + // The user-mode state directory really contains a `/` on Windows. + state_dir: PathBuf::from(r"C:\Users\dev\.local/state\agentdesktop"), + launch_dir: PathBuf::from( + r"C:\Users\dev\.local/state\agentdesktop\launches\0123456789abcdef", + ), + git_dir: Some(PathBuf::from(r"C:\Program Files\Git")), + program_files_claude: Some(PathBuf::from(r"C:\Program Files\ClaudeCode")), + gateway_url: "https://alpha-dev.aks.solo.sademo.de".into(), + base_env: base_env(), + exists, + is_dir, + } +} + +fn request_json( + existing: &BTreeSet, + directories: &BTreeSet, + tweak: impl FnOnce(&mut LaunchInput), +) -> serde_json::Value { + let exists = |path: &Path| existing.contains(path.to_str().expect("UTF-8 path")); + let is_dir = |path: &Path| directories.contains(path.to_str().expect("UTF-8 path")); + let mut input = launch_input(&exists, &is_dir); + tweak(&mut input); + serde_json::to_value(build_request(&input)).expect("the request serializes") +} + +fn list(value: &serde_json::Value, pointer: &str) -> Vec { + value + .pointer(pointer) + .unwrap_or_else(|| panic!("{pointer} in {value}")) + .as_array() + .unwrap_or_else(|| panic!("{pointer} is a list")) + .iter() + .map(|item| item.as_str().expect("a path string").to_owned()) + .collect() +} + +fn readonly(value: &serde_json::Value) -> Vec { + list(value, "/filesystem/readonlyPaths") +} + +fn denied(value: &serde_json::Value) -> Vec { + list(value, "/filesystem/deniedPaths") +} + +fn readwrite(value: &serde_json::Value) -> Vec { + list(value, "/filesystem/readwritePaths") +} + +/// Path lists are sets to the sandbox; the snapshot compares them sorted. +fn sorted_paths(mut value: serde_json::Value) -> serde_json::Value { + for key in ["readwritePaths", "readonlyPaths", "deniedPaths"] { + if let Some(items) = value["filesystem"][key].as_array_mut() { + items.sort_by(|a, b| a.as_str().cmp(&b.as_str())); + } + } + value +} + +#[test] +fn the_request_matches_the_checked_in_snapshot() { + let actual = request_json(&snapshot_existing(), &snapshot_directories(), |_| {}); + let expected: serde_json::Value = + serde_json::from_str(include_str!("testdata/request-claude.json")).unwrap(); + assert_eq!(sorted_paths(actual), sorted_paths(expected)); +} + +#[test] +fn the_request_has_the_fixed_shape_and_no_process_timeout() { + let request = request_json(&snapshot_existing(), &snapshot_directories(), |_| {}); + assert_eq!(request["version"], "1.0.0"); + assert_eq!(request["containment"], "processcontainer"); + assert_eq!(request["containerId"], format!("agentdesktop-{LAUNCH_ID}")); + assert_eq!( + request["ui"], + serde_json::json!({"disable": false, "clipboard": "none", "injection": false}) + ); + assert_eq!( + request["network"], + serde_json::json!({ + "egress": {"default": "allow"}, + "ingress": {"default": "allow", "hostLoopback": "deny"}, + }) + ); + assert_eq!(request["process"]["inheritDefaultEnv"], false); + assert!(request["process"].get("timeout").is_none()); +} + +#[test] +fn the_command_line_starts_the_program_through_cmd_without_autorun_or_delayed_expansion() { + let request = request_json(&snapshot_existing(), &snapshot_directories(), |_| {}); + assert_eq!( + request["process"]["commandLine"], + concat!( + r#"cmd.exe /d /v:off /c start "agentdesktop: claude" /WAIT /D "C:\lab\ws pr1" "#, + r#""C:\Users\dev\.local\bin\claude.exe" "#, + r#"--debug-file "C:\lab\ws pr1\pr1-l2.log" --allowedTools PowerShell"# + ) + ); + let without_args = request_json(&snapshot_existing(), &snapshot_directories(), |input| { + input.args.clear() + }); + assert_eq!( + without_args["process"]["commandLine"] + .as_str() + .unwrap() + .trim_end(), + r#"cmd.exe /d /v:off /c start "agentdesktop: claude" /WAIT /D "C:\lab\ws pr1" "C:\Users\dev\.local\bin\claude.exe""# + ); +} + +#[test] +fn the_environment_is_closed_and_expands_the_path_from_system_root() { + let request = request_json(&snapshot_existing(), &snapshot_directories(), |_| {}); + let env = env_map(&request); + assert_eq!( + env["PATH"], + r"C:\Windows\System32;C:\Windows;C:\Windows\System32\WindowsPowerShell\v1.0;C:\Program Files\Git\cmd;C:\Users\dev\.local\bin;C:\lab\agentdesktop" + ); + assert_eq!(env["TERM"], "xterm-256color"); + assert_eq!(env["TEMP"], format!(r"{LAUNCH_DIR}\tmp")); + assert_eq!(env["TMP"], format!(r"{LAUNCH_DIR}\tmp")); + assert_eq!(env["CLAUDE_CONFIG_DIR"], r"C:\Users\dev\.claude"); + assert_eq!(env["CLAUDE_CODE_USE_POWERSHELL_TOOL"], "1"); + assert_eq!(env["DISABLE_AUTOUPDATER"], "1"); + assert_eq!(env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], "1"); + assert_eq!( + env["ANTHROPIC_BASE_URL"], + "https://alpha-dev.aks.solo.sademo.de" + ); + assert_eq!( + env["AGENTDESKTOP_CREDENTIAL_FILE"], + format!(r"{LAUNCH_DIR}\credential\token") + ); + for (key, value) in base_env() { + assert_eq!( + env[&key], value, + "{key} comes from the daemon's environment" + ); + } +} + +#[test] +fn the_path_has_no_git_entry_without_git() { + let request = request_json(&snapshot_existing(), &snapshot_directories(), |input| { + input.git_dir = None + }); + let env = env_map(&request); + let path = env["PATH"].as_str(); + assert_eq!( + path, + r"C:\Windows\System32;C:\Windows;C:\Windows\System32\WindowsPowerShell\v1.0;C:\Users\dev\.local\bin;C:\lab\agentdesktop" + ); + assert!(!readonly(&request).iter().any(|path| path.contains("Git"))); +} + +#[test] +fn the_request_contains_no_percent_sign_and_no_forward_slash_in_any_path() { + // Inputs with `/` in every position a path can come from. + let request = request_json(&snapshot_existing(), &snapshot_directories(), |input| { + input.cwd = PathBuf::from(r"C:\lab\ws pr1"); + input.client_exe_dir = PathBuf::from("C:/lab/agentdesktop"); + }); + assert!(!request.to_string().contains('%')); + + let mut paths = readwrite(&request); + paths.extend(readonly(&request)); + paths.extend(denied(&request)); + paths.push(request["process"]["cwd"].as_str().unwrap().to_owned()); + for key in [ + "PATH", + "TEMP", + "TMP", + "CLAUDE_CONFIG_DIR", + "AGENTDESKTOP_CREDENTIAL_FILE", + ] { + paths.push(env_map(&request)[key].clone()); + } + for path in paths { + assert!(!path.contains('/'), "{path}"); + } + assert!(readonly(&request).contains(&r"C:\lab\agentdesktop".to_owned())); +} + +#[test] +fn a_url_in_the_request_keeps_its_slashes() { + let request = request_json(&snapshot_existing(), &snapshot_directories(), |input| { + input.gateway_url = "https://gateway.example.com/anthropic".into(); + }); + assert_eq!( + env_map(&request)["ANTHROPIC_BASE_URL"], + "https://gateway.example.com/anthropic" + ); +} + +#[test] +fn read_write_paths_are_the_workspace_the_claude_directory_and_the_launch_tmp() { + let request = request_json(&snapshot_existing(), &snapshot_directories(), |_| {}); + let mut actual = readwrite(&request); + actual.sort(); + let mut expected = vec![ + CWD.to_owned(), + r"C:\Users\dev\.claude".to_owned(), + format!(r"{LAUNCH_DIR}\tmp"), + ]; + expected.sort(); + assert_eq!(actual, expected); +} + +#[test] +fn read_only_runtime_directories_are_listed_only_when_they_exist() { + let all = request_json(&snapshot_existing(), &snapshot_directories(), |_| {}); + let candidates = [ + r"C:\Users\dev\.local\bin", + r"C:\Users\dev\.local\share\claude", + r"C:\Program Files\Git", + r"C:\lab\agentdesktop", + r"C:\Program Files\ClaudeCode", + &format!(r"{LAUNCH_DIR}\credential"), + ]; + for candidate in candidates { + assert!( + readonly(&all).contains(&candidate.to_owned()), + "{candidate}" + ); + let mut existing = snapshot_existing(); + existing.remove(candidate); + let directories = snapshot_directories(); + let request = request_json(&existing, &directories, |_| {}); + assert!( + !readonly(&request).contains(&candidate.to_owned()), + "{candidate} is absent from the host" + ); + assert!( + !denied(&request).contains(&candidate.to_owned()), + "{candidate} is optional, not denied" + ); + } +} + +/// The executable-config rule: exists -> read-only, absent -> denied. +#[test] +fn executable_configuration_is_read_only_when_present_and_denied_when_absent() { + let rule = [ + r"C:\Users\dev\.claude\settings.json", + r"C:\Users\dev\.claude\hooks", + r"C:\Users\dev\.claude\plugins", + r"C:\lab\ws pr1\.claude\settings.json", + r"C:\lab\ws pr1\.claude\settings.local.json", + r"C:\lab\ws pr1\.claude\hooks", + r"C:\lab\ws pr1\.mcp.json", + r"C:\lab\ws pr1\.git\config", + r"C:\lab\ws pr1\.git\hooks", + ]; + for path in rule { + let mut with = snapshot_existing(); + with.insert(path.to_owned()); + let directories = snapshot_directories(); + let present = request_json(&with, &directories, |_| {}); + assert!( + readonly(&present).contains(&path.to_owned()), + "{path} exists" + ); + assert!( + !denied(&present).contains(&path.to_owned()), + "{path} exists" + ); + assert!(!readwrite(&present).contains(&path.to_owned())); + + let mut without = snapshot_existing(); + without.remove(path); + let directories = snapshot_directories(); + let absent = request_json(&without, &directories, |_| {}); + assert!( + denied(&absent).contains(&path.to_owned()), + "{path} is absent" + ); + assert!( + !readonly(&absent).contains(&path.to_owned()), + "{path} is absent" + ); + } +} + +#[test] +fn a_workspace_without_a_git_directory_denies_it_so_the_box_cannot_init_one() { + let mut existing = snapshot_existing(); + let mut directories = snapshot_directories(); + for gone in [ + r"C:\lab\ws pr1\.git", + r"C:\lab\ws pr1\.git\HEAD", + r"C:\lab\ws pr1\.git\config", + r"C:\lab\ws pr1\.git\hooks", + ] { + existing.remove(gone); + directories.remove(gone); + } + let request = request_json(&existing, &directories, |_| {}); + assert!(denied(&request).contains(&r"C:\lab\ws pr1\.git".to_owned())); + assert!(!readonly(&request).contains(&r"C:\lab\ws pr1\.git".to_owned())); + assert!(!readwrite(&request).contains(&r"C:\lab\ws pr1\.git".to_owned())); +} + +#[test] +fn a_git_directory_is_left_writable_apart_from_its_config_and_hooks() { + let request = request_json(&snapshot_existing(), &snapshot_directories(), |_| {}); + let git = r"C:\lab\ws pr1\.git".to_owned(); + assert!(!denied(&request).contains(&git)); + assert!(!readonly(&request).contains(&git)); + assert!(readonly(&request).contains(&r"C:\lab\ws pr1\.git\config".to_owned())); + assert!(readonly(&request).contains(&r"C:\lab\ws pr1\.git\hooks".to_owned())); +} + +#[test] +fn an_empty_git_directory_gets_config_and_hooks_guards() { + // An empty `.git` directory: exists as a directory but has no children. + let mut existing = snapshot_existing(); + for gone in [ + r"C:\lab\ws pr1\.git\HEAD", + r"C:\lab\ws pr1\.git\config", + r"C:\lab\ws pr1\.git\hooks", + ] { + existing.remove(gone); + } + let directories = snapshot_directories(); + let request = request_json(&existing, &directories, |_| {}); + // The guards are emitted even though the children are absent. + assert!(denied(&request).contains(&r"C:\lab\ws pr1\.git\config".to_owned())); + assert!(denied(&request).contains(&r"C:\lab\ws pr1\.git\hooks".to_owned())); + assert!(denied(&request).contains(&r"C:\lab\ws pr1\.git\commondir".to_owned())); +} + +#[test] +fn a_git_file_of_a_worktree_or_submodule_is_left_as_it_is() { + // `.git` exists but is a file, not a directory: worktree or submodule. + let mut existing = snapshot_existing(); + let mut directories = snapshot_directories(); + // Remove .git from directories to make it a file. + directories.remove(r"C:\lab\ws pr1\.git"); + // A .git file has no children in the filesystem. + for gone in [ + r"C:\lab\ws pr1\.git\HEAD", + r"C:\lab\ws pr1\.git\config", + r"C:\lab\ws pr1\.git\hooks", + ] { + existing.remove(gone); + } + let request = request_json(&existing, &directories, |_| {}); + for path in readwrite(&request) + .into_iter() + .chain(readonly(&request)) + .chain(denied(&request)) + { + assert!(!path.contains(r"\.git"), "{path}"); + } +} + +#[test] +fn credential_and_ssh_style_paths_are_denied_whether_or_not_they_exist() { + let always = [ + r"C:\Users\dev\.claude\.credentials.json", + r"C:\Users\dev\.claude\history.jsonl", + r"C:\Users\dev\.ssh", + r"C:\Users\dev\.aws", + r"C:\Users\dev\.azure", + r"C:\Users\dev\.kube", + ]; + for (existing, directories) in [ + (BTreeSet::new(), BTreeSet::new()), + (snapshot_existing(), snapshot_directories()), + ] { + let request = request_json(&existing, &directories, |_| {}); + for path in always { + assert!(denied(&request).contains(&path.to_owned()), "{path}"); + assert!(!readonly(&request).contains(&path.to_owned()), "{path}"); + } + } +} + +#[test] +fn write_request_creates_the_private_launch_dir_and_writes_json_without_a_bom() { + let temp = tempfile::tempdir().unwrap(); + let launch_dir = temp.path().join("launches").join(LAUNCH_ID); + let existing = snapshot_existing(); + let directories = snapshot_directories(); + let exists = |path: &Path| existing.contains(path.to_str().unwrap()); + let is_dir = |path: &Path| directories.contains(path.to_str().unwrap()); + let request = build_request(&launch_input(&exists, &is_dir)); + + let path = write_request(&launch_dir, &request).unwrap(); + + assert_eq!(path, launch_dir.join("request.json")); + let bytes = std::fs::read(&path).unwrap(); + assert!(!bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "no UTF-8 BOM"); + assert!(!bytes.starts_with(&[0xFF, 0xFE]) && !bytes.starts_with(&[0xFE, 0xFF])); + let written: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(written, serde_json::to_value(&request).unwrap()); + assert_eq!(written["containerId"], format!("agentdesktop-{LAUNCH_ID}")); +} + +#[cfg(unix)] +#[test] +fn write_request_uses_a_private_directory_and_a_0600_file() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let launch_dir = temp.path().join("launches").join(LAUNCH_ID); + let exists = |_: &Path| false; + let is_dir = |_: &Path| false; + let request = build_request(&launch_input(&exists, &is_dir)); + + let path = write_request(&launch_dir, &request).unwrap(); + + let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&launch_dir), 0o700); + assert_eq!(mode(&path), 0o600); +} + +// ============================================================ refresh (AC 8) + +#[test] +fn refresh_runs_fifty_seconds_before_expiry() { + assert_eq!(refresh_at(1_000, 100), 950); +} + +#[test] +fn refresh_never_runs_sooner_than_ten_seconds_from_now() { + assert_eq!(refresh_at(1_000, 990), 1_000); + assert_eq!(refresh_at(1_000, 995), 1_005); + assert_eq!(refresh_at(1_000, 2_000), 2_010); +} + +#[test] +fn refresh_does_not_underflow_for_an_early_expiry() { + assert_eq!(refresh_at(30, 0), 10); +} + +#[test] +fn a_later_expiry_is_rewritten() { + assert_eq!( + next_action(&RefreshInput::Ok(5_000), 1_000, 950), + RefreshAction::Rewrite + ); +} + +#[test] +fn the_same_expiry_keeps_the_file_and_retries() { + assert_eq!( + next_action(&RefreshInput::Ok(1_000), 1_000, 950), + RefreshAction::Keep + ); +} + +#[test] +fn a_gateway_error_or_timeout_keeps_the_file_until_it_expires() { + for input in [ + RefreshInput::Status(StatusCode::UNAUTHORIZED), + RefreshInput::Status(StatusCode::BAD_GATEWAY), + RefreshInput::Timeout, + ] { + assert_eq!( + next_action(&input, 1_000, 950), + RefreshAction::Keep, + "{input:?} before expiry" + ); + assert_eq!( + next_action(&input, 1_000, 1_001), + RefreshAction::Delete, + "{input:?} after expiry" + ); + } +} + +#[test] +fn a_policy_or_precondition_failure_deletes_the_file_at_once() { + for status in [StatusCode::CONFLICT, StatusCode::FAILED_DEPENDENCY] { + assert_eq!( + next_action(&RefreshInput::Status(status), 1_000, 100), + RefreshAction::Delete, + "{status}" + ); + } +} + +// =========================================================== LaunchRegistry (AC 9) + +fn launch(id: &str, started_at: u64) -> Launch { + Launch { + launch_id: id.to_owned(), + program: "claude-code".into(), + cwd: p("work/app"), + container_id: format!("agentdesktop-{id}"), + status: LaunchStatus::Running, + exit_code: None, + started_at_unix_seconds: started_at, + ended_at_unix_seconds: None, + credential_expires_at_unix_seconds: None, + } +} + +fn ids(registry: &LaunchRegistry) -> Vec { + registry + .list() + .into_iter() + .map(|launch| launch.launch_id) + .collect() +} + +#[test] +fn a_new_registry_is_empty() { + assert!(LaunchRegistry::new().list().is_empty()); +} + +#[test] +fn the_registry_lists_launches_by_start_time_ascending() { + let registry = LaunchRegistry::new(); + registry.insert(launch("c", 30)); + registry.insert(launch("a", 10)); + registry.insert(launch("b", 20)); + assert_eq!(ids(®istry), ["a", "b", "c"]); +} + +#[test] +fn clones_of_the_registry_share_their_launches() { + let registry = LaunchRegistry::new(); + let clone = registry.clone(); + clone.insert(launch("a", 10)); + assert_eq!(ids(®istry), ["a"]); +} + +#[test] +fn marking_a_launch_exited_records_the_code_and_the_time() { + let registry = LaunchRegistry::new(); + registry.insert(launch("a", 10)); + registry.insert(launch("b", 20)); + + registry.mark_exited("a", Some(3), 99); + + let listed = registry.list(); + assert_eq!(listed[0].status, LaunchStatus::Exited); + assert_eq!(listed[0].exit_code, Some(3)); + assert_eq!(listed[0].ended_at_unix_seconds, Some(99)); + assert_eq!( + listed[1].status, + LaunchStatus::Running, + "the other launch is untouched" + ); + assert_eq!(listed[1].exit_code, None); + assert_eq!(listed[1].ended_at_unix_seconds, None); +} + +#[test] +fn an_exit_without_a_code_is_recorded_as_exited() { + let registry = LaunchRegistry::new(); + registry.insert(launch("a", 10)); + registry.mark_exited("a", None, 50); + let listed = registry.list(); + assert_eq!(listed[0].status, LaunchStatus::Exited); + assert_eq!(listed[0].exit_code, None); +} + +#[test] +fn the_credential_expiry_of_a_running_launch_can_be_updated() { + let registry = LaunchRegistry::new(); + registry.insert(launch("a", 10)); + registry.set_credential_expiry("a", 3_600); + assert_eq!( + registry.list()[0].credential_expires_at_unix_seconds, + Some(3_600) + ); + registry.set_credential_expiry("a", 7_200); + assert_eq!( + registry.list()[0].credential_expires_at_unix_seconds, + Some(7_200) + ); +} + +#[test] +fn updating_an_unknown_launch_changes_nothing() { + let registry = LaunchRegistry::new(); + registry.insert(launch("a", 10)); + registry.mark_exited("nope", Some(1), 5); + registry.set_credential_expiry("nope", 5); + assert_eq!(registry.list(), vec![launch("a", 10)]); +} + +#[test] +fn the_registry_keeps_every_running_launch_and_the_last_32_exited_ones() { + let registry = LaunchRegistry::new(); + registry.insert(launch("running", 1)); + for index in 0..40u64 { + let id = format!("exited-{index:02}"); + registry.insert(launch(&id, 100 + index)); + registry.mark_exited(&id, Some(0), 1_000 + index); + } + + let listed = ids(®istry); + + assert_eq!(listed.len(), 33, "1 running + 32 exited"); + assert_eq!(listed[0], "running"); + assert!( + !listed.contains(&"exited-00".to_owned()), + "the oldest exits are dropped" + ); + assert!(!listed.contains(&"exited-07".to_owned())); + assert!(listed.contains(&"exited-08".to_owned())); + assert!(listed.contains(&"exited-39".to_owned())); +} + +#[test] +fn many_running_launches_are_never_dropped() { + let registry = LaunchRegistry::new(); + for index in 0..50u64 { + registry.insert(launch(&format!("run-{index:02}"), index)); + } + assert_eq!(registry.list().len(), 50); +} + +// ================================================================== sweep (AC 9) + +fn make_launch_dir(state: &Path, id: &str, pid_file: Option<&str>) -> PathBuf { + let dir = state.join("launches").join(id); + std::fs::create_dir_all(dir.join("credential")).unwrap(); + std::fs::create_dir_all(dir.join("tmp")).unwrap(); + std::fs::write(dir.join("credential").join("token"), "secret-gateway-token").unwrap(); + std::fs::write(dir.join("request.json"), "{}").unwrap(); + std::fs::write(dir.join("wxc-exec.log"), "log").unwrap(); + if let Some(pid_file) = pid_file { + std::fs::write(dir.join("pid"), pid_file).unwrap(); + } + dir +} + +#[test] +fn sweep_of_a_state_directory_without_launches_succeeds() { + let temp = tempfile::tempdir().unwrap(); + sweep(temp.path(), &|_, _| panic!("no launches, nothing to ask")).unwrap(); +} + +#[test] +fn sweep_removes_the_whole_directory_of_a_launch_whose_process_is_gone() { + let temp = tempfile::tempdir().unwrap(); + let dir = make_launch_dir(temp.path(), "dead", Some("4242 133700000000000000")); + let asked = RefCell::new(Vec::new()); + + sweep(temp.path(), &|pid, start| { + asked.borrow_mut().push((pid, start)); + false + }) + .unwrap(); + + assert!(!dir.exists()); + assert_eq!(&*asked.borrow(), &[(4242, 133_700_000_000_000_000)]); +} + +#[test] +fn sweep_removes_only_the_secrets_of_a_launch_that_is_still_running() { + let temp = tempfile::tempdir().unwrap(); + let dir = make_launch_dir(temp.path(), "alive", Some("4242 133700000000000000")); + + sweep(temp.path(), &|pid, start| { + pid == 4242 && start == 133_700_000_000_000_000 + }) + .unwrap(); + + assert!( + !dir.join("credential").exists(), + "the credential is no longer refreshed" + ); + assert!(!dir.join("request.json").exists()); + assert!(dir.is_dir(), "the box still runs in it"); + assert!(dir.join("pid").is_file()); + assert!(dir.join("tmp").is_dir()); +} + +#[test] +fn sweep_handles_live_and_dead_launches_side_by_side() { + let temp = tempfile::tempdir().unwrap(); + let alive = make_launch_dir(temp.path(), "alive", Some("100 1")); + let dead = make_launch_dir(temp.path(), "dead", Some("200 2")); + + sweep(temp.path(), &|pid, _| pid == 100).unwrap(); + + assert!(alive.is_dir() && !alive.join("credential").exists()); + assert!(!dead.exists()); +} + +#[test] +fn sweep_does_not_treat_a_reused_pid_as_the_launch() { + // The start time is part of the identity: same pid, different creation time. + let temp = tempfile::tempdir().unwrap(); + let dir = make_launch_dir(temp.path(), "reused", Some("4242 1")); + + sweep(temp.path(), &|pid, start| pid == 4242 && start == 2).unwrap(); + + assert!(!dir.exists()); +} + +// ======================================================= routes (AC 3, 4, 8, 9) +// +// A real HTTP round trip against `launch::router`, with a fake `LaunchHost` +// (temp-dir paths on both platforms), a stub executor and a stub credential +// source. Time is paused: the 3 s early-exit watch, the 1 s poller and the +// refresh timer run on the virtual clock, and the fake host's `now()` follows +// it. + +const BASE_TIME: u64 = 1_700_000_000; +const CREDENTIAL_LIFETIME: u64 = 3_600; + +const GOOD_CONFIG: &str = r#" +llmGateway: + url: https://gateway.example.com + authentication: + type: oidc + issuer: https://login.example.com + clientId: agentdesktop +programs: + claudeCode: {} +"#; + +#[derive(Clone)] +struct Clock { + start: tokio::time::Instant, +} + +impl Clock { + fn new() -> Self { + Self { + start: tokio::time::Instant::now(), + } + } + + fn now(&self) -> u64 { + BASE_TIME + self.start.elapsed().as_secs() + } +} + +struct FakeHost { + home: PathBuf, + state_dir: PathBuf, + client_exe_dir: PathBuf, + clock: Clock, +} + +impl LaunchHost for FakeHost { + fn home(&self) -> PathBuf { + self.home.clone() + } + + fn path_dirs(&self) -> Vec { + Vec::new() + } + + fn base_env(&self) -> BTreeMap { + base_env() + } + + fn cwd_context(&self) -> CwdContext { + CwdContext { + home: self.home.clone(), + state_dir: self.state_dir.clone(), + system_dirs: vec![self.home.parent().unwrap().join("windows")], + denied: vec![self.home.join(".ssh")], + app_data: vec![], + } + } + + fn cwd_fs(&self) -> &dyn CwdFs { + &RealFs + } + + fn program_fs(&self) -> &dyn ProgramFs { + &RealFs + } + + fn exists(&self, path: &Path) -> bool { + path.exists() + } + + fn is_dir(&self, path: &Path) -> bool { + path.is_dir() + } + + fn git_dir(&self) -> Option { + None + } + + fn program_files_claude(&self) -> Option { + None + } + + fn client_exe_dir(&self) -> PathBuf { + self.client_exe_dir.clone() + } + + fn now(&self) -> u64 { + self.clock.now() + } +} + +struct StubCredentials { + clock: Clock, + calls: Mutex>, + failure: Mutex>, + panic_on_call: bool, + issued: AtomicU32, +} + +impl StubCredentials { + fn calls(&self) -> Vec<(String, bool)> { + self.calls.lock().unwrap().clone() + } + + fn fail_with(&self, failure: Option<(StatusCode, &str)>) { + *self.failure.lock().unwrap() = failure.map(|(status, body)| (status, body.to_owned())); + } +} + +#[async_trait] +impl CredentialSource for StubCredentials { + async fn credential( + &self, + client_id: &str, + interactive: bool, + ) -> Result { + self.calls + .lock() + .unwrap() + .push((client_id.to_owned(), interactive)); + assert!( + !self.panic_on_call, + "a precondition failure must not reach the credential source" + ); + if let Some(failure) = self.failure.lock().unwrap().clone() { + return Err(failure); + } + let issued = self.issued.fetch_add(1, Ordering::SeqCst); + Ok(LlmGatewayCredential { + credential: format!("gateway-token-{issued}"), + expires_at_unix_seconds: self.clock.now() + CREDENTIAL_LIFETIME, + }) + } +} + +#[derive(Clone, Debug)] +struct SpawnCall { + launch_id: String, + executor: PathBuf, + request: PathBuf, + log: PathBuf, + /// `request.json` as it was when the executor was started. + request_json: Option, + /// `credential\token` as it was when the executor was started. + token: Option, +} + +struct StubExecutor { + spawns: Mutex>, + error: Mutex>, + /// Written to the log file by `spawn`. + log_text: Mutex, + /// The children's exit status; `None` while running. + exit: Arc>>, +} + +impl StubExecutor { + fn spawns(&self) -> Vec { + self.spawns.lock().unwrap().clone() + } + + fn exit_with(&self, code: i32) { + *self.exit.lock().unwrap() = Some(code); + } +} + +struct StubChild { + exit: Arc>>, +} + +impl LaunchChild for StubChild { + fn try_wait(&mut self) -> io::Result> { + Ok(*self.exit.lock().unwrap()) + } + + fn pid(&self) -> u32 { + 4242 + } + + fn start_time(&self) -> u64 { + 987_654_321 + } +} + +impl Executor for StubExecutor { + fn spawn( + &self, + launch_id: &str, + executor: &Path, + request: &Path, + log: &Path, + ) -> io::Result> { + if let Some(error) = self.error.lock().unwrap().take() { + return Err(error); + } + let launch_dir = request + .parent() + .expect("request.json is inside the launch directory"); + self.spawns.lock().unwrap().push(SpawnCall { + launch_id: launch_id.to_owned(), + executor: executor.to_owned(), + request: request.to_owned(), + log: log.to_owned(), + request_json: std::fs::read_to_string(request).ok(), + token: std::fs::read_to_string(launch_dir.join("credential").join("token")).ok(), + }); + let text = self.log_text.lock().unwrap().clone(); + if !text.is_empty() { + std::fs::write(log, text)?; + } + Ok(Box::new(StubChild { + exit: self.exit.clone(), + })) + } +} + +enum Availability { + Available, + Unavailable(&'static str), + Missing, +} + +struct Setup { + config: &'static str, + remote_config: Option<&'static str>, + system_mode: bool, + availability: Availability, + daemon_sid: Option<&'static str>, + peer: Option, + claude_installed: bool, + credentials_must_not_be_reached: bool, +} + +impl Default for Setup { + fn default() -> Self { + Self { + config: GOOD_CONFIG, + remote_config: None, + system_mode: false, + availability: Availability::Available, + daemon_sid: None, + peer: None, + claude_installed: true, + credentials_must_not_be_reached: false, + } + } +} + +struct Harness { + _dir: tempfile::TempDir, + cwd: PathBuf, + home: PathBuf, + state_dir: PathBuf, + base: String, + client: reqwest::Client, + executor: Arc, + credentials: Arc, +} + +fn strip_verbatim(path: PathBuf) -> PathBuf { + match path.to_str().and_then(|text| text.strip_prefix(r"\\?\")) { + Some(stripped) => PathBuf::from(stripped), + None => path, + } +} + +impl Harness { + async fn start(setup: Setup) -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = strip_verbatim(std::fs::canonicalize(dir.path()).unwrap()); + let home = root.join("home").join("dev"); + let cwd = home.join("work").join("app"); + let state_dir = root.join("state"); + let client_exe_dir = root.join("bin"); + for directory in [&cwd, &state_dir, &client_exe_dir] { + std::fs::create_dir_all(directory).unwrap(); + } + if setup.claude_installed { + let bin = home.join(".local").join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("claude.exe"), b"MZ").unwrap(); + } + if let Some(remote) = setup.remote_config { + std::fs::write(state_dir.join("remote-config.yaml"), remote).unwrap(); + } + + let clock = Clock::new(); + let credentials = Arc::new(StubCredentials { + clock: clock.clone(), + calls: Mutex::new(Vec::new()), + failure: Mutex::new(None), + panic_on_call: setup.credentials_must_not_be_reached, + issued: AtomicU32::new(0), + }); + let executor = Arc::new(StubExecutor { + spawns: Mutex::new(Vec::new()), + error: Mutex::new(None), + log_text: Mutex::new(String::new()), + exit: Arc::new(Mutex::new(None)), + }); + let executor_path = client_exe_dir.join("wxc-exec.exe"); + let availability = match setup.availability { + Availability::Available => Some(ContainmentInfo { + executor: executor_path.display().to_string(), + available: true, + error: None, + }), + Availability::Unavailable(error) => Some(ContainmentInfo { + executor: executor_path.display().to_string(), + available: false, + error: Some(error.to_owned()), + }), + Availability::Missing => None, + }; + + let daemon_info: agentdesktop_core::model::DaemonInfo = + serde_json::from_value(serde_json::json!({ + "version": "test", + "scope": if setup.system_mode { "system" } else { "user" }, + "configPath": "", + "stateDirectory": "", + "inventoryInterval": "60s", + "controller": null, + })) + .unwrap(); + let (_discovery_sender, discovery) = + tokio::sync::watch::channel(Arc::new(agentdesktop_core::model::Discovery { + agents: vec![], + model_runtimes: vec![], + })); + let app = api::AppState { + config: agentdesktop_core::config::parse_daemon(setup.config) + .expect("test configuration"), + daemon_info, + discovery, + enrollment: crate::enrollment::EnrollmentState::new(false), + controller_status: None, + state_dir: state_dir.clone(), + oidc_callback_listen: None, + telemetry: None, + logout: None, + usage: None, + programs: None, + intercept: None, + }; + let state = LaunchState { + app, + executor: executor.clone(), + credentials: credentials.clone(), + host: Arc::new(FakeHost { + home: home.clone(), + state_dir: state_dir.clone(), + client_exe_dir, + clock: clock.clone(), + }), + registry: LaunchRegistry::new(), + availability, + daemon_sid: setup.daemon_sid.map(str::to_owned), + }; + + let mut router = router(state); + if let Some(peer) = setup.peer { + router = router.layer(axum::Extension(peer)); + } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + + Self { + _dir: dir, + cwd, + home, + state_dir, + base: format!("http://{address}"), + client: reqwest::Client::new(), + executor, + credentials, + } + } + + fn body(&self) -> serde_json::Value { + serde_json::to_value(LaunchRequest { + program: "claude-code".into(), + cwd: self.cwd.clone(), + args: vec![], + }) + .unwrap() + } + + fn body_with(&self, change: impl FnOnce(&mut serde_json::Value)) -> serde_json::Value { + let mut body = self.body(); + change(&mut body); + body + } + + async fn launch(&self, body: &serde_json::Value) -> (u16, String) { + let response = self + .client + .post(format!("{}/v1/launch", self.base)) + .json(body) + .send() + .await + .unwrap(); + (response.status().as_u16(), response.text().await.unwrap()) + } + + async fn launches(&self) -> (u16, String) { + let response = self + .client + .get(format!("{}/v1/launches", self.base)) + .send() + .await + .unwrap(); + (response.status().as_u16(), response.text().await.unwrap()) + } + + async fn listed(&self) -> Vec { + let (status, body) = self.launches().await; + assert_eq!(status, 200, "{body}"); + serde_json::from_str(&body).unwrap() + } + + async fn launch_ok(&self) -> Launch { + let (status, body) = self.launch(&self.body()).await; + assert_eq!(status, 201, "{body}"); + serde_json::from_str(&body).unwrap() + } + + fn launch_dir(&self, launch_id: &str) -> PathBuf { + self.state_dir.join("launches").join(launch_id) + } + + fn token(&self, launch_id: &str) -> Option { + std::fs::read_to_string(self.launch_dir(launch_id).join("credential").join("token")).ok() + } + + /// Nothing was written or started for a refused request. + fn assert_untouched(&self) { + assert!(self.executor.spawns().is_empty(), "nothing is spawned"); + let launches = self.state_dir.join("launches"); + assert!( + !launches.exists() || std::fs::read_dir(&launches).unwrap().next().is_none(), + "no launch directory is created" + ); + } +} + +fn is_launch_id(id: &str) -> bool { + id.len() == 16 + && id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +// ----------------------------------------------------- check order and statuses + +#[tokio::test(start_paused = true)] +async fn system_mode_answers_409_on_both_routes_before_anything_else() { + let h = Harness::start(Setup { + system_mode: true, + availability: Availability::Missing, + daemon_sid: Some("S-1-5-21-1"), + peer: Some(peer(Some("S-1-5-21-2"), true)), + ..Setup::default() + }) + .await; + + let (status, body) = h + .launch(&h.body_with(|body| body["program"] = "codex".into())) + .await; + assert_eq!(status, 409, "{body}"); + assert!( + body.contains("containment needs the user-mode daemon"), + "{body}" + ); + let (status, body) = h.launches().await; + assert_eq!(status, 409, "{body}"); + assert!( + body.contains("containment needs the user-mode daemon"), + "{body}" + ); + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn a_peer_of_another_user_is_refused_with_403_on_both_routes() { + let h = Harness::start(Setup { + daemon_sid: Some("S-1-5-21-1"), + peer: Some(peer(Some("S-1-5-21-2"), false)), + ..Setup::default() + }) + .await; + + let (status, body) = h.launch(&h.body()).await; + assert_eq!(status, 403, "{body}"); + assert_eq!(body, "launch is available to the daemon's own user only"); + let (status, body) = h.launches().await; + assert_eq!(status, 403, "{body}"); + assert_eq!(body, "launch is available to the daemon's own user only"); + h.assert_untouched(); + assert!(h.credentials.calls().is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn an_app_container_peer_of_the_same_user_is_refused_with_403() { + let h = Harness::start(Setup { + daemon_sid: Some("S-1-5-21-1"), + peer: Some(peer(Some("S-1-5-21-1"), true)), + ..Setup::default() + }) + .await; + assert_eq!(h.launch(&h.body()).await.0, 403); + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn a_request_without_a_peer_extension_is_refused_when_the_daemon_has_a_sid() { + let h = Harness::start(Setup { + daemon_sid: Some("S-1-5-21-1"), + peer: None, + ..Setup::default() + }) + .await; + assert_eq!(h.launches().await.0, 403); + assert_eq!(h.launch(&h.body()).await.0, 403); +} + +#[tokio::test(start_paused = true)] +async fn the_daemons_own_user_outside_an_app_container_is_served() { + let h = Harness::start(Setup { + daemon_sid: Some("S-1-5-21-1"), + peer: Some(peer(Some("S-1-5-21-1"), false)), + ..Setup::default() + }) + .await; + assert_eq!(h.launches().await, (200, "[]".to_owned())); + assert_eq!(h.launch(&h.body()).await.0, 201); +} + +#[tokio::test(start_paused = true)] +async fn the_peer_check_comes_before_the_availability_check() { + let h = Harness::start(Setup { + availability: Availability::Missing, + daemon_sid: Some("S-1-5-21-1"), + peer: Some(peer(Some("S-1-5-21-2"), false)), + ..Setup::default() + }) + .await; + assert_eq!(h.launch(&h.body()).await.0, 403); +} + +#[tokio::test(start_paused = true)] +async fn a_daemon_without_a_sid_does_not_check_the_peer() { + // Unix user mode: the socket is 0600 and the accept loop attaches no peer. + let h = Harness::start(Setup::default()).await; + assert_eq!(h.launches().await.0, 200); +} + +#[tokio::test(start_paused = true)] +async fn an_unavailable_containment_answers_409_with_its_error() { + let h = Harness::start(Setup { + availability: Availability::Unavailable("executor not found: C:\\missing\\wxc-exec.exe"), + ..Setup::default() + }) + .await; + let (status, body) = h + .launch(&h.body_with(|body| body["program"] = "codex".into())) + .await; + assert_eq!(status, 409, "{body}"); + assert!( + body.contains("executor not found: C:\\missing\\wxc-exec.exe"), + "{body}" + ); + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn an_unconfigured_containment_answers_409() { + let h = Harness::start(Setup { + availability: Availability::Missing, + ..Setup::default() + }) + .await; + assert_eq!(h.launch(&h.body()).await.0, 409); + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn only_claude_code_can_be_launched() { + let h = Harness::start(Setup::default()).await; + for program in ["codex", "claude", ""] { + let (status, body) = h + .launch(&h.body_with(|body| body["program"] = program.into())) + .await; + assert_eq!(status, 400, "{program}: {body}"); + assert_eq!(body, "program must be claude-code", "{program}"); + } + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn the_program_is_checked_before_the_workspace() { + let h = Harness::start(Setup::default()).await; + let body = h.body_with(|body| { + body["program"] = "codex".into(); + body["cwd"] = h.home.display().to_string().into(); + }); + assert_eq!( + h.launch(&body).await, + (400, "program must be claude-code".to_owned()) + ); +} + +#[tokio::test(start_paused = true)] +async fn a_workspace_that_breaks_the_rules_answers_400() { + let h = Harness::start(Setup::default()).await; + let with_percent = h.cwd.parent().unwrap().join("50%off"); + std::fs::create_dir_all(&with_percent).unwrap(); + std::fs::create_dir_all(h.home.join(".claude")).unwrap(); + for cwd in [ + h.home.clone(), + h.home.join(".claude"), + h.home.join("missing-directory"), + with_percent, + h.state_dir.clone(), + ] { + let (status, body) = h + .launch(&h.body_with(|body| body["cwd"] = cwd.display().to_string().into())) + .await; + assert_eq!(status, 400, "{}: {body}", cwd.display()); + } + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn the_workspace_is_checked_before_the_arguments() { + let h = Harness::start(Setup::default()).await; + let body = h.body_with(|body| { + body["cwd"] = h.home.display().to_string().into(); + body["args"] = serde_json::json!(["a&b"]); + }); + let (status, text) = h.launch(&body).await; + assert_eq!(status, 400); + assert_ne!(text, "argument contains a character cmd.exe interprets"); +} + +#[tokio::test(start_paused = true)] +async fn an_argument_cmd_exe_would_interpret_answers_400() { + let h = Harness::start(Setup::default()).await; + for argument in ["a&b", "50%", "x|y", "say \"hi\"", "!x", "a\nb"] { + let (status, body) = h + .launch(&h.body_with(|body| body["args"] = serde_json::json!([argument]))) + .await; + assert_eq!(status, 400, "{argument:?}: {body}"); + assert_eq!( + body, "argument contains a character cmd.exe interprets", + "{argument:?}" + ); + } + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn the_arguments_are_checked_before_the_program_is_resolved() { + let h = Harness::start(Setup { + claude_installed: false, + ..Setup::default() + }) + .await; + let (status, body) = h + .launch(&h.body_with(|body| body["args"] = serde_json::json!(["a&b"]))) + .await; + assert_eq!( + (status, body.as_str()), + (400, "argument contains a character cmd.exe interprets") + ); +} + +#[tokio::test(start_paused = true)] +async fn a_missing_native_install_answers_400() { + let h = Harness::start(Setup { + claude_installed: false, + ..Setup::default() + }) + .await; + let (status, body) = h.launch(&h.body()).await; + assert_eq!(status, 400, "{body}"); + assert!( + body.contains("Claude Code native install (claude.exe) not found"), + "{body}" + ); + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn an_unreadable_applied_configuration_answers_500() { + let h = Harness::start(Setup { + config: "controller:\n address: https://controller.example.com\n", + remote_config: Some("llmGateway: [this is not a gateway"), + credentials_must_not_be_reached: true, + ..Setup::default() + }) + .await; + let (status, body) = h.launch(&h.body()).await; + assert_eq!(status, 500, "{body}"); + assert!(body.starts_with("read applied configuration: "), "{body}"); + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn the_program_is_resolved_before_the_configuration_is_read() { + let h = Harness::start(Setup { + config: "controller:\n address: https://controller.example.com\n", + remote_config: Some("llmGateway: [this is not a gateway"), + claude_installed: false, + ..Setup::default() + }) + .await; + assert_eq!(h.launch(&h.body()).await.0, 400); +} + +// ------------------------------------------------------------ 409 preconditions + +async fn precondition(config: &'static str) -> (u16, String) { + let h = Harness::start(Setup { + config, + credentials_must_not_be_reached: true, + ..Setup::default() + }) + .await; + let answer = h.launch(&h.body()).await; + assert!( + h.credentials.calls().is_empty(), + "the credential source is not reached" + ); + h.assert_untouched(); + answer +} + +#[tokio::test(start_paused = true)] +async fn a_gateway_without_authentication_answers_409() { + let (status, body) = precondition( + "llmGateway:\n url: https://gateway.example.com\nprograms:\n claudeCode: {}\n", + ) + .await; + assert_eq!(status, 409, "{body}"); + assert!( + body.contains("llmGateway") && body.contains("authentication"), + "{body}" + ); +} + +#[tokio::test(start_paused = true)] +async fn no_gateway_at_all_answers_409() { + let (status, body) = precondition("programs:\n claudeCode: {}\n").await; + assert_eq!(status, 409, "{body}"); + assert!(body.contains("llmGateway"), "{body}"); +} + +#[tokio::test(start_paused = true)] +async fn a_configuration_without_claude_code_answers_409() { + let (status, body) = precondition( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\n", + ) + .await; + assert_eq!(status, 409, "{body}"); + assert!(body.contains("programs.claudeCode"), "{body}"); +} + +#[tokio::test(start_paused = true)] +async fn claude_code_that_does_not_use_the_gateway_answers_409() { + let (status, body) = precondition( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\nprograms:\n claudeCode:\n useLlmGateway: false\n", + ) + .await; + assert_eq!(status, 409, "{body}"); + assert!(body.contains("useLlmGateway"), "{body}"); +} + +#[tokio::test(start_paused = true)] +async fn subscription_sign_in_answers_409() { + let (status, body) = precondition( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\nprograms:\n claudeCode:\n auth: subscription\n", + ) + .await; + assert_eq!(status, 409, "{body}"); + assert!( + body.contains("subscription sign-in is not available inside the box in PR 1"), + "{body}" + ); +} + +#[tokio::test(start_paused = true)] +async fn a_top_level_sandbox_answers_409() { + let (status, body) = precondition( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\nprograms:\n claudeCode: {}\nsandbox: {}\n", + ) + .await; + assert_eq!(status, 409, "{body}"); + assert!( + body.contains("Claude Code's own sandbox is not available inside the box"), + "{body}" + ); +} + +// ------------------------------------------------------------------- credential + +#[tokio::test(start_paused = true)] +async fn a_credential_error_passes_through_and_nothing_is_started_or_written() { + for (status, text) in [ + (StatusCode::UNAUTHORIZED, "LLM gateway sign-in required"), + ( + StatusCode::FAILED_DEPENDENCY, + "daemon has no LLM gateway configured", + ), + (StatusCode::BAD_GATEWAY, "upstream refused"), + ] { + let h = Harness::start(Setup::default()).await; + h.credentials.fail_with(Some((status, text))); + + let (answered, body) = h.launch(&h.body()).await; + + assert_eq!(answered, status.as_u16(), "{body}"); + assert_eq!(body, text); + h.assert_untouched(); + assert!(h.listed().await.is_empty()); + } +} + +// ---------------------------------------------------------------- 201 and files + +#[tokio::test(start_paused = true)] +async fn a_launch_answers_201_with_the_launch_and_lists_it() { + let h = Harness::start(Setup::default()).await; + + let launched = h.launch_ok().await; + + assert!(is_launch_id(&launched.launch_id), "{}", launched.launch_id); + assert_eq!(launched.program, "claude-code"); + assert_eq!(launched.cwd, h.cwd); + assert_eq!( + launched.container_id, + format!("agentdesktop-{}", launched.launch_id) + ); + assert_eq!(launched.status, LaunchStatus::Running); + assert_eq!(launched.exit_code, None); + assert_eq!(launched.ended_at_unix_seconds, None); + assert!((BASE_TIME..BASE_TIME + 10).contains(&launched.started_at_unix_seconds)); + let expires = launched + .credential_expires_at_unix_seconds + .expect("credential expiry"); + assert!( + (BASE_TIME + CREDENTIAL_LIFETIME..BASE_TIME + CREDENTIAL_LIFETIME + 10).contains(&expires) + ); + assert_eq!(h.listed().await, vec![launched]); +} + +#[tokio::test(start_paused = true)] +async fn the_handler_asks_for_an_interactive_credential_for_claude_code() { + let h = Harness::start(Setup::default()).await; + h.launch_ok().await; + assert_eq!( + h.credentials.calls(), + vec![("claude-code".to_owned(), true)] + ); +} + +#[tokio::test(start_paused = true)] +async fn the_credential_and_request_files_exist_before_the_executor_starts() { + let h = Harness::start(Setup::default()).await; + + let launched = h.launch_ok().await; + + let spawns = h.executor.spawns(); + assert_eq!(spawns.len(), 1); + let spawn = &spawns[0]; + let dir = h.launch_dir(&launched.launch_id); + assert_eq!(spawn.launch_id, launched.launch_id); + assert_eq!(spawn.request, dir.join("request.json")); + assert_eq!(spawn.log, dir.join("wxc-exec.log")); + assert_eq!(spawn.token.as_deref(), Some("gateway-token-0")); + let request: serde_json::Value = + serde_json::from_str(spawn.request_json.as_deref().unwrap()).unwrap(); + assert_eq!( + request["containerId"], + format!("agentdesktop-{}", launched.launch_id) + ); + let env = env_map(&request); + assert!( + env["ANTHROPIC_BASE_URL"].starts_with("https://gateway.example.com"), + "{env:?}" + ); + assert!( + env["AGENTDESKTOP_CREDENTIAL_FILE"].ends_with(r"credential\token"), + "{env:?}" + ); + assert!( + !request.to_string().contains("gateway-token"), + "the request holds no secret" + ); + assert_eq!( + spawn.executor.display().to_string(), + h.executor_path(), + "the configured executor is started" + ); +} + +impl Harness { + fn executor_path(&self) -> String { + self.state_dir + .parent() + .unwrap() + .join("bin") + .join("wxc-exec.exe") + .display() + .to_string() + } +} + +#[tokio::test(start_paused = true)] +async fn the_pid_file_names_the_process_and_its_start_time() { + let h = Harness::start(Setup::default()).await; + let launched = h.launch_ok().await; + assert_eq!( + std::fs::read_to_string(h.launch_dir(&launched.launch_id).join("pid")).unwrap(), + "4242 987654321" + ); + assert_eq!( + h.token(&launched.launch_id).as_deref(), + Some("gateway-token-0") + ); +} + +#[tokio::test(start_paused = true)] +async fn launches_are_listed_by_start_time_ascending() { + let h = Harness::start(Setup::default()).await; + let first = h.launch_ok().await; + tokio::time::sleep(Duration::from_secs(30)).await; + let second = h.launch_ok().await; + assert_ne!(first.launch_id, second.launch_id); + assert!(second.started_at_unix_seconds > first.started_at_unix_seconds); + assert_eq!(h.listed().await, vec![first, second]); +} + +#[tokio::test(start_paused = true)] +async fn the_list_is_empty_before_any_launch() { + let h = Harness::start(Setup::default()).await; + assert_eq!(h.launches().await, (200, "[]".to_owned())); +} + +// ----------------------------------------------------------------- spawn errors + +#[tokio::test(start_paused = true)] +async fn a_spawn_failure_answers_500_with_the_os_error() { + let h = Harness::start(Setup::default()).await; + *h.executor.error.lock().unwrap() = + Some(io::Error::new(io::ErrorKind::NotFound, "no such file")); + + let (status, body) = h.launch(&h.body()).await; + + assert_eq!(status, 500, "{body}"); + assert!(body.starts_with("start wxc-exec: "), "{body}"); + assert!(body.contains("no such file"), "{body}"); + assert!( + h.listed().await.is_empty(), + "a launch that never started is not listed" + ); +} + +#[tokio::test(start_paused = true)] +async fn an_exit_within_three_seconds_answers_500_with_the_last_4_kib_of_the_log() { + let h = Harness::start(Setup::default()).await; + *h.executor.log_text.lock().unwrap() = format!("HEAD-MARKER{}TAIL-MARKER", "x".repeat(8_000)); + h.executor.exit_with(2); + + let (status, body) = h.launch(&h.body()).await; + + assert_eq!(status, 500, "{body}"); + assert!(body.contains("TAIL-MARKER"), "{body}"); + assert!( + !body.contains("HEAD-MARKER"), + "only the last 4 KiB are returned" + ); + assert!(body.len() < 5_000, "{}", body.len()); +} + +// ---------------------------------------------------------- lifecycle and refresh + +#[tokio::test(start_paused = true)] +async fn the_poller_marks_an_exit_stops_the_refresh_and_removes_the_launch_directory() { + let h = Harness::start(Setup::default()).await; + let launched = h.launch_ok().await; + assert!(h.launch_dir(&launched.launch_id).is_dir()); + + h.executor.exit_with(7); + tokio::time::sleep(Duration::from_secs(3)).await; + + let listed = h.listed().await; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].status, LaunchStatus::Exited); + assert_eq!(listed[0].exit_code, Some(7)); + let ended = listed[0].ended_at_unix_seconds.expect("an end time"); + assert!(ended >= listed[0].started_at_unix_seconds); + assert!(!h.launch_dir(&launched.launch_id).exists()); + + tokio::time::sleep(Duration::from_secs(2 * CREDENTIAL_LIFETIME)).await; + assert_eq!(h.credentials.calls().len(), 1, "no refresh after the exit"); +} + +#[tokio::test(start_paused = true)] +async fn the_refresh_task_rewrites_the_token_before_it_expires_without_prompting() { + let h = Harness::start(Setup::default()).await; + let launched = h.launch_ok().await; + let first_expiry = launched.credential_expires_at_unix_seconds.unwrap(); + assert_eq!( + h.token(&launched.launch_id).as_deref(), + Some("gateway-token-0") + ); + + tokio::time::sleep(Duration::from_secs(CREDENTIAL_LIFETIME - 40)).await; + + assert_eq!( + h.token(&launched.launch_id).as_deref(), + Some("gateway-token-1") + ); + let calls = h.credentials.calls(); + assert_eq!(calls[0], ("claude-code".to_owned(), true)); + assert_eq!( + calls[1], + ("claude-code".to_owned(), false), + "the refresh never prompts" + ); + let listed = h.listed().await; + assert!( + listed[0].credential_expires_at_unix_seconds.unwrap() > first_expiry, + "the launch reports the new expiry" + ); +} + +#[tokio::test(start_paused = true)] +async fn a_refresh_refused_by_policy_deletes_the_token_but_not_the_launch() { + let h = Harness::start(Setup::default()).await; + let launched = h.launch_ok().await; + h.credentials.fail_with(Some(( + StatusCode::CONFLICT, + "programs.claudeCode must use the gateway", + ))); + + tokio::time::sleep(Duration::from_secs(CREDENTIAL_LIFETIME - 40)).await; + + assert_eq!(h.token(&launched.launch_id), None); + assert_eq!(h.listed().await[0].status, LaunchStatus::Running); +} + +#[tokio::test(start_paused = true)] +async fn a_gateway_error_keeps_the_token_until_it_expires_then_deletes_it() { + let h = Harness::start(Setup::default()).await; + let launched = h.launch_ok().await; + h.credentials + .fail_with(Some((StatusCode::BAD_GATEWAY, "upstream down"))); + + tokio::time::sleep(Duration::from_secs(CREDENTIAL_LIFETIME - 40)).await; + assert_eq!( + h.token(&launched.launch_id).as_deref(), + Some("gateway-token-0"), + "a transient error keeps the still-valid token" + ); + + tokio::time::sleep(Duration::from_secs(120)).await; + assert_eq!( + h.token(&launched.launch_id), + None, + "an expired token is removed" + ); +} + +// -------------------------------------------------------------------- body errors + +#[tokio::test(start_paused = true)] +async fn body_errors_are_the_ones_axum_produces() { + let h = Harness::start(Setup::default()).await; + let url = format!("{}/v1/launch", h.base); + + let not_json = h + .client + .post(&url) + .header("content-type", "text/plain") + .body("program=claude-code") + .send() + .await + .unwrap(); + assert_eq!(not_json.status().as_u16(), 415); + + let malformed = h + .client + .post(&url) + .header("content-type", "application/json") + .body("{not json") + .send() + .await + .unwrap(); + assert_eq!(malformed.status().as_u16(), 400); + + let missing_cwd = h + .client + .post(&url) + .json(&serde_json::json!({"program": "claude-code", "args": []})) + .send() + .await + .unwrap(); + assert_eq!(missing_cwd.status().as_u16(), 422); + h.assert_untouched(); +} + +// ----------------------------------------------------------------- Windows only + +#[cfg(windows)] +#[test] +fn the_current_process_has_a_user_sid_outside_an_app_container() { + let peer = peer_identity(std::process::id()).expect("read the identity of this process"); + let sid = peer.user_sid.expect("a user SID"); + assert!(sid.starts_with("S-1-5-"), "{sid}"); + assert!(!peer.app_container); +} + +#[cfg(windows)] +#[test] +fn the_user_mode_pipe_security_descriptor_for_the_daemon_sid_is_valid() { + let sid = peer_identity(std::process::id()).unwrap().user_sid.unwrap(); + crate::windows_security::SecurityDescriptor::from_sddl(&user_mode_pipe_sddl(&sid)) + .expect("the SDDL parses"); +} + +#[test] +fn copy_log_lines_uses_raw_byte_offset_not_lossy_string_offset() { + use std::io::Write; + + // A non-UTF-8 byte before a newline: the file is 5 bytes, but the lossy + // string is 7 bytes (U+FFFD is 3 bytes in UTF-8). The returned offset + // must be the raw byte position (5) so the next read starts at the + // correct position. + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("test.log"); + let mut file = std::fs::File::create(&log).unwrap(); + file.write_all(b"caf\xe9\n").unwrap(); + drop(file); + + let new_offset = super::copy_log_lines("test-launch", &log, 0); + assert_eq!(new_offset, 5, "offset should be raw byte count"); + + // Append another line and verify the second read starts at the correct position. + let mut file = std::fs::OpenOptions::new().append(true).open(&log).unwrap(); + file.write_all(b"second\n").unwrap(); + drop(file); + + let final_offset = super::copy_log_lines("test-launch", &log, new_offset); + assert_eq!(final_offset, 12, "second read from correct offset"); +} diff --git a/crates/agent/src/launch/writer_tests.rs b/crates/agent/src/launch/writer_tests.rs new file mode 100644 index 00000000..cc97de1f --- /dev/null +++ b/crates/agent/src/launch/writer_tests.rs @@ -0,0 +1,593 @@ +//! Writer-owned tests for the launch module: what the spec-derived baseline +//! (`tests.rs`) leaves to the implementation. + +use std::path::{Path, PathBuf}; + +use super::*; + +#[test] +fn the_user_mode_pipe_admits_system_and_the_daemon_user_only() { + assert_eq!( + user_mode_pipe_sddl("S-1-5-21-1-2-3-1001"), + "D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1001)" + ); +} + +/// The read-only grant for `credential\` is only honoured when the directory +/// exists, so it is created before the request is built. +#[test] +fn the_credential_directory_exists_before_the_request_is_written() { + let temp = tempfile::tempdir().unwrap(); + let launch_dir = temp.path().join("launches").join("0123456789abcdef"); + + create_launch_dir(&launch_dir, "gateway-token").unwrap(); + + assert!(launch_dir.join("credential").is_dir()); + assert_eq!( + std::fs::read_to_string(launch_dir.join("credential").join("token")).unwrap(), + "gateway-token" + ); + assert!(launch_dir.join("tmp").is_dir()); + assert!( + !launch_dir.join("request.json").exists(), + "the request is built after the directories it grants exist" + ); +} + +struct OnlyFiles(Vec); + +impl ProgramFs for OnlyFiles { + fn canonicalize(&self, path: &Path) -> std::io::Result { + Ok(path.to_owned()) + } + + fn is_file(&self, path: &Path) -> bool { + self.0.contains(&path.to_owned()) + } +} + +/// A trailing `;` in PATH is an empty directory entry; it must not make every +/// path count as "under a PATH directory". +#[test] +fn an_empty_path_entry_does_not_admit_a_claude_exe_anywhere() { + let home = PathBuf::from("/home/dev"); + let elsewhere = PathBuf::from("/opt/other/claude.exe"); + let fs = OnlyFiles(vec![elsewhere.clone()]); + let mut dirs = vec![PathBuf::new(), PathBuf::from("/opt/other")]; + assert_eq!( + resolve_program(&home, &dirs, &fs), + Ok(elsewhere), + "the real PATH entry still resolves" + ); + dirs.pop(); + assert!( + resolve_program(&home, &dirs, &fs).is_err(), + "the empty entry alone admits nothing" + ); +} + +#[test] +fn the_executable_configuration_rule_covers_git_commondir_and_the_ide_directory_is_denied() { + use std::collections::{BTreeMap, BTreeSet}; + + let existing: BTreeSet = [r"C:\ws\.git", r"C:\ws\.git\HEAD"] + .into_iter() + .map(str::to_owned) + .collect(); + let directories: BTreeSet = [r"C:\ws\.git"].into_iter().map(str::to_owned).collect(); + let exists = |path: &Path| existing.contains(path.to_str().unwrap()); + let is_dir = |path: &Path| directories.contains(path.to_str().unwrap()); + let request = build_request(&LaunchInput { + launch_id: "0123456789abcdef".into(), + executable: PathBuf::from(r"C:\Users\dev\.local\bin\claude.exe"), + client_exe_dir: PathBuf::from(r"C:\Program Files\Agentdesktop"), + cwd: PathBuf::from(r"C:\ws"), + args: vec![], + home: PathBuf::from(r"C:\Users\dev"), + state_dir: PathBuf::from(r"C:\Users\dev\.local\state\agentdesktop"), + launch_dir: PathBuf::from( + r"C:\Users\dev\.local\state\agentdesktop\launches\0123456789abcdef", + ), + git_dir: None, + program_files_claude: None, + gateway_url: "https://gateway.example.com/".into(), + base_env: BTreeMap::new(), + exists: &exists, + is_dir: &is_dir, + }); + let denied = request.filesystem.denied_paths; + assert!(denied.contains(&r"C:\ws\.git\commondir".to_owned())); + assert!(denied.contains(&r"C:\ws\.git\config".to_owned())); + assert!(denied.contains(&r"C:\Users\dev\.claude\ide".to_owned())); +} + +#[cfg(windows)] +#[test] +fn the_current_process_is_alive_only_with_its_own_start_time() { + use std::os::windows::io::AsRawHandle; + + let pid = std::process::id(); + let handle = std::process::Command::new("cmd.exe") + .args(["/d", "/c", "exit 0"]) + .spawn() + .map(|mut child| { + let _ = child.wait(); + }); + assert!(handle.is_ok()); + // The daemon's own process: alive with the true creation time, dead with + // any other (a reused pid is not the launch). + let current = std::process::Command::new("cmd.exe") + .args(["/d", "/c", "ping -n 3 127.0.0.1 >NUL"]) + .spawn() + .unwrap(); + let start = host::windows::creation_time(current.as_raw_handle() as _).unwrap(); + assert!(host::process_alive(current.id(), start)); + assert!(!host::process_alive(current.id(), start.wrapping_add(1))); + assert!(!host::process_alive(pid, 0)); +} + +// ------------------------------------------------- handler path (regression) + +mod handler { + //! `POST /v1/launch` end to end with an `exists` that sees `\`-joined + //! paths the way Windows does: the credential directory must be granted. + + use std::{ + collections::BTreeMap, + io, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + }; + + use agentdesktop_core::model::{LaunchRequest, LlmGatewayCredential}; + use async_trait::async_trait; + use axum::{Json, extract::State, http::StatusCode}; + + use super::super::*; + + /// Maps a request path (`\`-joined, possibly mixed) back to the real + /// filesystem of this platform. + fn real(path: &Path) -> PathBuf { + PathBuf::from( + path.to_string_lossy() + .replace('\\', std::path::MAIN_SEPARATOR_STR), + ) + } + + struct Host { + home: PathBuf, + state_dir: PathBuf, + } + + struct Fs; + + impl CwdFs for Fs { + fn canonicalize(&self, p: &Path) -> io::Result { + std::fs::canonicalize(p) + } + fn kind(&self, p: &Path) -> io::Result { + Ok(PathKind { + is_dir: std::fs::metadata(p)?.is_dir(), + }) + } + } + + impl ProgramFs for Fs { + fn canonicalize(&self, p: &Path) -> io::Result { + std::fs::canonicalize(p) + } + fn is_file(&self, p: &Path) -> bool { + p.is_file() + } + } + + static FS: Fs = Fs; + + impl LaunchHost for Host { + fn home(&self) -> PathBuf { + self.home.clone() + } + fn path_dirs(&self) -> Vec { + vec![] + } + fn base_env(&self) -> BTreeMap { + BTreeMap::new() + } + fn cwd_context(&self) -> CwdContext { + CwdContext { + home: self.home.clone(), + state_dir: self.state_dir.clone(), + system_dirs: vec![], + denied: vec![], + app_data: vec![], + } + } + fn cwd_fs(&self) -> &dyn CwdFs { + &FS + } + fn program_fs(&self) -> &dyn ProgramFs { + &FS + } + fn exists(&self, path: &Path) -> bool { + real(path).exists() + } + fn is_dir(&self, path: &Path) -> bool { + real(path).is_dir() + } + fn git_dir(&self) -> Option { + None + } + fn program_files_claude(&self) -> Option { + None + } + fn client_exe_dir(&self) -> PathBuf { + self.state_dir.join("bin") + } + fn now(&self) -> u64 { + 1_700_000_000 + } + } + + struct Credentials; + + #[async_trait] + impl CredentialSource for Credentials { + async fn credential( + &self, + _client_id: &str, + _interactive: bool, + ) -> Result { + Ok(LlmGatewayCredential { + credential: "gateway-token".into(), + expires_at_unix_seconds: 1_700_003_600, + }) + } + } + + pub(super) struct Recorder { + pub(super) request_json: Mutex>, + pub(super) home_claude_existed: Mutex, + pub(super) home: PathBuf, + } + + struct Running; + + impl LaunchChild for Running { + fn try_wait(&mut self) -> io::Result> { + Ok(None) + } + fn pid(&self) -> u32 { + 1 + } + fn start_time(&self) -> u64 { + 1 + } + } + + impl Executor for Recorder { + fn spawn( + &self, + _launch_id: &str, + _executor: &Path, + request: &Path, + _log: &Path, + ) -> io::Result> { + // Record whether ~/.claude existed at spawn time (n1: ordering). + *self.home_claude_existed.lock().unwrap() = self.home.join(".claude").is_dir(); + *self.request_json.lock().unwrap() = std::fs::read_to_string(request).ok(); + Ok(Box::new(Running)) + } + } + + /// A launch state over a fake host rooted in temp directories. + pub(super) fn state(home: PathBuf, state_dir: PathBuf, executor: Arc) -> LaunchState { + let daemon_info = serde_json::from_value(serde_json::json!({ + "version": "test", "scope": "user", "configPath": "", "stateDirectory": "", + "inventoryInterval": "60s", "controller": null, + })) + .unwrap(); + let (sender, discovery) = + tokio::sync::watch::channel(Arc::new(agentdesktop_core::model::Discovery { + agents: vec![], + model_runtimes: vec![], + })); + std::mem::forget(sender); + let app = api::AppState { + config: agentdesktop_core::config::parse_daemon( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\nprograms:\n claudeCode: {}\n", + ) + .unwrap(), + daemon_info, + discovery, + enrollment: crate::enrollment::EnrollmentState::new(false), + controller_status: None, + state_dir: state_dir.clone(), + oidc_callback_listen: None, + telemetry: None, + logout: None, + usage: None, + programs: None, + intercept: None, + }; + LaunchState { + app, + executor, + credentials: Arc::new(Credentials), + host: Arc::new(Host { home, state_dir }), + registry: LaunchRegistry::new(), + availability: Some(ContainmentInfo { + executor: "wxc-exec.exe".into(), + available: true, + error: None, + }), + daemon_sid: None, + } + } + + #[tokio::test(start_paused = true)] + async fn the_credential_directory_is_granted_read_only_to_the_box() { + let temp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(temp.path()).unwrap(); + let home = root.join("home"); + let cwd = home.join("work"); + let state_dir = root.join("state"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(home.join(".local").join("bin")).unwrap(); + std::fs::write(home.join(".local").join("bin").join("claude.exe"), b"MZ").unwrap(); + std::fs::create_dir_all(&state_dir).unwrap(); + let recorder = Arc::new(Recorder { + request_json: Mutex::new(None), + home_claude_existed: Mutex::new(false), + home: home.clone(), + }); + let state = state(home.clone(), state_dir.clone(), recorder.clone()); + + let (status, Json(launch)) = launch( + State(state), + Json(LaunchRequest { + program: "claude-code".into(), + cwd: cwd.clone(), + args: vec![], + }), + ) + .await + .expect("the launch succeeds"); + + assert_eq!(status, StatusCode::CREATED); + let request: serde_json::Value = + serde_json::from_str(recorder.request_json.lock().unwrap().as_deref().unwrap()) + .unwrap(); + let launch_dir = state_dir.join("launches").join(&launch.launch_id); + let credential_dir = launch_dir + .join("credential") + .to_string_lossy() + .replace('/', "\\"); + let readonly: Vec = request["filesystem"]["readonlyPaths"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_owned()) + .collect(); + assert!( + readonly.contains(&credential_dir), + "credential directory {credential_dir} must be granted; got {readonly:?}" + ); + assert!(launch_dir.join("credential").join("token").is_file()); + // `~\.claude` did not exist before the launch; its entries need it. + assert!( + *recorder.home_claude_existed.lock().unwrap(), + "~\\.claude did not exist when the executor started" + ); + assert!( + home.join(".claude").is_dir(), + "~\\.claude is created before the request" + ); + } + + /// The user's git configuration is granted read-only when it exists (m-a). + #[tokio::test] + async fn the_git_configuration_is_granted_when_it_exists() { + use std::collections::{BTreeMap, BTreeSet}; + + let existing: BTreeSet = [r"C:\Users\dev\.gitconfig"] + .into_iter() + .map(str::to_owned) + .collect(); + let exists = |path: &Path| existing.contains(path.to_str().unwrap()); + let is_dir = |_: &Path| false; + let request = build_request(&LaunchInput { + launch_id: "0123456789abcdef".into(), + executable: PathBuf::from(r"C:\Users\dev\.local\bin\claude.exe"), + client_exe_dir: PathBuf::from(r"C:\Program Files\Agentdesktop"), + cwd: PathBuf::from(r"C:\ws"), + args: vec![], + home: PathBuf::from(r"C:\Users\dev"), + state_dir: PathBuf::from(r"C:\Users\dev\.local\state\agentdesktop"), + launch_dir: PathBuf::from( + r"C:\Users\dev\.local\state\agentdesktop\launches\0123456789abcdef", + ), + git_dir: None, + program_files_claude: None, + gateway_url: "https://gateway.example.com/".into(), + base_env: BTreeMap::new(), + exists: &exists, + is_dir: &is_dir, + }); + let readonly = &request.filesystem.readonly_paths; + assert!( + readonly.contains(&r"C:\Users\dev\.gitconfig".to_owned()), + "~\\.gitconfig is granted when it exists; got {readonly:?}" + ); + assert!( + !readonly.contains(&r"C:\Users\dev\.config\git\config".to_owned()), + "~\\.config\\git\\config is not in readonlyPaths when it does not exist" + ); + assert!( + !request + .filesystem + .denied_paths + .contains(&r"C:\Users\dev\.config\git\config".to_owned()), + "~\\.config\\git\\config is not in deniedPaths when it does not exist" + ); + } +} + +#[test] +fn a_canonical_workspace_with_a_percent_sign_is_rejected() { + use std::collections::BTreeMap; + + struct Mapped(BTreeMap); + impl CwdFs for Mapped { + fn canonicalize(&self, p: &Path) -> std::io::Result { + Ok(self.0.get(p).cloned().unwrap_or_else(|| p.to_owned())) + } + fn kind(&self, _p: &Path) -> std::io::Result { + Ok(PathKind { is_dir: true }) + } + } + let ctx = CwdContext { + home: PathBuf::from("/home/dev"), + state_dir: PathBuf::from("/state"), + system_dirs: vec![], + denied: vec![], + app_data: vec![], + }; + let fs = Mapped( + [( + PathBuf::from("/home/dev/link"), + PathBuf::from("/home/dev/50%off"), + )] + .into_iter() + .collect(), + ); + assert!(validate_cwd(Path::new("/home/dev/link"), &ctx, &fs).is_err()); + assert!(validate_cwd(Path::new("/home/dev/plain"), &ctx, &fs).is_ok()); +} + +#[tokio::test(start_paused = true)] +async fn a_resolved_program_path_with_a_percent_sign_is_rejected() { + use std::sync::{Arc, Mutex}; + + use agentdesktop_core::model::LaunchRequest; + use axum::{Json, extract::State, http::StatusCode}; + + // The home (and so `\.local\bin\claude.exe`) carries a `%`; the + // workspace lives outside it, so the workspace rules pass first. + let temp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(temp.path()).unwrap(); + let home = root.join("h%me"); + let cwd = root.join("work"); + let state_dir = root.join("state"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(home.join(".local").join("bin")).unwrap(); + std::fs::write(home.join(".local").join("bin").join("claude.exe"), b"MZ").unwrap(); + std::fs::create_dir_all(&state_dir).unwrap(); + let state = handler::state( + home.clone(), + state_dir, + Arc::new(handler::Recorder { + request_json: Mutex::new(None), + home_claude_existed: Mutex::new(false), + home: home.clone(), + }), + ); + + let error = launch( + State(state), + Json(LaunchRequest { + program: "claude-code".into(), + cwd, + args: vec![], + }), + ) + .await + .expect_err("a program path cmd.exe would interpret is refused"); + + assert_eq!(error.0, StatusCode::BAD_REQUEST); + assert_eq!( + error.1, + "program path contains a character cmd.exe interprets" + ); +} + +#[test] +fn the_environment_is_a_list_of_key_value_entries_and_a_missing_workspace_claude_folder_is_denied_whole() + { + use std::collections::BTreeMap; + + let exists = |_: &Path| false; + let is_dir = |_: &Path| false; + let request = build_request(&LaunchInput { + launch_id: "0123456789abcdef".into(), + executable: PathBuf::from(r"C:\Users\dev\.local\bin\claude.exe"), + client_exe_dir: PathBuf::from(r"C:\Program Files\Agentdesktop"), + cwd: PathBuf::from(r"C:\ws"), + args: vec![], + home: PathBuf::from(r"C:\Users\dev"), + state_dir: PathBuf::from(r"C:\Users\dev\.local\state\agentdesktop"), + launch_dir: PathBuf::from( + r"C:\Users\dev\.local\state\agentdesktop\launches\0123456789abcdef", + ), + git_dir: None, + program_files_claude: None, + gateway_url: "https://gateway.example.com/".into(), + base_env: BTreeMap::from([("SystemRoot".to_owned(), r"C:\Windows".to_owned())]), + exists: &exists, + is_dir: &is_dir, + }); + // MXC 1.0.0 takes `env` as `KEY=VALUE` strings, not an object (lab L2). + assert!(request.process.env.iter().all(|entry| entry.contains('='))); + assert!( + request + .process + .env + .contains(&"TERM=xterm-256color".to_owned()) + ); + let json = serde_json::to_value(&request).unwrap(); + assert!(json["process"]["env"].is_array()); + // MXC refuses a denied path under a missing folder (lab L2, 0x80070003): + // the folder is denied instead of its entries. + let denied = request.filesystem.denied_paths; + assert!(denied.contains(&r"C:\ws\.claude".to_owned())); + assert!( + !denied + .iter() + .any(|path| path.starts_with(r"C:\ws\.claude\")) + ); + // Entries whose parent is granted read-write stay individual. + assert!(denied.contains(&r"C:\Users\dev\.claude\settings.json".to_owned())); +} + +#[test] +fn the_box_environment_has_home_set_to_the_daemon_home_when_daemon_env_has_no_home() { + use std::collections::BTreeMap; + + let exists = |_: &Path| false; + let is_dir = |_: &Path| false; + let request = build_request(&LaunchInput { + launch_id: "0123456789abcdef".into(), + executable: PathBuf::from(r"C:\Users\dev\.local\bin\claude.exe"), + client_exe_dir: PathBuf::from(r"C:\Program Files\Agentdesktop"), + cwd: PathBuf::from(r"C:\ws"), + args: vec![], + home: PathBuf::from(r"C:\Users\dev"), + state_dir: PathBuf::from(r"C:\Users\dev\.local\state\agentdesktop"), + launch_dir: PathBuf::from( + r"C:\Users\dev\.local\state\agentdesktop\launches\0123456789abcdef", + ), + git_dir: None, + program_files_claude: None, + gateway_url: "https://gateway.example.com/".into(), + base_env: BTreeMap::new(), + exists: &exists, + is_dir: &is_dir, + }); + assert!( + request + .process + .env + .contains(&r"HOME=C:\Users\dev".to_owned()), + "HOME must be set in box env even when daemon env has no HOME; got {:?}", + request.process.env + ); +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 1bf0f6de..61fa417f 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -7,6 +7,7 @@ pub mod gateway_oidc; mod github_oauth; pub mod identity; mod inventory_watch; +pub mod launch; mod llm_proxy; pub mod oidc; mod program_choice; diff --git a/crates/agent/src/llm_proxy.rs b/crates/agent/src/llm_proxy.rs index 5f310de6..63708c94 100644 --- a/crates/agent/src/llm_proxy.rs +++ b/crates/agent/src/llm_proxy.rs @@ -1428,7 +1428,7 @@ mod tests { daemon_info: DaemonInfo { version: "test".into(), scope: DaemonScope::User, config_path: String::new(), state_directory: String::new(), inventory_interval: Duration::from_secs(60), controller: None, llm_proxy: None, - usage_receiver: None }, + usage_receiver: None, containment: None }, discovery, enrollment: EnrollmentState::new(false), state_dir: dir.path().to_owned(), oidc_callback_listen: None, telemetry: None, logout: None, usage: None, programs: None, @@ -2059,6 +2059,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + containment: None, }, discovery, enrollment: EnrollmentState::new(true), @@ -2449,6 +2450,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + containment: None, }, discovery, enrollment: EnrollmentState::new(false), @@ -2628,6 +2630,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + containment: None, }, discovery, enrollment: EnrollmentState::new(false), @@ -2698,6 +2701,7 @@ mod tests { controller: None, llm_proxy: None, usage_receiver: None, + containment: None, }, discovery, enrollment: EnrollmentState::new(false), diff --git a/crates/agent/src/provider/README.md b/crates/agent/src/provider/README.md index 32c573ac..257afccb 100644 --- a/crates/agent/src/provider/README.md +++ b/crates/agent/src/provider/README.md @@ -81,6 +81,7 @@ files are not scanned until the adapter imports them into a Pi-owned file. | Sandbox configuration | ✅ | ◯ | ✅ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | | Tool-use telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | | Session-start telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | +| Sandboxed launch (Windows) | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | - ◯ describes a gap in agentdesktop; it does not mean the upstream provider cannot support the feature. diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 29dd26bd..0370ffea 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -15,3 +15,6 @@ serde.workspace = true serde_json.workspace = true tokio.workspace = true tracing.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 53635476..2f3217b5 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -160,11 +160,222 @@ where Ok(body) } +/// A non-2xx answer from the daemon, as the error of [`post_json_response`]. +/// `Display` is `daemon returned : `. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusError { + pub status: u16, + pub body: String, +} + +impl std::fmt::Display for StatusError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let reason = hyper::StatusCode::from_u16(self.status) + .ok() + .and_then(|status| status.canonical_reason()) + .unwrap_or(""); + write!( + formatter, + "daemon returned {} {reason}: {}", + self.status, self.body + ) + } +} + +impl std::error::Error for StatusError {} + +/// Sends a POST with a JSON body and decodes the JSON response. A non-2xx +/// answer is an error that carries a [`StatusError`]. +pub async fn post_json_response(endpoint: &Path, path: &str, body: &B) -> anyhow::Result +where + B: Serialize, + T: DeserializeOwned, +{ + let stream = connect(endpoint).await?; + let (mut sender, connection) = http1::handshake(TokioIo::new(stream)) + .await + .context("start HTTP connection")?; + + tokio::spawn(async move { + if let Err(error) = connection.await { + tracing::debug!(%error, "local HTTP connection failed"); + } + }); + + let encoded = serde_json::to_vec(body).context("encode request body")?; + let request = Request::builder() + .method("POST") + .uri(path) + .header("host", "localhost") + .header("content-type", "application/json") + .body(Full::new(Bytes::from(encoded)))?; + let response = sender.send_request(request).await.context("send request")?; + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .context("read response")? + .to_bytes(); + if !status.is_success() { + return Err(StatusError { + status: status.as_u16(), + body: String::from_utf8_lossy(&bytes).into_owned(), + } + .into()); + } + serde_json::from_slice(&bytes).context("decode daemon response") +} + #[cfg(test)] mod tests { use std::path::Path; - use super::connect_error; + use super::{StatusError, connect_error}; + + #[test] + fn status_error_display_names_the_status_and_carries_the_body() { + let error = StatusError { + status: 409, + body: "executor not found: C:\\missing".into(), + }; + assert_eq!( + error.to_string(), + "daemon returned 409 Conflict: executor not found: C:\\missing" + ); + assert_eq!( + StatusError { + status: 404, + body: String::new(), + } + .to_string(), + "daemon returned 404 Not Found: " + ); + } + + #[cfg(unix)] + mod unix { + use std::path::Path; + + use serde::{Deserialize, Serialize}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixListener, + }; + + use super::super::{StatusError, post_json_response}; + + #[derive(Serialize)] + struct Body { + program: String, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct Answer { + id: String, + } + + /// Accepts one connection, returns the raw request text, answers with + /// `status_line` and `body`. + async fn serve_once( + socket: &Path, + status_line: &'static str, + body: &'static str, + ) -> tokio::task::JoinHandle { + let listener = UnixListener::bind(socket).unwrap(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let read = stream.read(&mut buffer).await.unwrap(); + request.extend_from_slice(&buffer[..read]); + let text = String::from_utf8_lossy(&request).to_string(); + if let Some((head, rest)) = text.split_once("\r\n\r\n") { + let length = head + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|value| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + if rest.len() >= length { + break; + } + } + if read == 0 { + break; + } + } + let response = format!( + "HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8_lossy(&request).to_string() + }) + } + + #[tokio::test] + async fn post_json_response_sends_the_json_body_and_decodes_the_answer() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("daemon.sock"); + let server = serve_once(&socket, "201 Created", r#"{"id":"abc"}"#).await; + + let answer: Answer = post_json_response( + &socket, + "/v1/launch", + &Body { + program: "claude-code".into(), + }, + ) + .await + .unwrap(); + + assert_eq!(answer, Answer { id: "abc".into() }); + let request = server.await.unwrap(); + assert!(request.starts_with("POST /v1/launch HTTP/1.1"), "{request}"); + assert!( + request + .to_ascii_lowercase() + .contains("content-type: application/json"), + "{request}" + ); + assert!( + request.ends_with(r#"{"program":"claude-code"}"#), + "{request}" + ); + } + + #[tokio::test] + async fn post_json_response_reports_a_non_2xx_answer_as_a_status_error() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("daemon.sock"); + let server = serve_once(&socket, "409 Conflict", "executor not found: x").await; + + let error = post_json_response::<_, Answer>( + &socket, + "/v1/launch", + &Body { + program: "claude-code".into(), + }, + ) + .await + .unwrap_err(); + + let status = error + .downcast_ref::() + .unwrap_or_else(|| panic!("a StatusError in {error:#}")); + assert_eq!(status.status, 409); + assert_eq!(status.body, "executor not found: x"); + assert_eq!( + error.to_string(), + "daemon returned 409 Conflict: executor not found: x" + ); + server.await.unwrap(); + } + } #[test] fn connection_error_suggests_checking_the_daemon() { diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 83c7c126..9605ee78 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -104,6 +104,9 @@ pub struct DaemonStartupConfig { /// Usage collection without a controller, from a gateway on this machine. #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, + /// Sandboxed launch (`agentdesktop run`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub containment: Option, /// Opt-in interval between periodic re-applies of the current /// configuration, which repair drift (a managed file deleted or edited by /// hand, a fixed conflict, a loosened mode) without rewriting anything @@ -130,6 +133,48 @@ pub struct LlmProxyStartupConfig { pub client_id: Option, } +/// Sandboxed launch settings. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ContainmentStartupConfig { + /// Windows only. Absolute path to MXC `wxc-exec.exe`; no default. Read at + /// startup only (restart the daemon after changing it). Requires user + /// mode; ignored with `--once` and `--dry-run`. + pub executor: PathBuf, +} + +/// Checks `daemon.containment`. With `windows == true` the executor must be an +/// absolute Windows path (`X:\...` or `\\server\share\...`, judged on the +/// string) to a file whose name ends in `.exe`, case-insensitively. +pub fn validate_containment_startup( + cfg: &ContainmentStartupConfig, + windows: bool, +) -> Result<(), String> { + if !windows { + return Ok(()); + } + let text = cfg.executor.to_string_lossy(); + let bytes = text.as_bytes(); + let drive_absolute = + bytes.len() > 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\'; + let unc_absolute = text.starts_with("\\\\") + && text[2..] + .split('\\') + .filter(|part| !part.is_empty()) + .count() + >= 3; + let exe = text + .rsplit('\\') + .next() + .is_some_and(|name| name.to_ascii_lowercase().ends_with(".exe")); + if (drive_absolute || unc_absolute) && exe { + Ok(()) + } else { + Err("daemon.containment.executor must be an absolute path to wxc-exec.exe".to_owned()) + } +} + /// Token and cost collection for a daemon without a controller. agentgateway /// on this machine exports its access logs (`frontendPolicies.accessLog.otlp`) /// to the daemon, which keeps this device's usage for the desktop app. @@ -1359,6 +1404,13 @@ fn parse_local_daemon(contents: &str) -> anyhow::Result { anyhow::bail!("daemon.usage.listen must be a loopback address"); } } + if let Some(containment) = config + .daemon + .as_ref() + .and_then(|daemon| daemon.containment.as_ref()) + { + validate_containment_startup(containment, cfg!(windows)).map_err(anyhow::Error::msg)?; + } validate_daemon( config.llm_gateway.as_ref(), config.sandbox.as_ref(), @@ -2348,4 +2400,132 @@ programs: ) .expect("each daemon manages the programs supported in its mode"); } + + fn containment(executor: &str) -> super::ContainmentStartupConfig { + super::ContainmentStartupConfig { + executor: executor.into(), + } + } + + const CONTAINMENT_EXECUTOR_ERROR: &str = + "daemon.containment.executor must be an absolute path to wxc-exec.exe"; + + #[test] + fn containment_executor_on_windows_must_be_an_absolute_path_to_an_exe() { + for executor in [ + r"wxc-exec.exe", + r"mxc\wxc-exec.exe", + r"\mxc\wxc-exec.exe", + r"C:wxc-exec.exe", + ] { + assert_eq!( + super::validate_containment_startup(&containment(executor), true), + Err(CONTAINMENT_EXECUTOR_ERROR.to_owned()), + "{executor} is not absolute" + ); + } + } + + #[test] + fn containment_executor_on_windows_accepts_drive_and_unc_paths_and_any_exe_case() { + for executor in [ + r"C:\mxc\x64\wxc-exec.exe", + r"C:\mxc\x64\WXC-EXEC.EXE", + r"C:\mxc\x64\wxc-exec.Exe", + r"\\server\share\mxc\wxc-exec.exe", + ] { + assert_eq!( + super::validate_containment_startup(&containment(executor), true), + Ok(()), + "{executor}" + ); + } + } + + #[test] + fn containment_executor_on_windows_rejects_a_cmd_file() { + assert_eq!( + super::validate_containment_startup(&containment(r"C:\mxc\wxc-exec.cmd"), true), + Err(CONTAINMENT_EXECUTOR_ERROR.to_owned()) + ); + assert_eq!( + super::validate_containment_startup(&containment(r"C:\mxc\wxc-exec"), true), + Err(CONTAINMENT_EXECUTOR_ERROR.to_owned()) + ); + } + + #[test] + fn containment_executor_is_not_checked_off_windows() { + for executor in ["wxc-exec.exe", "relative/wxc-exec.cmd", "/opt/mxc/wxc-exec"] { + assert_eq!( + super::validate_containment_startup(&containment(executor), false), + Ok(()), + "{executor}" + ); + } + } + + #[test] + fn daemon_containment_is_a_startup_setting_with_an_executor_path() { + let config = super::parse_local_daemon( + "daemon:\n user: true\n containment:\n executor: 'C:\\mxc\\wxc-exec.exe'\n", + ) + .expect("a containment block parses off Windows"); + let containment = config + .daemon + .unwrap() + .containment + .expect("containment block"); + assert_eq!( + containment.executor, + std::path::Path::new(r"C:\mxc\wxc-exec.exe") + ); + } + + #[test] + fn daemon_containment_rejects_unknown_fields_and_a_missing_executor() { + assert!( + super::parse_local_daemon( + "daemon:\n containment:\n executor: /x/wxc-exec\n timeout: 5s\n" + ) + .is_err() + ); + assert!(super::parse_local_daemon("daemon:\n containment: {}\n").is_err()); + } + + #[test] + fn daemon_containment_is_local_only_and_absent_by_default() { + let yaml = "daemon:\n containment:\n executor: 'C:\\mxc\\wxc-exec.exe'\n"; + assert!( + parse_daemon(yaml) + .unwrap_err() + .to_string() + .contains("only allowed in the local") + ); + let startup = super::DaemonStartupConfig::default(); + assert!(startup.containment.is_none()); + assert!( + serde_json::to_value(&startup) + .unwrap() + .get("containment") + .is_none(), + "an unset containment block is not serialized" + ); + } + + #[cfg(windows)] + #[test] + fn parse_rejects_a_relative_containment_executor_on_windows() { + let error = + super::parse_local_daemon("daemon:\n containment:\n executor: wxc-exec.exe\n") + .expect_err("relative executor"); + assert!( + error.to_string().contains(CONTAINMENT_EXECUTOR_ERROR), + "{error:#}" + ); + super::parse_local_daemon( + "daemon:\n containment:\n executor: 'C:\\mxc\\wxc-exec.exe'\n", + ) + .expect("absolute executor"); + } } diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 20bd1734..f436b91f 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -141,6 +141,59 @@ pub struct DaemonInfo { /// `daemon.usage` is configured. #[serde(default, skip_serializing_if = "Option::is_none")] pub usage_receiver: Option, + /// Sandboxed launch (`agentdesktop run`), when `daemon.containment` is + /// configured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub containment: Option, +} + +/// Whether `agentdesktop run` can launch a sandboxed program on this daemon. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ContainmentInfo { + /// Configured `daemon.containment.executor` (lossy display string). + pub executor: String, + /// Whether launches are possible. `false` carries the reason in `error`. + pub available: bool, + /// Why launches are impossible, when `available` is `false`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Body of `POST /v1/launch`. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LaunchRequest { + /// Provider id of the program to launch (`claude-code`). + pub program: String, + /// Workspace directory of the launch. + pub cwd: PathBuf, + /// Arguments passed to the program. + pub args: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LaunchStatus { + Running, + Exited, +} + +/// A sandboxed launch started by this daemon. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Launch { + /// 16 lowercase hexadecimal characters. + pub launch_id: String, + pub program: String, + pub cwd: PathBuf, + /// `agentdesktop-`. + pub container_id: String, + pub status: LaunchStatus, + pub exit_code: Option, + pub started_at_unix_seconds: u64, + pub ended_at_unix_seconds: Option, + pub credential_expires_at_unix_seconds: Option, } /// State of the daemon's receiver for gateway usage. @@ -379,7 +432,10 @@ pub enum TelemetryEventKind { #[cfg(test)] mod tests { - use super::{ControllerConnectionError, ControllerConnectionStatus, ProgramChoice}; + use super::{ + ContainmentInfo, ControllerConnectionError, ControllerConnectionStatus, Launch, + LaunchRequest, LaunchStatus, ProgramChoice, + }; #[test] fn controller_connection_error_is_a_closed_camel_case_set() { @@ -420,4 +476,84 @@ mod tests { assert!(selected.includes("codex")); assert!(!selected.includes("claude-code")); } + + #[test] + fn containment_info_is_camel_case_and_omits_an_absent_error() { + let available = ContainmentInfo { + executor: r"C:\mxc\x64\wxc-exec.exe".into(), + available: true, + error: None, + }; + assert_eq!( + serde_json::to_value(&available).unwrap(), + serde_json::json!({"executor": r"C:\mxc\x64\wxc-exec.exe", "available": true}) + ); + + let unavailable: ContainmentInfo = serde_json::from_str( + r#"{"executor":"x","available":false,"error":"requires user mode"}"#, + ) + .unwrap(); + assert_eq!(unavailable.error.as_deref(), Some("requires user mode")); + assert!(!unavailable.available); + let again: ContainmentInfo = + serde_json::from_str(&serde_json::to_string(&unavailable).unwrap()).unwrap(); + assert_eq!(again, unavailable); + } + + #[test] + fn launch_request_is_camel_case_json() { + let request = LaunchRequest { + program: "claude-code".into(), + cwd: "/work/app".into(), + args: vec!["--model".into(), "x".into()], + }; + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({ + "program": "claude-code", + "cwd": "/work/app", + "args": ["--model", "x"], + }) + ); + let parsed: LaunchRequest = serde_json::from_value(serde_json::json!({ + "program": "claude-code", "cwd": "/work/app", "args": [] + })) + .unwrap(); + assert!(parsed.args.is_empty()); + } + + #[test] + fn launch_serializes_with_camel_case_keys_and_lowercase_status() { + let running = 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, + credential_expires_at_unix_seconds: Some(1_700_003_600), + }; + let json = serde_json::to_value(&running).unwrap(); + assert_eq!(json["launchId"], "0123456789abcdef"); + assert_eq!(json["containerId"], "agentdesktop-0123456789abcdef"); + assert_eq!(json["status"], "running"); + assert_eq!(json["startedAtUnixSeconds"], 1_700_000_000u64); + assert_eq!(json["credentialExpiresAtUnixSeconds"], 1_700_003_600u64); + let again: Launch = serde_json::from_value(json).unwrap(); + assert_eq!(again, running); + + let exited = Launch { + status: LaunchStatus::Exited, + exit_code: Some(3), + ended_at_unix_seconds: Some(1_700_000_100), + ..running + }; + let json = serde_json::to_value(&exited).unwrap(); + assert_eq!(json["status"], "exited"); + assert_eq!(json["exitCode"], 3); + assert_eq!(json["endedAtUnixSeconds"], 1_700_000_100u64); + assert_eq!(serde_json::from_value::(json).unwrap(), exited); + } } diff --git a/frontend/desktop/src/components/DaemonInformation.tsx b/frontend/desktop/src/components/DaemonInformation.tsx index d72f479a..c2144ad2 100644 --- a/frontend/desktop/src/components/DaemonInformation.tsx +++ b/frontend/desktop/src/components/DaemonInformation.tsx @@ -105,6 +105,14 @@ export function DaemonInformation({ : `${info.usageReceiver.listen} (off${info.usageReceiver.error ? `: ${info.usageReceiver.error}` : ""})`, ]); } + if (info?.containment) { + fields.push([ + "Sandboxed launch", + info.containment.available + ? `${info.containment.executor} (available)` + : `${info.containment.executor} (unavailable${info.containment.error ? `: ${info.containment.error}` : ""})`, + ]); + } return (
diff --git a/frontend/desktop/src/stories/fixtures.ts b/frontend/desktop/src/stories/fixtures.ts index 0de0535d..593ceebf 100644 --- a/frontend/desktop/src/stories/fixtures.ts +++ b/frontend/desktop/src/stories/fixtures.ts @@ -41,6 +41,15 @@ export const usageReceiverDaemonInfo: DaemonInfo = { usageReceiver: { listen: "127.0.0.1:4319", bound: true }, }; +export const containmentDaemonInfo: DaemonInfo = { + ...standaloneDaemonInfo, + containment: { + executor: "C:\\Program Files\\mxc\\wxc-exec.exe", + available: false, + error: "executor not found: C:\\Program Files\\mxc\\wxc-exec.exe", + }, +}; + export const usageReceiverOffDaemonInfo: DaemonInfo = { ...standaloneDaemonInfo, usageReceiver: { diff --git a/frontend/desktop/src/types.ts b/frontend/desktop/src/types.ts index 43b10438..7b5ece3a 100644 --- a/frontend/desktop/src/types.ts +++ b/frontend/desktop/src/types.ts @@ -68,6 +68,12 @@ export interface DaemonInfo { bound: boolean; error?: string; }; + /** Sandboxed launch (`agentdesktop run`), when `daemon.containment` is configured. */ + containment?: { + executor: string; + available: boolean; + error?: string; + }; } export interface ConnectorRuntime { diff --git a/frontend/desktop/src/views/StatusView.stories.tsx b/frontend/desktop/src/views/StatusView.stories.tsx index ad24920a..8665a951 100644 --- a/frontend/desktop/src/views/StatusView.stories.tsx +++ b/frontend/desktop/src/views/StatusView.stories.tsx @@ -5,6 +5,7 @@ import { DesktopStoryFrame } from "../stories/DesktopStoryFrame"; import { approvedDevice, bootstrap, + containmentDaemonInfo, emptyDiscovery, managedConnector, managedDaemonInfo, @@ -175,6 +176,32 @@ export const StandaloneUsageReceiverOff: Story = { }, }; +export const StandaloneContainmentUnavailable: Story = { + args: { + connector: { + ...standaloneConnector, + runtime: { + ...standaloneConnector.runtime, + daemon: containmentDaemonInfo, + }, + }, + discovery: emptyDiscovery, + managedDevice: unconfiguredDevice, + }, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByText("Advanced")); + const local = within( + canvas.getByRole("region", { name: "Daemon information" }), + ); + await expect(local.getByText("Sandboxed launch")).toBeVisible(); + await expect( + local.getByText( + `${containmentDaemonInfo.containment?.executor} (unavailable: ${containmentDaemonInfo.containment?.error})`, + ), + ).toBeVisible(); + }, +}; + export const NoCustomControllerCa: Story = { args: { connector: { diff --git a/schema/daemon-config.json b/schema/daemon-config.json index 377fc811..bfd645d3 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -149,6 +149,20 @@ }, "additionalProperties": false }, + "ContainmentStartupConfig": { + "description": "Sandboxed launch settings.", + "type": "object", + "properties": { + "executor": { + "description": "Windows only. Absolute path to MXC `wxc-exec.exe`; no default. Read at\nstartup only (restart the daemon after changing it). Requires user\nmode; ignored with `--once` and `--dry-run`.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "executor" + ] + }, "ControllerConnectionConfig": { "description": "Connection settings used by a daemon to reach the fleet controller.", "type": "object", @@ -250,6 +264,17 @@ "$ref": "#/$defs/ToolConfigPath", "default": {} }, + "containment": { + "description": "Sandboxed launch (`agentdesktop run`).", + "anyOf": [ + { + "$ref": "#/$defs/ContainmentStartupConfig" + }, + { + "type": "null" + } + ] + }, "copilot": { "description": "GitHub Copilot CLI paths (`config` = the `providers.json` to manage;\ndefaults to `COPILOT_PROVIDERS_CONFIG`, `$COPILOT_HOME/providers.json`,\nthen `~/.copilot/providers.json`).", "$ref": "#/$defs/ToolConfigPath", diff --git a/schema/daemon-config.md b/schema/daemon-config.md index 81e5fe73..d3e6b0d3 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -15,6 +15,8 @@ |`daemon.claudeDesktop.credentialHelper`|string|Credential helper path.| |`daemon.codex`|object|Codex paths.| |`daemon.codex.config`|string|Configuration file.| +|`daemon.containment`|object|Sandboxed launch (`agentdesktop run`).| +|`daemon.containment.executor`|string|Windows only. Absolute path to MXC `wxc-exec.exe`; no default. Read at
startup only (restart the daemon after changing it). Requires user
mode; ignored with `--once` and `--dry-run`.| |`daemon.copilot`|object|GitHub Copilot CLI paths (`config` = the `providers.json` to manage;
defaults to `COPILOT_PROVIDERS_CONFIG`, `$COPILOT_HOME/providers.json`,
then `~/.copilot/providers.json`).| |`daemon.copilot.config`|string|Configuration file.| |`daemon.grok`|object|Grok Build paths.| From 6cda08e04f48b7919286e8034710d5da6d8b1759 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Tue, 20 Oct 2026 18:32:29 +0200 Subject: [PATCH 02/19] launch: stop a sandboxed session the daemon has lost track of When the daemon starts the MXC executor but then cannot record the launch pid, or loses track of the executor during the start-up watch or later in the poller, it now stops the executor instead of dropping the handle. Before this change such a box kept running, unrecorded and with a live credential file, until the next sweep. - `LaunchChild::kill` (Ok for an executor that has already exited); `ProcessChild` calls `std::process::Child::kill` (TerminateProcess on Windows). - One `stop_executor` helper at the three places that lose track of the executor. A kill error is logged as a warning and never replaces the original error or keeps the launch from being marked exited. - Boxes still survive a daemon restart: the kill is explicit, not on drop. No config, schema, core or provider change. Co-Authored-By: Claude Fable 5 --- README.md | 6 +- crates/agent/src/launch/host.rs | 40 ++++++ crates/agent/src/launch/mod.rs | 22 ++- crates/agent/src/launch/tests.rs | 173 +++++++++++++++++++++++- crates/agent/src/launch/writer_tests.rs | 3 + 5 files changed, 237 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a0e75709..80d0e21e 100644 --- a/README.md +++ b/README.md @@ -773,8 +773,10 @@ another user calls a user-mode daemon on Windows, `409` for the configuration rules above, `401`/`424`/`502` straight from the credential request, and `500` when preparing the launch fails (creating `~\.claude`, the credential directory, or writing the request), when the executor cannot be started, when recording the -launch pid fails, or when it exits within three seconds (its last log lines are -in the message). Launches are +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). A daemon restart keeps running boxes alive but stops refreshing their credential: at startup the daemon removes diff --git a/crates/agent/src/launch/host.rs b/crates/agent/src/launch/host.rs index f28ec12c..35df2de3 100644 --- a/crates/agent/src/launch/host.rs +++ b/crates/agent/src/launch/host.rs @@ -217,6 +217,10 @@ impl LaunchChild for ProcessChild { fn start_time(&self) -> u64 { self.start_time } + + fn kill(&mut self) -> io::Result<()> { + self.child.kill() + } } impl Executor for ProcessExecutor { @@ -516,3 +520,39 @@ pub(crate) mod windows { }) } } + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn kill_stops_the_process_and_returns_ok_when_already_exited() { + let child = Command::new("ping") + .args(["-n", "300", "127.0.0.1"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .expect("spawn ping"); + let pid = child.id(); + let mut process_child = ProcessChild { + child, + pid, + start_time: 0, + }; + + process_child.kill().expect("kill the process"); + + let mut exit_code = None; + for _ in 0..100 { + if let Ok(Some(code)) = process_child.try_wait() { + exit_code = Some(code); + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert_eq!(exit_code, Some(1), "process exited with code 1"); + + let result = process_child.kill(); + assert!(result.is_ok(), "second kill returns Ok"); + } +} diff --git a/crates/agent/src/launch/mod.rs b/crates/agent/src/launch/mod.rs index 67888fff..ae29ee58 100644 --- a/crates/agent/src/launch/mod.rs +++ b/crates/agent/src/launch/mod.rs @@ -690,6 +690,14 @@ pub trait LaunchChild: Send { fn pid(&self) -> u32; /// Creation time of the process (FILETIME on Windows). fn start_time(&self) -> u64; + /// Stops the process; `Ok` if it has already exited. + fn kill(&mut self) -> io::Result<()>; +} + +fn stop_executor(child: &mut dyn LaunchChild, launch_id: &str) { + if let Err(error) = child.kill() { + tracing::warn!(launch_id, %error, "failed to stop the executor process"); + } } pub trait Executor: Send + Sync { @@ -1004,6 +1012,7 @@ async fn start_launch( launch_dir.join("pid"), format!("{} {}", child.pid(), child.start_time()), ) { + stop_executor(child.as_mut(), &launch_id); return fail(&launch_dir, format!("record the launch pid: {error}")); } @@ -1023,7 +1032,10 @@ async fn start_launch( ), ); } - Err(error) => return fail(&launch_dir, format!("watch wxc-exec: {error}")), + Err(error) => { + stop_executor(child.as_mut(), &launch_id); + return fail(&launch_dir, format!("watch wxc-exec: {error}")); + } } if tokio::time::Instant::now() >= deadline { break; @@ -1082,8 +1094,9 @@ fn log_tail(log: &Path) -> String { String::from_utf8_lossy(&bytes[start..]).into_owned() } -/// Watches the executor: copies new log lines to the daemon log, and on exit -/// records it, stops the refresh and removes the launch directory. +/// Watches the executor: copies new log lines to the daemon log. When it exits, +/// or when it can no longer be watched (then it is stopped first), records the +/// exit, stops the refresh and removes the launch directory. async fn poll_task( mut child: Box, refresh: tokio::task::AbortHandle, @@ -1101,7 +1114,8 @@ async fn poll_task( Ok(None) => continue, Ok(Some(code)) => Some(code), Err(error) => { - tracing::warn!(launch_id, %error, "lost track of the launch; treating it as exited"); + tracing::warn!(launch_id, %error, "lost track of the launch; stopping it"); + stop_executor(child.as_mut(), &launch_id); None } }; diff --git a/crates/agent/src/launch/tests.rs b/crates/agent/src/launch/tests.rs index 351f9293..4fc2054b 100644 --- a/crates/agent/src/launch/tests.rs +++ b/crates/agent/src/launch/tests.rs @@ -14,7 +14,7 @@ use std::{ path::{Path, PathBuf}, sync::{ Arc, Mutex, - atomic::{AtomicU32, Ordering}, + atomic::{AtomicBool, AtomicU32, Ordering}, }, time::Duration, }; @@ -1709,6 +1709,16 @@ struct StubExecutor { log_text: Mutex, /// The children's exit status; `None` while running. exit: Arc>>, + /// Error kind returned by `try_wait` while set. + try_wait_error: Arc>>, + /// Error kind returned by `kill` while set. + kill_error: Arc>>, + /// Number of times `kill` was called. + kills: Arc, + /// When true, `spawn` creates the pid file as a directory. + pid_as_dir: AtomicBool, + /// Records whether the launch directory existed when `kill` was called. + dir_existed_at_kill: Arc>>, } impl StubExecutor { @@ -1719,14 +1729,26 @@ impl StubExecutor { fn exit_with(&self, code: i32) { *self.exit.lock().unwrap() = Some(code); } + + fn kills(&self) -> u32 { + self.kills.load(Ordering::SeqCst) + } } struct StubChild { exit: Arc>>, + try_wait_error: Arc>>, + kill_error: Arc>>, + kills: Arc, + launch_dir: PathBuf, + dir_existed_at_kill: Arc>>, } impl LaunchChild for StubChild { fn try_wait(&mut self) -> io::Result> { + if let Some(kind) = *self.try_wait_error.lock().unwrap() { + return Err(io::Error::from(kind)); + } Ok(*self.exit.lock().unwrap()) } @@ -1737,6 +1759,15 @@ impl LaunchChild for StubChild { fn start_time(&self) -> u64 { 987_654_321 } + + fn kill(&mut self) -> io::Result<()> { + self.kills.fetch_add(1, Ordering::SeqCst); + *self.dir_existed_at_kill.lock().unwrap() = Some(self.launch_dir.exists()); + if let Some(kind) = *self.kill_error.lock().unwrap() { + return Err(io::Error::from(kind)); + } + Ok(()) + } } impl Executor for StubExecutor { @@ -1765,8 +1796,16 @@ impl Executor for StubExecutor { if !text.is_empty() { std::fs::write(log, text)?; } + if self.pid_as_dir.load(Ordering::SeqCst) { + std::fs::create_dir(launch_dir.join("pid"))?; + } Ok(Box::new(StubChild { exit: self.exit.clone(), + try_wait_error: self.try_wait_error.clone(), + kill_error: self.kill_error.clone(), + kills: self.kills.clone(), + launch_dir: launch_dir.to_owned(), + dir_existed_at_kill: self.dir_existed_at_kill.clone(), })) } } @@ -1854,6 +1893,11 @@ impl Harness { error: Mutex::new(None), log_text: Mutex::new(String::new()), exit: Arc::new(Mutex::new(None)), + try_wait_error: Arc::new(Mutex::new(None)), + kill_error: Arc::new(Mutex::new(None)), + kills: Arc::new(AtomicU32::new(0)), + pid_as_dir: AtomicBool::new(false), + dir_existed_at_kill: Arc::new(Mutex::new(None)), }); let executor_path = client_exe_dir.join("wxc-exec.exe"); let availability = match setup.availability { @@ -2669,6 +2713,133 @@ async fn body_errors_are_the_ones_axum_produces() { h.assert_untouched(); } +// -------------------------------------------------------- stopping the executor + +#[tokio::test(start_paused = true)] +async fn the_executor_is_stopped_when_the_pid_file_cannot_be_written() { + let h = Harness::start(Setup::default()).await; + h.executor.pid_as_dir.store(true, Ordering::SeqCst); + + let (status, body) = h.launch(&h.body()).await; + + assert_eq!(status, 500, "{body}"); + assert!(body.starts_with("record the launch pid: "), "{body}"); + assert_eq!(h.executor.kills(), 1); + assert!( + h.listed().await.is_empty(), + "a launch that never started is not listed" + ); + let launches = h.state_dir.join("launches"); + assert!( + !launches.exists() || std::fs::read_dir(&launches).unwrap().next().is_none(), + "no launch directory remains" + ); +} + +#[tokio::test(start_paused = true)] +async fn the_executor_is_stopped_when_the_startup_watch_fails() { + let h = Harness::start(Setup::default()).await; + *h.executor.try_wait_error.lock().unwrap() = Some(io::ErrorKind::Other); + + let (status, body) = h.launch(&h.body()).await; + + assert_eq!(status, 500, "{body}"); + assert!(body.starts_with("watch wxc-exec: "), "{body}"); + assert_eq!(h.executor.kills(), 1); + assert!( + h.listed().await.is_empty(), + "a launch that never started is not listed" + ); + let launches = h.state_dir.join("launches"); + assert!( + !launches.exists() || std::fs::read_dir(&launches).unwrap().next().is_none(), + "no launch directory remains" + ); +} + +#[tokio::test(start_paused = true)] +async fn a_kill_error_does_not_replace_the_original_error() { + let h = Harness::start(Setup::default()).await; + *h.executor.try_wait_error.lock().unwrap() = Some(io::ErrorKind::Other); + *h.executor.kill_error.lock().unwrap() = Some(io::ErrorKind::PermissionDenied); + + let (status, body) = h.launch(&h.body()).await; + + assert_eq!(status, 500, "{body}"); + assert!(body.starts_with("watch wxc-exec: "), "{body}"); + assert!(body.contains("other error"), "{body}"); + assert!(!body.to_lowercase().contains("permission"), "{body}"); +} + +#[tokio::test(start_paused = true)] +async fn the_executor_is_stopped_when_the_poller_loses_track_of_it() { + let h = Harness::start(Setup::default()).await; + let launched = h.launch_ok().await; + + *h.executor.try_wait_error.lock().unwrap() = Some(io::ErrorKind::Other); + tokio::time::sleep(Duration::from_secs(3)).await; + + let listed = h.listed().await; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].status, LaunchStatus::Exited); + assert_eq!(listed[0].exit_code, None); + assert_eq!(h.executor.kills(), 1); + assert!( + h.executor.dir_existed_at_kill.lock().unwrap().unwrap(), + "directory must exist when kill is called" + ); + assert!(!h.launch_dir(&launched.launch_id).exists()); +} + +#[tokio::test(start_paused = true)] +async fn a_kill_error_does_not_prevent_marking_the_launch_exited() { + let h = Harness::start(Setup::default()).await; + let launched = h.launch_ok().await; + + *h.executor.try_wait_error.lock().unwrap() = Some(io::ErrorKind::Other); + *h.executor.kill_error.lock().unwrap() = Some(io::ErrorKind::PermissionDenied); + tokio::time::sleep(Duration::from_secs(3)).await; + + let listed = h.listed().await; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].status, LaunchStatus::Exited); + assert_eq!(listed[0].exit_code, None); + assert!( + h.executor.dir_existed_at_kill.lock().unwrap().unwrap(), + "directory must exist when kill is called" + ); + assert!(!h.launch_dir(&launched.launch_id).exists()); +} + +#[tokio::test(start_paused = true)] +async fn the_executor_is_not_stopped_after_spawn_failure_early_exit_or_normal_exit() { + let h = Harness::start(Setup::default()).await; + + // Spawn failure + *h.executor.error.lock().unwrap() = + Some(io::Error::new(io::ErrorKind::NotFound, "no such file")); + h.launch(&h.body()).await; + assert_eq!(h.executor.kills(), 0, "no kill after spawn failure"); + + // Early exit + *h.executor.error.lock().unwrap() = None; + h.executor.exit_with(2); + let (status, _) = h.launch(&h.body()).await; + assert_eq!(status, 500, "early exit returns 500"); + assert_eq!(h.executor.kills(), 0, "no kill after early exit"); + + // Normal exit + *h.executor.exit.lock().unwrap() = None; + let launched = h.launch_ok().await; + h.executor.exit_with(7); + tokio::time::sleep(Duration::from_secs(3)).await; + let listed = h.listed().await; + assert_eq!(listed[0].status, LaunchStatus::Exited); + assert_eq!(listed[0].exit_code, Some(7)); + assert!(!h.launch_dir(&launched.launch_id).exists()); + assert_eq!(h.executor.kills(), 0, "no kill after normal exit"); +} + // ----------------------------------------------------------------- Windows only #[cfg(windows)] diff --git a/crates/agent/src/launch/writer_tests.rs b/crates/agent/src/launch/writer_tests.rs index cc97de1f..43ec8a4a 100644 --- a/crates/agent/src/launch/writer_tests.rs +++ b/crates/agent/src/launch/writer_tests.rs @@ -262,6 +262,9 @@ mod handler { fn start_time(&self) -> u64 { 1 } + fn kill(&mut self) -> io::Result<()> { + Ok(()) + } } impl Executor for Recorder { From 4adae6212ccf6ddb6a53b461fa090ab15167f527 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Tue, 20 Oct 2026 18:35:50 +0200 Subject: [PATCH 03/19] llm-proxy: per-session pairing and the /sandbox route A launcher (or `agentdesktop llm-proxy session create`) registers a session over the local API and receives a one-off pairing secret bound to a client id. Requests on the new `/sandbox` prefix are accepted only with a registered, unexpired session pairing; the proxy strips every client credential, adds the gateway identity for the session's client id and logs each request with the session id. The device-wide pairing and the existing routes are unchanged, with one named exception: the prefix-less route now refuses first path segments starting with `sandbox`. - `llm_proxy::sessions`: in-memory `SessionRegistry` (hashed pairings, constant-time compare, sliding idle timeout, bound 64, 60 s sweep); `POST/GET/DELETE /v1/llm-proxy/sessions`; creation-time policy check (`allowedClientIds` under controller JWT; subscription mode refused). An expired pairing cannot revive on a backward clock step, and expired pairings are removed on observation. - A canceled sandbox request still leaves its access line; sized and empty responses complete the request log line. - CLI `agentdesktop llm-proxy session create|list|revoke`; client crate `post_json_response`, `StatusError`, `delete`. - README "Sandbox route and per-session pairing" with the threat model. No config change. Co-Authored-By: Claude Fable 5 --- README.md | 114 +- crates/agent/src/cli.rs | 275 ++- crates/agent/src/daemon.rs | 34 +- .../src/{llm_proxy.rs => llm_proxy/mod.rs} | 1530 ++++++++++++++++- crates/agent/src/llm_proxy/sessions.rs | 1392 +++++++++++++++ crates/client/src/lib.rs | 372 ++-- crates/core/src/model.rs | 168 +- 7 files changed, 3673 insertions(+), 212 deletions(-) rename crates/agent/src/{llm_proxy.rs => llm_proxy/mod.rs} (70%) create mode 100644 crates/agent/src/llm_proxy/sessions.rs diff --git a/README.md b/README.md index 80d0e21e..84dd93e9 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 @@ -810,6 +814,100 @@ pipe (for example `\\.\pipe\agentdesktop-`) and pass it to `run` with `--socket` (or `AGENTDESKTOP_SOCKET` in the unified binary); 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. + +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/src/cli.rs b/crates/agent/src/cli.rs index 18a1b1db..23eb2646 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -1,11 +1,16 @@ -use std::{ffi::OsStr, io::Read, path::PathBuf}; +use std::{ + ffi::OsStr, + io::Read, + path::{Path, PathBuf}, +}; use agentdesktop_client as client; use agentdesktop_core::{ config::DaemonConfig, model::{ Discovery, EnrollmentStatus, Health, Launch, LaunchRequest, LlmGatewayCredential, - LlmGatewayLoginStatus, ProgramChoice, ProgramManagement, TelemetryEventKind, + LlmGatewayLoginStatus, ProgramChoice, ProgramManagement, SessionCreated, SessionInfo, + SessionRequest, TelemetryEventKind, }, }; use anyhow::Context; @@ -53,6 +58,111 @@ pub enum ClientCommand { #[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 => format!( + "too many sessions ({}); revoke unused ones", + crate::llm_proxy::sessions::MAX_SESSIONS + ), + 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)] @@ -203,6 +313,11 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()> } } } + 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 }, } => { @@ -402,8 +517,8 @@ mod tests { use clap::Parser; use super::{ - ClientCommand, file_credential, parse_claude_pre_tool_use, parse_program_choice, - run_error_message, run_request, + ClientCommand, LlmProxyCommand, SessionCommand, file_credential, parse_claude_pre_tool_use, + parse_program_choice, run_error_message, run_request, session_error_message, }; fn args(values: &[&str]) -> Vec { @@ -572,6 +687,126 @@ mod tests { } } + 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( @@ -605,3 +840,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 dc37ab96..49d1566c 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -704,6 +704,19 @@ where usage: usage_store, programs: Some(program_control), }; + // 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_api = llm_proxy::sessions::SessionApiState { + registry: sessions.clone(), + proxy_address: proxy_listener + .as_ref() + .and_then(|listener| listener.local_addr().ok()), + policy: std::sync::Arc::new(llm_proxy::sessions::ConfigPolicy { + config: state.config.clone(), + state_dir: state.state_dir.clone(), + }), + }; let launch_state = launch::LaunchState { app: state.clone(), executor: Arc::new(launch::host::ProcessExecutor), @@ -720,7 +733,10 @@ where availability: containment, daemon_sid: daemon_sid.clone(), }; - let app = api::router(state.clone()).merge(launch::router(launch_state)); + 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 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. @@ -732,6 +748,7 @@ where llm_proxy::ProxyConfig { default_client_id: args.llm_proxy_client_id, pairing: pairing.expect("pairing exists whenever the listener does"), + sessions, }, ) .await @@ -749,10 +766,12 @@ where let local_api = serve_unix(&socket, local_api_access()?, app, shutdown); #[cfg(windows)] let local_api = serve_named_pipe(&socket, app, shutdown, daemon_sid, first_pipe_server); - tokio::select! { - result = local_api => result?, - result = proxy => result?, - } + let result = tokio::select! { + result = local_api => result, + result = proxy => result, + }; + session_sweep.abort(); + result?; Ok(()) } @@ -1246,7 +1265,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, { diff --git a/crates/agent/src/llm_proxy.rs b/crates/agent/src/llm_proxy/mod.rs similarity index 70% rename from crates/agent/src/llm_proxy.rs rename to crates/agent/src/llm_proxy/mod.rs index 63708c94..a2067006 100644 --- a/crates/agent/src/llm_proxy.rs +++ b/crates/agent/src/llm_proxy/mod.rs @@ -21,6 +21,9 @@ use agentdesktop_core::config::{GitHubTokenSource, LlmGatewayConfig}; use crate::api::{self, AppState}; +pub(crate) mod sessions; +use sessions::SessionRegistry; + type ProxyClient = Client, Full>; type ProxyBody = BoxBody; @@ -274,15 +277,25 @@ pub(crate) enum Upstream { ProxyUrl, } -/// A fixed path prefix the proxy serves for one managed program. +/// Where a route takes the client id it presents to the gateway. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RouteIdentity { + /// The id is fixed by the route (the file the daemon owns decides it). + Fixed(&'static str), + /// The id comes from the registered session whose pairing the request carries. + Session, +} + +/// A fixed path prefix the proxy serves. /// -/// The prefix is what a reconciler writes into that program's client file, so -/// the client id is decided by the file the daemon owns, never by request -/// headers. The prefix is stripped before forwarding. +/// For a managed program the prefix is what its reconciler writes into the +/// client file, so the client id is decided by the file the daemon owns, never +/// by request headers; on the sandbox route the client id is the registered +/// session's. The prefix is stripped before forwarding. #[derive(Clone, Copy, Debug)] pub(crate) struct ProxyRoute { pub prefix: &'static str, - pub client_id: &'static str, + pub identity: RouteIdentity, pub credential: RouteCredential, pub upstream: Upstream, /// The pairing is the first path segment after the prefix (the header is @@ -302,12 +315,19 @@ pub(crate) struct ProxyRoute { /// (the `GET /responses` Auto/agent conversation). pub(crate) const CAPI_ROUTE: &str = "/vscode-copilot-capi"; -/// Routes for the Copilot programs. A prefix matches only at a `/` boundary, so +/// The route for a program inside a sandbox: its pairing is a per-session +/// value minted over the local API (`sessions`), never the device pairing, and +/// the client id is the session's. The box needs nothing but this route, so +/// it holds no credential and needs nothing but loopback. +pub(crate) const SANDBOX_ROUTE: &str = "/sandbox"; + +/// The prefixed routes: the Copilot programs and the sandbox route. A prefix +/// matches only at a `/` boundary, so /// `/vscode-copilot-passthrough/...` never matches `/vscode-copilot`. pub(crate) const ROUTES: &[ProxyRoute] = &[ ProxyRoute { prefix: "/vscode-copilot-passthrough", - client_id: "vscode-copilot", + identity: RouteIdentity::Fixed("vscode-copilot"), credential: RouteCredential::Passthrough, upstream: Upstream::ProxyUrl, pairing_in_path: false, @@ -316,7 +336,7 @@ pub(crate) const ROUTES: &[ProxyRoute] = &[ }, ProxyRoute { prefix: CAPI_ROUTE, - client_id: "vscode-copilot", + identity: RouteIdentity::Fixed("vscode-copilot"), credential: RouteCredential::Passthrough, upstream: Upstream::ProxyUrl, pairing_in_path: true, @@ -325,7 +345,7 @@ pub(crate) const ROUTES: &[ProxyRoute] = &[ }, ProxyRoute { prefix: "/vscode-copilot", - client_id: "vscode-copilot", + identity: RouteIdentity::Fixed("vscode-copilot"), credential: RouteCredential::Gateway, upstream: Upstream::Url, pairing_in_path: false, @@ -334,7 +354,16 @@ pub(crate) const ROUTES: &[ProxyRoute] = &[ }, ProxyRoute { prefix: "/copilot-cli", - client_id: "copilot-cli", + identity: RouteIdentity::Fixed("copilot-cli"), + credential: RouteCredential::Gateway, + upstream: Upstream::Url, + pairing_in_path: false, + tunnels_upgrades: false, + credential_less_ping: false, + }, + ProxyRoute { + prefix: SANDBOX_ROUTE, + identity: RouteIdentity::Session, credential: RouteCredential::Gateway, upstream: Upstream::Url, pairing_in_path: false, @@ -433,8 +462,11 @@ pub(crate) struct ProxyConfig { /// Client id for requests that match no prefixed route (the original, /// hand-configured shape from the README). pub default_client_id: String, - /// Pairing value required on every route. + /// Device pairing value required on every route except the sandbox + /// route, which takes a session pairing from `sessions`. pub pairing: Arc, + /// Per-session pairings accepted on the `/sandbox` route. + pub sessions: SessionRegistry, } impl std::fmt::Debug for ProxyConfig { @@ -442,6 +474,7 @@ impl std::fmt::Debug for ProxyConfig { f.debug_struct("ProxyConfig") .field("default_client_id", &self.default_client_id) .field("pairing", &"") + .field("sessions", &self.sessions.len(now_unix())) .finish() } } @@ -571,10 +604,23 @@ pub(crate) async fn serve_with_cache( let cache = cache.clone(); let slot = slot.clone(); async move { - let response = match forward(request, &client, &state, &config, &cache, &slot).await { - Ok(response) => response, + let mut log = RequestLog::default(); + let response = match forward(request, &client, &state, &config, &cache, &slot, &mut log).await { + Ok(response) => match log.sandbox.take() { + // A sandbox request: the event is emitted + // when the response body ends, so it can + // carry the bytes delivered and the outcome. + Some(sandbox) => { + let status = response.status().as_u16(); + response.map(|body| LoggedBody::new(body, sandbox.with_status(status, None)).boxed()) + } + None => response, + }, Err(error) => { tracing::warn!(status = %error.status, code = error.code, message = %error.message, "LLM proxy request failed"); + if let Some(sandbox) = log.sandbox.take() { + sandbox.with_status(error.status.as_u16(), Some(error.code)).emit(0, "complete"); + } error.into_response() } }; @@ -608,6 +654,163 @@ pub(crate) async fn serve_with_cache( } } +/// What `forward` records about a request for the sandbox request log. Only a +/// request on the sandbox route fills `sandbox`; every other route logs +/// nothing new. Drop guard: if the handler future is dropped (client +/// disconnected) before `forward` or `LoggedBody` emitted the log, RequestLog's +/// Drop emits with outcome=canceled. +#[derive(Debug, Default)] +pub(crate) struct RequestLog { + pub sandbox: Option, +} + +impl Drop for RequestLog { + fn drop(&mut self) { + // If sandbox is still present, the request was canceled before the + // normal log paths (error arm or LoggedBody) took it and emitted. + if let Some(request) = self.sandbox.take() { + request.emit(0, "canceled"); + } + } +} + +/// One request on the sandbox route, as its log event reports it: never a +/// header, a body, a query string or a pairing value. +#[derive(Debug)] +pub(crate) struct SandboxRequest { + /// Session id once the pairing matched; `-` until then. + pub session: Option, + pub client_id: Option, + pub method: String, + /// The path as sent, prefix stripped, query omitted. + pub path: String, + /// Upstream status, or the proxy's refusal status. + pub status: u16, + /// The refusal code; `-` for a forwarded request. + pub code: Option<&'static str>, + /// Buffered request body length; 0 when refused before the body was read. + pub bytes_in: usize, + started: std::time::Instant, +} + +impl SandboxRequest { + fn new(method: &Method, path: &str) -> Self { + Self { + session: None, + client_id: None, + method: method.to_string(), + path: path.to_owned(), + status: 0, + code: None, + bytes_in: 0, + started: std::time::Instant::now(), + } + } + + fn with_status(mut self, status: u16, code: Option<&'static str>) -> Self { + self.status = status; + self.code = code; + self + } + + /// The one `info` event per sandbox request. + fn emit(&self, bytes_out: u64, outcome: &'static str) { + tracing::info!( + session = self.session.as_deref().unwrap_or("-"), + client_id = self.client_id.as_deref().unwrap_or("-"), + method = %self.method, + path = %self.path, + status = self.status, + code = self.code.unwrap_or("-"), + bytes_in = self.bytes_in, + bytes_out, + duration_ms = self.started.elapsed().as_millis() as u64, + outcome, + "LLM proxy sandbox request" + ); + } +} + +/// Response body of a sandbox request: counts the data bytes delivered and +/// emits the request log once, when the upstream body ends (`complete`, also +/// when hyper drops a body that has reported its end without polling for the +/// trailing `None`), fails, or is dropped before its end because the client +/// went away (`aborted`). +pub(crate) struct LoggedBody { + inner: ProxyBody, + log: SandboxRequest, + bytes_out: u64, + emitted: bool, +} + +impl LoggedBody { + fn new(inner: ProxyBody, log: SandboxRequest) -> Self { + Self { + inner, + log, + bytes_out: 0, + emitted: false, + } + } + + fn finish(&mut self, outcome: &'static str) { + if !self.emitted { + self.emitted = true; + self.log.emit(self.bytes_out, outcome); + } + } +} + +impl hyper::body::Body for LoggedBody { + type Data = Bytes; + type Error = hyper::Error; + + fn poll_frame( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, hyper::Error>>> { + let polled = std::pin::Pin::new(&mut self.inner).poll_frame(cx); + match &polled { + std::task::Poll::Ready(Some(Ok(frame))) => { + if let Some(data) = frame.data_ref() { + self.bytes_out += data.len() as u64; + } + // hyper stops polling once the body reports its end (a + // Content-Length body after its last frame) and never asks + // for the trailing `None`: that is a completed response too. + if hyper::body::Body::is_end_stream(&self.inner) { + self.finish("complete"); + } + } + std::task::Poll::Ready(Some(Err(_))) => self.finish("aborted"), + std::task::Poll::Ready(None) => self.finish("complete"), + std::task::Poll::Pending => {} + } + polled + } + + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } + + fn size_hint(&self) -> hyper::body::SizeHint { + self.inner.size_hint() + } +} + +impl Drop for LoggedBody { + fn drop(&mut self) { + // An empty body (204, HEAD) is never polled at all; a body dropped + // before its end was cut off by the client. + let outcome = if hyper::body::Body::is_end_stream(&self.inner) { + "complete" + } else { + "aborted" + }; + self.finish(outcome); + } +} + /// A refused or failed request, rendered as an OpenAI-style JSON error so the /// LLM clients display the message instead of dropping the connection. #[derive(Debug)] @@ -626,6 +829,15 @@ impl ProxyError { ) } + /// The sandbox route's refusal: a session pairing that matches nothing. + fn session_invalid() -> Self { + Self::new( + StatusCode::FORBIDDEN, + "pairing_invalid", + "agentdesktop proxy: session pairing missing, expired or revoked; start a new session", + ) + } + fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { Self { status, @@ -801,7 +1013,29 @@ async fn forward( config: &ProxyConfig, cache: &Arc, tunnel_slot: &PendingTunnel, + log: &mut RequestLog, ) -> Result, ProxyError> { + // Pure string work on the path first; no configuration is read yet. It + // comes before every check so a refused sandbox request is logged with + // its route and path. + let path_and_query = request + .uri() + .path_and_query() + .map_or("/", |path| path.as_str()) + .to_owned(); + let (path, query) = match path_and_query.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (path_and_query.as_str(), None), + }; + // Matched once; the route decides pairing placement, upgrade handling, + // the ping exemption, client id, credential mode and upstream. + let matched = match_route(path); + let matched_route = matched.map(|(route, _)| route); + if let Some((route, rest)) = matched + && route.identity == RouteIdentity::Session + { + log.sandbox = Some(SandboxRequest::new(request.method(), rest)); + } if !host_is_loopback(request.headers()) { return Err(ProxyError::new( StatusCode::FORBIDDEN, @@ -824,17 +1058,8 @@ async fn forward( "agentdesktop proxy: OPTIONS is not supported", )); } - // Pure string work on the path first; no configuration is read yet. - let path_and_query = request - .uri() - .path_and_query() - .map_or("/", |path| path.as_str()) - .to_owned(); - let (path, query) = match path_and_query.split_once('?') { - Some((path, query)) => (path, Some(query)), - None => (path_and_query.as_str(), None), - }; - // The pairing value is required on every route. It travels in its own + // A pairing value is required on every route: the device pairing, or a + // session pairing on the sandbox route. It travels in its own // header, so Authorization keeps one meaning (the client's own token on the // pass-through shapes) and the pairing is never mistaken for a credential. // The CAPI route's client (VS Code) cannot add a header, so there the @@ -843,10 +1068,8 @@ async fn forward( // nothing about this device and costs it no work. let pairing_ok = |offered: &str| constant_time_eq(offered.as_bytes(), config.pairing.as_bytes()); - // Matched once; the route decides pairing placement, upgrade handling, - // the ping exemption, client id, credential mode and upstream. - let matched = match_route(path); - let matched_route = matched.map(|(route, _)| route); + // The sandbox route's session, once its pairing matched. + let mut session: Option = None; let capi_rest = match matched { Some((route, rest)) if route.pairing_in_path => { let Some((segment, remainder)) = split_path_pairing(rest) else { @@ -857,6 +1080,26 @@ async fn forward( } Some(remainder.to_owned()) } + // The sandbox route takes a session pairing in the header: looked up + // and its idle timeout restarted under one lock. The device pairing + // never opens this route (a file that holds it must not become a + // session), and a session pairing opens no other route (below, it + // never equals the device pairing). + Some((route, _)) if route.identity == RouteIdentity::Session => { + let offered = request + .headers() + .get(PAIRING_HEADER) + .and_then(|value| value.to_str().ok()); + let found = offered + .and_then(|offered| config.sessions.authorize(offered, now_unix())) + .ok_or_else(ProxyError::session_invalid)?; + if let Some(sandbox) = log.sandbox.as_mut() { + sandbox.session = Some(found.id.clone()); + sandbox.client_id = Some(found.client_id.clone()); + } + session = Some(found); + None + } _ => { let offered = request .headers() @@ -890,7 +1133,7 @@ async fn forward( return Err(ProxyError::new( StatusCode::NOT_FOUND, "route_unknown", - "agentdesktop proxy: unknown route; the managed prefixes are /copilot-cli, /vscode-copilot, /vscode-copilot-passthrough and /vscode-copilot-capi", + "agentdesktop proxy: unknown route; the managed prefixes are /copilot-cli, /vscode-copilot, /vscode-copilot-passthrough, /vscode-copilot-capi and /sandbox", )); } let effective = @@ -927,7 +1170,13 @@ async fn forward( )); } ( - route.client_id.to_owned(), + match route.identity { + RouteIdentity::Fixed(client_id) => client_id.to_owned(), + RouteIdentity::Session => session + .as_ref() + .map(|session| session.client_id.clone()) + .ok_or_else(ProxyError::session_invalid)?, + }, Some(route.credential), upstream_base(gateway, route.upstream), capi_rest.as_deref().unwrap_or(rest), @@ -1004,6 +1253,9 @@ async fn forward( } })? .to_bytes(); + if let Some(sandbox) = log.sandbox.as_mut() { + sandbox.bytes_in = body.len(); + } let mut request = Request::from_parts(parts, ()); strip_hop_headers(request.headers_mut()); @@ -1568,6 +1820,7 @@ mod tests { ProxyConfig { default_client_id: "vscode".into(), pairing: Arc::from(PAIRING), + sessions: SessionRegistry::default(), } } @@ -1841,7 +2094,7 @@ mod tests { #[test] fn route_matching_prefers_the_longer_prefix_and_strips_it() { let (route, rest) = match_route("/vscode-copilot-passthrough/chat/completions").unwrap(); - assert_eq!(route.client_id, "vscode-copilot"); + assert_eq!(route.identity, RouteIdentity::Fixed("vscode-copilot")); assert_eq!(route.credential, RouteCredential::Passthrough); assert_eq!(rest, "/chat/completions"); let (route, rest) = match_route("/vscode-copilot/v1/chat/completions").unwrap(); @@ -1850,6 +2103,12 @@ mod tests { assert_eq!(match_route("/copilot-cli").unwrap().1, "/"); assert!(match_route("/copilot-client/v1").is_none()); assert!(match_route("/v1/chat/completions").is_none()); + let (route, rest) = match_route("/sandbox/v1/messages").unwrap(); + assert_eq!(route.identity, RouteIdentity::Session); + assert_eq!(rest, "/v1/messages"); + assert_eq!(match_route("/sandbox").unwrap().1, "/"); + assert!(match_route("/sandboxx/v1/messages").is_none()); + assert!(match_route("/sandbox-x/v1/messages").is_none()); } #[test] @@ -1958,36 +2217,46 @@ mod tests { let expected = [ ( "/copilot-cli/v1/x", - "copilot-cli", + RouteIdentity::Fixed("copilot-cli"), RouteCredential::Gateway, Upstream::Url, ), ( "/vscode-copilot/v1/x", - "vscode-copilot", + RouteIdentity::Fixed("vscode-copilot"), RouteCredential::Gateway, Upstream::Url, ), ( "/vscode-copilot-passthrough/chat/completions", - "vscode-copilot", + RouteIdentity::Fixed("vscode-copilot"), RouteCredential::Passthrough, Upstream::ProxyUrl, ), ( "/vscode-copilot-capi/PAIRING/responses", - "vscode-copilot", + RouteIdentity::Fixed("vscode-copilot"), RouteCredential::Passthrough, Upstream::ProxyUrl, ), + ( + "/sandbox/v1/messages", + RouteIdentity::Session, + RouteCredential::Gateway, + Upstream::Url, + ), ]; - for (path, client_id, credential, upstream) in expected { + for (path, identity, credential, upstream) in expected { let (route, _) = match_route(path).unwrap_or_else(|| panic!("{path}")); - assert_eq!(route.client_id, client_id, "{path}"); + assert_eq!(route.identity, identity, "{path}"); assert_eq!(route.credential, credential, "{path}"); assert_eq!(route.upstream, upstream, "{path}"); } - assert_eq!(ROUTES.len(), 4); + let (sandbox, _) = match_route("/sandbox/v1/messages").unwrap(); + assert!(!sandbox.pairing_in_path); + assert!(!sandbox.tunnels_upgrades); + assert!(!sandbox.credential_less_ping); + assert_eq!(ROUTES.len(), 5); assert_eq!(CAPI_ROUTE, "/vscode-copilot-capi"); } @@ -3500,4 +3769,1189 @@ mod tests { .await .unwrap(); } + + // --- /sandbox route, per-session pairing and the sandbox request log. + + const SESSION_TTL: Duration = Duration::from_secs(3600); + + fn new_session(sessions: &SessionRegistry, client_id: &str) -> sessions::Session { + sessions + .register(client_id, "test session", SESSION_TTL, now_unix()) + .unwrap_or_else(|error| panic!("register a session: {error:?}")) + } + + fn http_client() -> Client> { + Client::builder(TokioExecutor::new()).build_http() + } + + /// A POST to `path_and_query` on the proxy, carrying `pairing` in the pairing header. + fn post_with( + proxy: std::net::SocketAddr, + path_and_query: &str, + pairing: Option<&str>, + body: &'static [u8], + ) -> Request> { + let mut builder = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy}{path_and_query}")); + if let Some(pairing) = pairing { + builder = builder.header(PAIRING_HEADER, pairing); + } + builder.body(Full::new(Bytes::from_static(body))).unwrap() + } + + /// Status, code and message of a refusal. + async fn refusal(response: Response) -> (StatusCode, String, String) { + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + ( + status, + json["error"]["code"].as_str().unwrap().to_owned(), + json["error"]["message"].as_str().unwrap().to_owned(), + ) + } + + struct Upstreamed { + uri: String, + headers: HeaderMap, + body: Bytes, + } + + type SeenRequests = Arc>>; + + /// An upstream that answers every request with 200 "ok" and records it. + async fn recording_upstream() -> ( + std::net::SocketAddr, + SeenRequests, + tokio::task::JoinHandle<()>, + ) { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let seen: SeenRequests = Arc::default(); + let record = seen.clone(); + let task = tokio::spawn(async move { + loop { + let (socket, _) = upstream.accept().await.unwrap(); + let record = record.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| { + let record = record.clone(); + async move { + let (parts, body) = request.into_parts(); + let body = body.collect().await.unwrap().to_bytes(); + record.lock().unwrap().push(Upstreamed { + uri: parts.uri.to_string(), + headers: parts.headers, + body, + }); + Ok::<_, Infallible>(Response::new(Full::new( + Bytes::from_static(b"ok"), + ))) + } + }), + ) + .await; + }); + } + }); + (address, seen, task) + } + + // The sandbox route is the copilot-cli shape with a session pairing: prefix + // stripped, every client credential and the pairing removed, the gateway + // identity added, other headers kept, and nothing forwarded when refused. + #[tokio::test] + async fn sandbox_route_accepts_a_session_pairing_and_forwards_with_the_gateway_identity() { + tokio::time::timeout(Duration::from_secs(15), async { + let (address, seen, upstream_task) = recording_upstream().await; + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + + // Refusals first: nothing may reach the gateway. + let unpaired = post_with(proxy_address, "/sandbox/v1/messages", None, b"{}"); + let (status, code, message) = refusal(client.request(unpaired).await.unwrap()).await; + assert_eq!((status, code.as_str()), (StatusCode::FORBIDDEN, "pairing_invalid")); + assert_eq!( + message, + "agentdesktop proxy: session pairing missing, expired or revoked; start a new session" + ); + let wrong = format!("{}X", &session.pairing[..session.pairing.len() - 1]); + let wrong = post_with(proxy_address, "/sandbox/v1/messages", Some(&wrong), b"{}"); + assert_eq!( + refusal(client.request(wrong).await.unwrap()).await.1, + "pairing_invalid" + ); + // The pairing is only accepted in its own header, never as the bearer. + let as_bearer = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}/sandbox/v1/messages")) + .header(AUTHORIZATION, format!("Bearer {}", session.pairing)) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + refusal(client.request(as_bearer).await.unwrap()).await.1, + "pairing_invalid" + ); + let escape = post_with( + proxy_address, + "/sandbox/../admin", + Some(&session.pairing), + b"{}", + ); + assert_eq!( + refusal(client.request(escape).await.unwrap()).await.1, + "path_invalid" + ); + let browser = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}/sandbox/v1/messages")) + .header(PAIRING_HEADER, &*session.pairing) + .header(ORIGIN, "https://evil.example") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + refusal(client.request(browser).await.unwrap()).await.1, + "browser_not_allowed" + ); + let options = Request::builder() + .method(Method::OPTIONS) + .uri(format!("http://{proxy_address}/sandbox/v1/messages")) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::new())) + .unwrap(); + assert_eq!( + refusal(client.request(options).await.unwrap()).await.1, + "method_not_allowed" + ); + assert!(seen.lock().unwrap().is_empty(), "a refused request was forwarded"); + + // The device routes keep their message. + let device_unpaired = post_with(proxy_address, "/copilot-cli/v1/x", None, b"{}"); + let (_, _, message) = refusal(client.request(device_unpaired).await.unwrap()).await; + assert!(message.contains("pairing value missing or wrong"), "{message}"); + + // The accepted request. + let request = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}/sandbox/v1/messages?beta=true")) + .header(PAIRING_HEADER, &*session.pairing) + .header(AUTHORIZATION, "Bearer a-real-key-by-mistake") + .header("x-api-key", "placeholder") + .header("api-key", "placeholder") + .header("x-llm-token", "must-not-forward") + .header("anthropic-version", "2023-06-01") + .body(Full::new(Bytes::from_static(b"{\"n\":1}"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "ok" + ); + { + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + let forwarded = &seen[0]; + assert_eq!(forwarded.uri, "/v1/messages?beta=true"); + assert_eq!(forwarded.body, Bytes::from_static(b"{\"n\":1}")); + assert_eq!(forwarded.headers[AUTHORIZATION], "Bearer identity"); + assert_eq!(forwarded.headers["anthropic-version"], "2023-06-01"); + assert_eq!(forwarded.headers[HOST], address.to_string()); + for removed in ["x-api-key", "api-key", "x-llm-token", PAIRING_HEADER] { + assert!(!forwarded.headers.contains_key(removed), "{removed}"); + } + } + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + // The device pairing never opens the sandbox route (the box must not be able + // to turn a device-pairing file into a session). + #[tokio::test] + async fn sandbox_route_refuses_the_device_pairing() { + tokio::time::timeout(Duration::from_secs(15), async { + let (address, seen, upstream_task) = recording_upstream().await; + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + let request = post_with(proxy_address, "/sandbox/v1/messages", Some(PAIRING), b"{}"); + assert_eq!( + refusal(client.request(request).await.unwrap()).await.1, + "pairing_invalid" + ); + assert!(seen.lock().unwrap().is_empty()); + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + // A session pairing is a bearer for /sandbox only: it never equals the + // device pairing, so every other route (and the prefix-less one) refuses it. + #[tokio::test] + async fn session_pairing_is_refused_on_every_other_route() { + tokio::time::timeout(Duration::from_secs(15), async { + let (address, seen, upstream_task) = recording_upstream().await; + let (dir, state) = capi_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + let capi = format!("/vscode-copilot-capi/{}/responses", session.pairing); + for path in [ + "/copilot-cli/v1/messages", + "/vscode-copilot/v1/messages", + "/vscode-copilot-passthrough/chat/completions", + "/v1/messages", + capi.as_str(), + ] { + let request = post_with(proxy_address, path, Some(&session.pairing), b"{}"); + let (status, code, _) = refusal(client.request(request).await.unwrap()).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "pairing_invalid"), + "{path}" + ); + } + assert!(seen.lock().unwrap().is_empty()); + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn expired_and_revoked_sessions_are_refused() { + tokio::time::timeout(Duration::from_secs(15), async { + let (address, seen, upstream_task) = recording_upstream().await; + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + // Registered an hour ago with a one-minute idle timeout. + let expired = sessions + .register( + "claude-code", + "old", + Duration::from_secs(60), + now_unix() - 3600, + ) + .ok() + .unwrap(); + let revoked = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + let request = post_with( + proxy_address, + "/sandbox/v1/messages", + Some(&expired.pairing), + b"{}", + ); + let (status, code, _) = refusal(client.request(request).await.unwrap()).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "pairing_invalid") + ); + + let request = post_with( + proxy_address, + "/sandbox/v1/messages", + Some(&revoked.pairing), + b"{}", + ); + assert_eq!( + client.request(request).await.unwrap().status(), + StatusCode::OK + ); + assert!(sessions.revoke(&revoked.id)); + let request = post_with( + proxy_address, + "/sandbox/v1/messages", + Some(&revoked.pairing), + b"{}", + ); + let (status, code, _) = refusal(client.request(request).await.unwrap()).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "pairing_invalid") + ); + assert_eq!( + seen.lock().unwrap().len(), + 1, + "only the live request is forwarded" + ); + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + // A first segment that merely starts like the sandbox prefix is a mistyped + // client file, not the prefix-less route. The device pairing gets the route + // error; a session pairing is not the device pairing and gets the pairing error. + #[tokio::test] + async fn sandbox_lookalike_prefixes_are_unknown_routes_or_refused() { + tokio::time::timeout(Duration::from_secs(15), async { + let (address, seen, upstream_task) = recording_upstream().await; + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + for path in [ + "/sandboxx/v1/messages", + "/sandbox2/v1/messages", + "/Sandbox/v1/messages", + ] { + let request = post_with(proxy_address, path, Some(PAIRING), b"{}"); + let (status, code, message) = refusal(client.request(request).await.unwrap()).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::NOT_FOUND, "route_unknown"), + "{path}" + ); + assert!(message.contains("/sandbox"), "{message}"); + let request = post_with(proxy_address, path, Some(&session.pairing), b"{}"); + let (status, code, _) = refusal(client.request(request).await.unwrap()).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "pairing_invalid"), + "{path}" + ); + } + assert!(seen.lock().unwrap().is_empty()); + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[test] + fn sandbox_lookalikes_resemble_a_route() { + for path in ["/sandboxx/v1", "/sandbox2/v1", "/Sandbox/v1", "/sandbox-x"] { + assert!(resembles_route(path), "{path}"); + } + assert!(match_route("/sandbox/v1/messages").is_some()); + } + + // The identity is the session's client id, so credentials are cached per + // client id and shared by every session of that id. + #[tokio::test] + async fn sandbox_sessions_use_their_client_id_and_share_the_cached_credential() { + tokio::time::timeout(Duration::from_secs(15), async { + let (address, seen, upstream_task) = recording_upstream().await; + let (dir, state) = controller_jwt_state(address, false).await; + let cache = Arc::new(CredentialCache::default()); + let issued = |value: &str| agentdesktop_core::model::LlmGatewayCredential { + credential: value.to_owned(), + expires_at_unix_seconds: now_unix() + 600, + }; + for (client_id, jwt) in [("claude-code", "jwt-claude"), ("my_tool", "jwt-tool")] { + cache.insert( + &format!("{client_id}\u{0}http://{address}/\u{0}agentgateway"), + "device-test", + &issued(jwt), + ); + } + let config = default_config(); + let sessions = config.sessions.clone(); + let first = new_session(&sessions, "claude-code"); + let second = new_session(&sessions, "claude-code"); + let other = new_session(&sessions, "my_tool"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve_with_cache(listener, state, config, cache)); + let client = http_client(); + for session in [&first, &second, &other] { + let request = post_with( + proxy_address, + "/sandbox/v1/messages", + Some(&session.pairing), + b"{}", + ); + assert_eq!( + client.request(request).await.unwrap().status(), + StatusCode::OK + ); + } + let authorizations: Vec = seen + .lock() + .unwrap() + .iter() + .map(|request| request.headers[AUTHORIZATION].to_str().unwrap().to_owned()) + .collect(); + assert_eq!( + authorizations, + ["Bearer jwt-claude", "Bearer jwt-claude", "Bearer jwt-tool"] + ); + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + // Like `cached_credential_is_used_and_dropped_on_401`, on the sandbox route. + #[tokio::test] + async fn sandbox_route_retries_once_on_401_and_drops_the_rejected_cached_credential() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + type Seen = Arc)>>>; + let seen: Seen = Arc::default(); + let record = seen.clone(); + let upstream_task = tokio::spawn(async move { + loop { + let (socket, _) = upstream.accept().await.unwrap(); + let record = record.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| { + let record = record.clone(); + async move { + let authorization = request + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let body = request + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + let status = if authorization == "Bearer good" { + StatusCode::OK + } else { + StatusCode::UNAUTHORIZED + }; + record.lock().unwrap().push((authorization, body)); + Ok::<_, Infallible>( + Response::builder() + .status(status) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(), + ) + } + }), + ) + .await; + }); + } + }); + let (dir, state) = controller_jwt_state(address, false).await; + let cache = Arc::new(CredentialCache::default()); + let key = format!("claude-code\u{0}http://{address}/\u{0}agentgateway"); + let issued = |value: &str| agentdesktop_core::model::LlmGatewayCredential { + credential: value.to_owned(), + expires_at_unix_seconds: now_unix() + 600, + }; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve_with_cache(listener, state, config, cache.clone())); + let client = http_client(); + let request = |body: &'static [u8]| { + post_with( + proxy_address, + "/sandbox/v1/messages", + Some(&session.pairing), + body, + ) + }; + cache.insert(&key, "device-test", &issued("good")); + let response = client.request(request(b"{\"n\":1}")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(cache.get(&key, "device-test").as_deref(), Some("good")); + // Rejected: entry dropped, one retry that needs a controller. + cache.insert(&key, "device-test", &issued("stale")); + let (status, code, _) = + refusal(client.request(request(b"{\"n\":2}")).await.unwrap()).await; + assert_eq!(code, "agentdesktop_credential", "{status}"); + assert_eq!(cache.get(&key, "device-test"), None); + assert_eq!( + seen.lock().unwrap().clone(), + vec![ + ("Bearer good".to_owned(), b"{\"n\":1}".to_vec()), + ("Bearer stale".to_owned(), b"{\"n\":2}".to_vec()), + ], + "each body reaches the gateway exactly once per attempt" + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // AC 5: revoking a session leaves the response it is already receiving + // intact; the next request is refused. + #[tokio::test] + async fn revoking_a_session_does_not_cut_a_response_in_flight() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let (release_tx, release_rx) = oneshot::channel::<()>(); + let release = Arc::new(tokio::sync::Mutex::new(Some(release_rx))); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| { + let release = release.clone(); + async move { + let _ = request.into_body().collect().await; + let gate = release.lock().await.take(); + let (tx, rx) = mpsc::channel::, Infallible>>(2); + tokio::spawn(async move { + tx.send(Ok(Frame::data(Bytes::from_static(b"first ")))) + .await + .unwrap(); + if let Some(gate) = gate { + gate.await.unwrap(); + } + let _ = + tx.send(Ok(Frame::data(Bytes::from_static(b"last")))).await; + }); + Ok::<_, Infallible>( + Response::builder() + .header("content-type", "text/event-stream") + .body(StreamBody::new(ReceiverStream::new(rx))) + .unwrap(), + ) + } + }), + ) + .await; + }); + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + let request = post_with( + proxy_address, + "/sandbox/v1/messages", + Some(&session.pairing), + b"{}", + ); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let mut body = response.into_body(); + let first = body.frame().await.unwrap().unwrap().into_data().unwrap(); + assert_eq!(first, "first "); + assert!(sessions.revoke(&session.id)); + release_tx.send(()).unwrap(); + let rest = body.collect().await.unwrap().to_bytes(); + assert_eq!(rest, "last"); + let request = post_with( + proxy_address, + "/sandbox/v1/messages", + Some(&session.pairing), + b"{}", + ); + assert_eq!( + refusal(client.request(request).await.unwrap()).await.1, + "pairing_invalid" + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // --- AC 3: the sandbox request log. + + #[derive(Clone, Default)] + struct LogCapture(Arc>>); + + struct LogWriter(Arc>>); + + impl std::io::Write for LogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture { + type Writer = LogWriter; + + fn make_writer(&'a self) -> LogWriter { + LogWriter(self.0.clone()) + } + } + + const SANDBOX_LOG_MESSAGE: &str = "LLM proxy sandbox request"; + + impl LogCapture { + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } + + fn sandbox_lines(&self) -> Vec { + self.text() + .lines() + .filter(|line| line.contains(SANDBOX_LOG_MESSAGE)) + .map(str::to_owned) + .collect() + } + + /// Waits (up to 5 s) until at least `count` sandbox lines were emitted; + /// the line of a streamed response appears when its body ends. + async fn wait_for_lines(&self, count: usize) -> Vec { + for _ in 0..100 { + let lines = self.sandbox_lines(); + if lines.len() >= count { + return lines; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + let lines = self.sandbox_lines(); + assert!( + lines.len() >= count, + "expected {count} sandbox log lines, got {lines:?}" + ); + lines + } + } + + fn capture_logs() -> (LogCapture, tracing::subscriber::DefaultGuard) { + let capture = LogCapture::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(capture.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::TRACE) + .finish(); + (capture, tracing::subscriber::set_default(subscriber)) + } + + /// The value of `key=value` in a formatted event line, without quotes. + fn log_field(line: &str, key: &str) -> String { + let marker = format!(" {key}="); + let start = line + .find(&marker) + .unwrap_or_else(|| panic!("no field {key} in {line}")) + + marker.len(); + line[start..] + .split(' ') + .next() + .unwrap() + .trim_matches('"') + .to_owned() + } + + /// hyper never polls a body past its declared end, so a `Content-Length` + /// response and an empty one (204) finish through `Drop`: still `complete`. + #[tokio::test] + async fn sandbox_request_log_records_sized_and_empty_responses_as_complete() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| async move { + let path = request.uri().path().to_owned(); + let _ = request.into_body().collect().await; + let response = if path.ends_with("/v1/models") { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Full::new(Bytes::new())) + .unwrap() + } else { + Response::builder() + .header("content-type", "application/json") + .body(Full::new(Bytes::from_static(b"{\"ok\":true}"))) + .unwrap() + }; + Ok::<_, Infallible>(response) + }), + ) + .await; + }); + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + + let sized = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}/sandbox/v1/messages")) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + let response = client.request(sized).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "{\"ok\":true}" + ); + let lines = logs.wait_for_lines(1).await; + assert_eq!(log_field(&lines[0], "status"), "200"); + assert_eq!(log_field(&lines[0], "bytes_out"), "11"); + assert_eq!(log_field(&lines[0], "outcome"), "complete", "{}", lines[0]); + + let empty = Request::builder() + .method(Method::GET) + .uri(format!("http://{proxy_address}/sandbox/v1/models")) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::new())) + .unwrap(); + let response = client.request(empty).await.unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let lines = logs.wait_for_lines(2).await; + assert_eq!(log_field(&lines[1], "status"), "204"); + assert_eq!(log_field(&lines[1], "bytes_out"), "0"); + assert_eq!(log_field(&lines[1], "outcome"), "complete", "{}", lines[1]); + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(logs.sandbox_lines().len(), 2, "one event per request"); + + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn sandbox_request_log_records_a_streamed_200_once_when_the_body_ends() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| async move { + let _ = request.into_body().collect().await; + let (tx, rx) = mpsc::channel::, Infallible>>(2); + tokio::spawn(async move { + tx.send(Ok(Frame::data(Bytes::from_static(b"hello")))) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = tx + .send(Ok(Frame::data(Bytes::from_static(b"world!")))) + .await; + }); + Ok::<_, Infallible>( + Response::builder() + .header("content-type", "text/event-stream") + .body(StreamBody::new(ReceiverStream::new(rx))) + .unwrap(), + ) + }), + ) + .await; + }); + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/sandbox/v1/messages?beta=true" + )) + .header(PAIRING_HEADER, &*session.pairing) + .header(AUTHORIZATION, "Bearer real-key-by-mistake") + .header("x-api-key", "placeholder-key") + .body(Full::new(Bytes::from_static(b"{\"n\":1}"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "helloworld!" + ); + let lines = logs.wait_for_lines(1).await; + tokio::time::sleep(Duration::from_millis(200)).await; + let lines_later = logs.sandbox_lines(); + assert_eq!( + lines_later.len(), + 1, + "exactly one event per request: {lines_later:?}" + ); + let line = &lines[0]; + assert!(line.contains("INFO"), "{line}"); + assert_eq!(log_field(line, "session"), session.id); + assert_eq!(log_field(line, "client_id"), "claude-code"); + assert_eq!(log_field(line, "method"), "POST"); + assert_eq!(log_field(line, "path"), "/v1/messages"); + assert_eq!(log_field(line, "status"), "200"); + assert_eq!(log_field(line, "code"), "-"); + assert_eq!(log_field(line, "bytes_in"), "7"); + assert_eq!(log_field(line, "bytes_out"), "11"); + assert_eq!(log_field(line, "outcome"), "complete"); + log_field(line, "duration_ms").parse::().unwrap(); + // Never headers, bodies, queries or pairings. + let everything = logs.text(); + for secret in [ + &*session.pairing, + PAIRING, + "beta=true", + "real-key-by-mistake", + "placeholder-key", + "{\"n\":1}", + ] { + assert!(!everything.contains(secret), "log leaks {secret}"); + } + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn sandbox_request_log_records_refusals_and_fixed_routes_log_nothing() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(15), async { + let (address, _seen, upstream_task) = recording_upstream().await; + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + + // No session matches: session and client_id are "-". + let wrong = post_with( + proxy_address, + "/sandbox/v1/messages?x=1", + Some(PAIRING), + b"{}", + ); + assert_eq!( + client.request(wrong).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let lines = logs.wait_for_lines(1).await; + let line = &lines[0]; + assert_eq!(log_field(line, "session"), "-"); + assert_eq!(log_field(line, "client_id"), "-"); + assert_eq!(log_field(line, "method"), "POST"); + assert_eq!(log_field(line, "path"), "/v1/messages"); + assert_eq!(log_field(line, "status"), "403"); + assert_eq!(log_field(line, "code"), "pairing_invalid"); + assert_eq!(log_field(line, "bytes_in"), "0"); + + // Refusals before the pairing check are logged too (the path split + // moved above the Host/Origin/OPTIONS checks). + let rebound = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}/sandbox/v1/messages")) + .header(HOST, "attacker.example") + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + client.request(rebound).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let options = Request::builder() + .method(Method::OPTIONS) + .uri(format!("http://{proxy_address}/sandbox/v1/messages")) + .body(Full::new(Bytes::new())) + .unwrap(); + assert_eq!( + client.request(options).await.unwrap().status(), + StatusCode::METHOD_NOT_ALLOWED + ); + // A matched session is recorded on later refusals. + let escape = post_with( + proxy_address, + "/sandbox/../admin", + Some(&session.pairing), + b"{}", + ); + assert_eq!( + client.request(escape).await.unwrap().status(), + StatusCode::BAD_REQUEST + ); + // The fixed routes emit nothing new. + let fixed = post_with(proxy_address, "/copilot-cli/v1/messages", None, b"{}"); + assert_eq!( + client.request(fixed).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + + let lines = logs.wait_for_lines(4).await; + tokio::time::sleep(Duration::from_millis(200)).await; + let lines_later = logs.sandbox_lines(); + assert_eq!(lines_later.len(), 4, "{lines_later:?}"); + assert_eq!(log_field(&lines[1], "session"), "-"); + assert_eq!(log_field(&lines[1], "status"), "403"); + assert_eq!(log_field(&lines[1], "code"), "host_not_allowed"); + assert_eq!(log_field(&lines[1], "path"), "/v1/messages"); + assert_eq!(log_field(&lines[2], "session"), "-"); + assert_eq!(log_field(&lines[2], "status"), "405"); + assert_eq!(log_field(&lines[2], "code"), "method_not_allowed"); + assert_eq!(log_field(&lines[2], "method"), "OPTIONS"); + assert_eq!(log_field(&lines[3], "session"), session.id); + assert_eq!(log_field(&lines[3], "client_id"), "claude-code"); + assert_eq!(log_field(&lines[3], "status"), "400"); + assert_eq!(log_field(&lines[3], "code"), "path_invalid"); + assert_eq!(log_field(&lines[3], "path"), "/../admin"); + let everything = logs.text(); + assert!(!everything.contains(&*session.pairing)); + assert!(!everything.contains("x=1")); + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn sandbox_request_log_marks_a_dropped_response_as_aborted() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| async move { + let _ = request.into_body().collect().await; + let (tx, rx) = mpsc::channel::, Infallible>>(2); + tokio::spawn(async move { + if tx + .send(Ok(Frame::data(Bytes::from_static(b"hello")))) + .await + .is_err() + { + return; + } + // The stream goes on for seconds unless the proxy + // drops it when the client is gone. + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(50)).await; + if tx + .send(Ok(Frame::data(Bytes::from_static(b".")))) + .await + .is_err() + { + return; + } + } + }); + Ok::<_, Infallible>( + Response::builder() + .header("content-type", "text/event-stream") + .body(StreamBody::new(ReceiverStream::new(rx))) + .unwrap(), + ) + }), + ) + .await; + }); + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + let request = post_with( + proxy_address, + "/sandbox/v1/messages", + Some(&session.pairing), + b"{}", + ); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let mut body = response.into_body(); + let first = body.frame().await.unwrap().unwrap().into_data().unwrap(); + assert_eq!(first, "hello"); + // The client goes away after the first chunk. + drop(body); + drop(client); + let lines = logs.wait_for_lines(1).await; + let line = &lines[0]; + assert_eq!(log_field(line, "session"), session.id); + assert_eq!(log_field(line, "status"), "200"); + assert_eq!(log_field(line, "outcome"), "aborted"); + assert!( + log_field(line, "bytes_out").parse::().unwrap() >= 5, + "{line}" + ); + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn sandbox_request_canceled_before_upstream_headers_still_logs() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| async move { + let _ = request.into_body().collect().await; + // Never respond: the client will be aborted first. + tokio::time::sleep(Duration::from_secs(600)).await; + Ok::<_, Infallible>( + Response::builder() + .status(StatusCode::OK) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(), + ) + }), + ) + .await; + }); + let (dir, state) = request_source_state(address).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + + // Client drops before the upstream responds: the handler future + // is dropped and RequestLog's Drop emits with outcome=canceled. + let canceled = tokio::spawn({ + let client = client.clone(); + let pairing = session.pairing.clone(); + async move { + let request = + post_with(proxy_address, "/sandbox/v1/messages", Some(&pairing), b"{}"); + let _ = client.request(request).await; + } + }); + // Give the request time to reach the proxy and upstream. + tokio::time::sleep(Duration::from_millis(100)).await; + canceled.abort(); + let lines = logs.wait_for_lines(1).await; + let line = &lines[0]; + assert_eq!(log_field(line, "session"), session.id); + assert_eq!(log_field(line, "client_id"), "claude-code"); + assert_eq!(log_field(line, "method"), "POST"); + assert_eq!(log_field(line, "path"), "/v1/messages"); + assert_eq!(log_field(line, "status"), "0"); + assert_eq!(log_field(line, "outcome"), "canceled"); + assert_eq!(log_field(line, "bytes_out"), "0"); + // Wait a bit to ensure no duplicate line is emitted. + tokio::time::sleep(Duration::from_millis(100)).await; + let lines_later = logs.sandbox_lines(); + assert_eq!( + lines_later.len(), + 1, + "canceled request should log exactly once" + ); + + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } } diff --git a/crates/agent/src/llm_proxy/sessions.rs b/crates/agent/src/llm_proxy/sessions.rs new file mode 100644 index 00000000..ecd261ce --- /dev/null +++ b/crates/agent/src/llm_proxy/sessions.rs @@ -0,0 +1,1392 @@ +//! Per-session pairing for the `/sandbox` route and the local API that manages +//! sessions. +//! +//! A session is minted over the local API by whoever starts a sandboxed +//! program (the launcher, the CLI, a test). It binds a one-off pairing secret +//! to a credential-policy client id; the proxy accepts that secret on the +//! `/sandbox` route only and presents the session's client id to the gateway. +//! Sessions live in memory: a daemon restart drops them all. + +use std::{ + net::SocketAddr, + sync::{Arc, Mutex}, + time::Duration, +}; + +use agentdesktop_core::model::{SessionCreated, SessionInfo, SessionRequest}; +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + routing::{delete, get}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use sha2::{Digest, Sha256}; + +use super::constant_time_eq; + +/// Idle timeout a session gets when the request names none. +pub(crate) const DEFAULT_IDLE_TTL: Duration = Duration::from_secs(28_800); +/// Bounds for a requested idle timeout, in seconds. +const MIN_TTL_SECONDS: u64 = 60; +const MAX_TTL_SECONDS: u64 = 86_400; +/// Unexpired sessions a daemon holds at most: a launcher that leaks sessions +/// fails loudly instead of growing the registry. +pub(crate) const MAX_SESSIONS: usize = 64; +/// Longest a label may be, in Unicode scalar values. +const MAX_LABEL_CHARS: usize = 128; + +/// Why a session could not be registered. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SessionError { + InvalidClientId, + InvalidLabel, + Full, +} + +/// A registered session as returned by `register`; the only place the +/// plaintext pairing exists. +#[derive(Clone)] +pub(crate) struct Session { + pub id: String, + pub pairing: Arc, + pub client_id: String, + pub label: String, + pub created_at_unix_seconds: u64, + pub expires_at_unix_seconds: u64, +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session") + .field("id", &self.id) + .field("pairing", &"") + .field("client_id", &self.client_id) + .field("label", &self.label) + .field("created_at_unix_seconds", &self.created_at_unix_seconds) + .field("expires_at_unix_seconds", &self.expires_at_unix_seconds) + .finish() + } +} + +/// What the proxy learns about a request's session. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SessionRef { + pub id: String, + pub client_id: String, +} + +/// One registered session. Only the SHA-256 digest of the pairing is kept, so +/// a memory dump or a `Debug` print never yields a usable secret. +struct Entry { + id: String, + pairing_hash: [u8; 32], + client_id: String, + label: String, + idle_ttl: Duration, + created_at_unix_seconds: u64, + expires_at_unix_seconds: u64, +} + +impl Entry { + fn expired(&self, now: u64) -> bool { + self.expires_at_unix_seconds <= now + } + + fn info(&self) -> SessionInfo { + SessionInfo { + id: self.id.clone(), + client_id: self.client_id.clone(), + label: self.label.clone(), + created_at_unix_seconds: self.created_at_unix_seconds, + expires_at_unix_seconds: self.expires_at_unix_seconds, + } + } +} + +/// The sessions the proxy accepts on the `/sandbox` route. Clones share one +/// registry. The lock is held for plain memory work only, never across an +/// `.await`. +#[derive(Clone, Default)] +pub(crate) struct SessionRegistry { + entries: Arc>>, +} + +fn pairing_hash(pairing: &str) -> [u8; 32] { + Sha256::digest(pairing.as_bytes()).into() +} + +fn expiry(now: u64, idle_ttl: Duration) -> u64 { + now.saturating_add(idle_ttl.as_secs()) +} + +impl SessionRegistry { + fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { + self.entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Registers a session that expires `idle_ttl` after its last request. + /// Expired entries are swept first; the bound counts unexpired ones. + pub(crate) fn register( + &self, + client_id: &str, + label: &str, + idle_ttl: Duration, + now: u64, + ) -> Result { + if !agentdesktop_core::config::valid_client_id(client_id) { + return Err(SessionError::InvalidClientId); + } + if !valid_label(label) { + return Err(SessionError::InvalidLabel); + } + let id = format!("{:016x}", rand::random::()); + let secret: [u8; 32] = rand::random(); + let pairing: Arc = URL_SAFE_NO_PAD.encode(secret).into(); + let mut entries = self.lock(); + entries.retain(|entry| !entry.expired(now)); + if entries.len() >= MAX_SESSIONS { + return Err(SessionError::Full); + } + let expires_at_unix_seconds = expiry(now, idle_ttl); + entries.push(Entry { + id: id.clone(), + pairing_hash: pairing_hash(&pairing), + client_id: client_id.to_owned(), + label: label.to_owned(), + idle_ttl, + created_at_unix_seconds: now, + expires_at_unix_seconds, + }); + Ok(Session { + id, + pairing, + client_id: client_id.to_owned(), + label: label.to_owned(), + created_at_unix_seconds: now, + expires_at_unix_seconds, + }) + } + + /// The session a pairing belongs to, with its idle timeout restarted + /// (`expires_at = max(expires_at, now + idle_ttl)`). The digest is compared + /// against every entry without an early return, so the time a lookup takes + /// does not depend on which entry matches. An expired pairing is removed on + /// observation, so it cannot become valid again even if the system clock + /// steps backward. + pub(crate) fn authorize(&self, pairing: &str, now: u64) -> Option { + let offered = pairing_hash(pairing); + let mut entries = self.lock(); + let mut found = None; + let mut expired_index = None; + for (index, entry) in entries.iter().enumerate() { + let expired = entry.expired(now); + let matches = constant_time_eq(&offered, &entry.pairing_hash) & !expired; + if matches { + found = Some(index); + } + // When the offered pairing matches an expired entry, remember it so + // it can be removed after the constant-time loop, preventing revival + // on a backward clock step. + if constant_time_eq(&offered, &entry.pairing_hash) & expired { + expired_index = Some(index); + } + } + if let Some(index) = expired_index { + entries.remove(index); + return None; + } + let entry = &mut entries[found?]; + entry.expires_at_unix_seconds = entry + .expires_at_unix_seconds + .max(expiry(now, entry.idle_ttl)); + Some(SessionRef { + id: entry.id.clone(), + client_id: entry.client_id.clone(), + }) + } + + /// Removes the session; `false` when no session has that id. + pub(crate) fn revoke(&self, id: &str) -> bool { + let mut entries = self.lock(); + let before = entries.len(); + entries.retain(|entry| entry.id != id); + entries.len() != before + } + + /// Unexpired sessions, oldest first, without their pairings. + pub(crate) fn list(&self, now: u64) -> Vec { + let entries = self.lock(); + let mut listed: Vec<_> = entries + .iter() + .filter(|entry| !entry.expired(now)) + .map(Entry::info) + .collect(); + listed.sort_by_key(|info| info.created_at_unix_seconds); + listed + } + + /// Drops expired sessions. + pub(crate) fn sweep(&self, now: u64) { + self.lock().retain(|entry| !entry.expired(now)); + } + + /// Number of unexpired sessions. + pub(crate) fn len(&self, now: u64) -> usize { + self.lock() + .iter() + .filter(|entry| !entry.expired(now)) + .count() + } +} + +/// A label is 1-128 Unicode scalar values with no control characters. +pub(crate) fn valid_label(label: &str) -> bool { + let count = label.chars().count(); + (1..=MAX_LABEL_CHARS).contains(&count) && !label.chars().any(char::is_control) +} + +/// Decides whether a client id may be registered as a session: the effective +/// configuration's credential policy, read when the session is created so a +/// refused id fails at `session create` and not at the first prompt. +#[async_trait::async_trait] +pub(crate) trait SessionPolicy: Send + Sync { + async fn check(&self, client_id: &str) -> Result<(), (StatusCode, String)>; +} + +/// The production policy: the effective configuration's gateway +/// authentication and program settings, reloaded on every check. +pub(crate) struct ConfigPolicy { + pub config: agentdesktop_core::config::DaemonConfig, + pub state_dir: std::path::PathBuf, +} + +#[async_trait::async_trait] +impl SessionPolicy for ConfigPolicy { + async fn check(&self, client_id: &str) -> Result<(), (StatusCode, String)> { + let effective = + crate::api::load_effective_config(&self.config, &self.state_dir).map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("could not read the applied configuration: {error:#}"), + ) + })?; + // With controller-issued credentials the gateway policy names the + // client ids it serves; a session for another id would fail at its + // first prompt. OIDC tokens carry no client id. + if let Some(agentdesktop_core::config::LlmGatewayAuthentication::ControllerJwt { + allowed_client_ids, + .. + }) = effective + .llm_gateway + .as_ref() + .and_then(|gateway| gateway.authentication.as_ref()) + && !allowed_client_ids.contains(client_id) + { + return Err(( + StatusCode::CONFLICT, + format!( + "client id {client_id} is not in llmGateway.authentication.allowedClientIds" + ), + )); + } + // The sandbox route presents the gateway identity only; a program in + // subscription mode would silently switch billing path inside the box. + if crate::api::program_uses_subscription(&effective, client_id) { + return Err(( + StatusCode::CONFLICT, + "subscription mode is not supported on the sandbox route".to_owned(), + )); + } + Ok(()) + } +} + +/// How often expired sessions are dropped from the registry. +pub(crate) const SWEEP_INTERVAL: Duration = Duration::from_secs(60); + +/// Drops expired sessions every `SWEEP_INTERVAL`, for as long as the daemon runs. +pub(crate) async fn sweep_forever(registry: SessionRegistry) { + let mut interval = tokio::time::interval(SWEEP_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + registry.sweep(super::now_unix()); + } +} + +#[derive(Clone)] +pub(crate) struct SessionApiState { + pub registry: SessionRegistry, + /// The bound proxy listener; `None` when the proxy is off. + pub proxy_address: Option, + pub policy: Arc, +} + +/// `POST`/`GET /v1/llm-proxy/sessions` and `DELETE /v1/llm-proxy/sessions/{id}` +/// on the local API. Errors are plain text like the rest of the local API. +pub(crate) fn session_router(state: SessionApiState) -> Router { + Router::new() + .route( + "/v1/llm-proxy/sessions", + get(list_sessions).post(create_session), + ) + .route("/v1/llm-proxy/sessions/{id}", delete(revoke_session)) + .with_state(state) +} + +type ApiError = (StatusCode, String); + +async fn create_session( + State(state): State, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let Some(address) = state.proxy_address else { + return Err(( + StatusCode::CONFLICT, + "the local LLM proxy is not running (daemon.llmProxy.listen)".to_owned(), + )); + }; + if !agentdesktop_core::config::valid_client_id(&request.client_id) { + return Err(( + StatusCode::BAD_REQUEST, + format!( + "clientId {:?} is not a valid client id (1-64 characters from A-Z, a-z, 0-9, '.', '_' and '-')", + request.client_id + ), + )); + } + if !valid_label(&request.label) { + return Err(( + StatusCode::BAD_REQUEST, + format!("label must be 1-{MAX_LABEL_CHARS} characters without control characters"), + )); + } + let idle_ttl = match request.ttl_seconds { + None => DEFAULT_IDLE_TTL, + Some(seconds) if (MIN_TTL_SECONDS..=MAX_TTL_SECONDS).contains(&seconds) => { + Duration::from_secs(seconds) + } + Some(_) => { + return Err(( + StatusCode::BAD_REQUEST, + format!("ttlSeconds must be between {MIN_TTL_SECONDS} and {MAX_TTL_SECONDS}"), + )); + } + }; + state.policy.check(&request.client_id).await?; + let session = state + .registry + .register( + &request.client_id, + &request.label, + idle_ttl, + super::now_unix(), + ) + .map_err(|error| match error { + SessionError::Full => ( + StatusCode::TOO_MANY_REQUESTS, + format!("too many sessions ({MAX_SESSIONS}); revoke unused ones"), + ), + // Both were checked above; a race with the registry's own + // validation would be a bug, reported as such. + SessionError::InvalidClientId | SessionError::InvalidLabel => ( + StatusCode::BAD_REQUEST, + "invalid session request".to_owned(), + ), + })?; + Ok(( + StatusCode::CREATED, + Json(SessionCreated { + id: session.id, + pairing: session.pairing.to_string(), + client_id: session.client_id, + label: session.label, + created_at_unix_seconds: session.created_at_unix_seconds, + expires_at_unix_seconds: session.expires_at_unix_seconds, + base_url: sandbox_base_url(address), + }), + )) +} + +/// What a client sets as its API base URL to reach the sandbox route. +pub(crate) fn sandbox_base_url(address: SocketAddr) -> String { + format!("http://{address}{}", super::SANDBOX_ROUTE) +} + +async fn list_sessions(State(state): State) -> Json> { + Json(state.registry.list(super::now_unix())) +} + +async fn revoke_session( + State(state): State, + Path(id): Path, +) -> Result { + // A session that has idled out is gone for the caller even before the + // sweep has dropped it: it is neither listed nor revocable. + state.registry.sweep(super::now_unix()); + if state.registry.revoke(&id) { + Ok(StatusCode::NO_CONTENT) + } else { + Err((StatusCode::NOT_FOUND, format!("no session {id}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agentdesktop_core::model::{SessionCreated, SessionInfo}; + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + const TTL: Duration = Duration::from_secs(600); + + fn register_ok( + registry: &SessionRegistry, + client_id: &str, + label: &str, + ttl: Duration, + now: u64, + ) -> Session { + match registry.register(client_id, label, ttl, now) { + Ok(session) => session, + Err(_) => panic!("register({client_id:?}, {label:?}) failed"), + } + } + + fn register_err( + registry: &SessionRegistry, + client_id: &str, + label: &str, + now: u64, + ) -> Option { + registry.register(client_id, label, TTL, now).err() + } + + fn expires_of(registry: &SessionRegistry, id: &str, now: u64) -> u64 { + registry + .list(now) + .into_iter() + .find(|info| info.id == id) + .unwrap_or_else(|| panic!("session {id} not listed")) + .expires_at_unix_seconds + } + + // --- AC 1: the registry. + + #[test] + fn register_returns_a_well_formed_session() { + let registry = SessionRegistry::default(); + let session = register_ok(®istry, "claude-code", "lab", TTL, 1_000); + assert_eq!(session.id.len(), 16); + assert!( + session + .id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "{}", + session.id + ); + assert_eq!(session.pairing.len(), 43); + assert!( + session + .pairing + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'), + "pairing must be URL-safe unpadded base64" + ); + assert_eq!(URL_SAFE_NO_PAD.decode(&*session.pairing).unwrap().len(), 32); + assert_eq!(session.client_id, "claude-code"); + assert_eq!(session.label, "lab"); + assert_eq!(session.created_at_unix_seconds, 1_000); + assert_eq!(session.expires_at_unix_seconds, 1_600); + + let other = register_ok(®istry, "claude-code", "lab", TTL, 1_000); + assert_ne!(session.id, other.id); + assert_ne!(session.pairing, other.pairing); + } + + #[test] + fn authorize_accepts_the_registered_pairing_and_nothing_else() { + let registry = SessionRegistry::default(); + let session = register_ok(®istry, "claude-code", "lab", TTL, 1_000); + let other = register_ok(®istry, "my_tool", "other", TTL, 1_000); + + let found = registry.authorize(&session.pairing, 1_001).unwrap(); + assert_eq!(found.id, session.id); + assert_eq!(found.client_id, "claude-code"); + let found = registry.authorize(&other.pairing, 1_001).unwrap(); + assert_eq!(found.id, other.id); + assert_eq!(found.client_id, "my_tool"); + + // Same length, one character different. + let last = session.pairing.chars().last().unwrap(); + let swapped = if last == 'A' { 'B' } else { 'A' }; + let wrong = format!("{}{swapped}", &session.pairing[..session.pairing.len() - 1]); + assert_eq!(wrong.len(), session.pairing.len()); + assert!(registry.authorize(&wrong, 1_001).is_none()); + assert!(registry.authorize("", 1_001).is_none()); + assert!(registry.authorize(&session.id, 1_001).is_none()); + assert!( + registry + .authorize(&format!("{}x", session.pairing), 1_001) + .is_none() + ); + } + + #[test] + fn revoke_removes_one_session_and_reports_whether_it_existed() { + let registry = SessionRegistry::default(); + let first = register_ok(®istry, "claude-code", "one", TTL, 1_000); + let second = register_ok(®istry, "claude-code", "two", TTL, 1_000); + assert!(!registry.revoke("0000000000000000")); + assert!(registry.revoke(&first.id)); + assert!(!registry.revoke(&first.id)); + assert!(registry.authorize(&first.pairing, 1_001).is_none()); + assert!(registry.authorize(&second.pairing, 1_001).is_some()); + assert_eq!(registry.len(1_001), 1); + } + + #[test] + fn a_session_expires_after_its_idle_timeout() { + let registry = SessionRegistry::default(); + let session = register_ok( + ®istry, + "claude-code", + "lab", + Duration::from_secs(60), + 1_000, + ); + assert!(registry.authorize(&session.pairing, 1_059).is_some()); + // The use at 1_059 moved the expiry to 1_119. + assert!(registry.authorize(&session.pairing, 1_118).is_some()); + assert!(registry.authorize(&session.pairing, 1_300).is_none()); + // An expired session stays expired, also for a later caller. + assert!(registry.authorize(&session.pairing, 1_301).is_none()); + assert_eq!(registry.len(1_300), 0); + assert!(registry.list(1_300).is_empty()); + } + + #[test] + fn an_expired_pairing_cannot_revive_on_a_backward_clock_step() { + let registry = SessionRegistry::default(); + let session = register_ok( + ®istry, + "claude-code", + "lab", + Duration::from_secs(60), + 1_000, + ); + // Session expires at 1_060. At 1_061 it is expired and rejected. + assert!(registry.authorize(&session.pairing, 1_061).is_none()); + // The rejection removed the entry. Even if the clock steps back to + // 1_059 (before the sweep at ≤1_121), the pairing is gone. + assert!(registry.authorize(&session.pairing, 1_059).is_none()); + assert_eq!(registry.len(1_059), 0); + } + + #[test] + fn authorize_slides_the_expiry_to_now_plus_the_idle_timeout_and_never_backwards() { + let registry = SessionRegistry::default(); + let session = register_ok(®istry, "claude-code", "lab", TTL, 1_000); + assert_eq!(expires_of(®istry, &session.id, 1_000), 1_600); + + assert!(registry.authorize(&session.pairing, 1_100).is_some()); + assert_eq!(expires_of(®istry, &session.id, 1_100), 1_700); + + // Two authorizations in the same second do not add twice. + assert!(registry.authorize(&session.pairing, 1_100).is_some()); + assert!(registry.authorize(&session.pairing, 1_100).is_some()); + assert_eq!(expires_of(®istry, &session.id, 1_100), 1_700); + + // A clock that reads earlier does not pull the expiry back. + assert!(registry.authorize(&session.pairing, 900).is_some()); + assert_eq!(expires_of(®istry, &session.id, 1_100), 1_700); + + // Continuous use outlives the original lifetime. + for now in (1_100..5_000).step_by(300) { + assert!(registry.authorize(&session.pairing, now).is_some(), "{now}"); + } + } + + #[test] + fn the_bound_is_64_unexpired_sessions() { + let registry = SessionRegistry::default(); + let mut sessions = Vec::new(); + for n in 0..64 { + sessions.push(register_ok( + ®istry, + "claude-code", + &format!("s{n}"), + TTL, + 1_000, + )); + } + assert_eq!(registry.len(1_000), 64); + assert_eq!( + register_err(®istry, "claude-code", "one too many", 1_000), + Some(SessionError::Full) + ); + assert!(registry.revoke(&sessions[10].id)); + assert!( + registry + .register("claude-code", "fits again", TTL, 1_000) + .is_ok() + ); + assert_eq!( + register_err(®istry, "claude-code", "full again", 1_000), + Some(SessionError::Full) + ); + } + + #[test] + fn an_expired_but_unswept_session_does_not_count_towards_the_bound() { + let registry = SessionRegistry::default(); + register_ok( + ®istry, + "claude-code", + "short", + Duration::from_secs(60), + 1_000, + ); + for n in 0..63 { + register_ok( + ®istry, + "claude-code", + &format!("s{n}"), + Duration::from_secs(100_000), + 1_000, + ); + } + // 64 entries are stored, one of them expired at 1_100 and never swept. + assert_eq!(registry.len(1_100), 63); + assert!(registry.register("claude-code", "fits", TTL, 1_100).is_ok()); + assert_eq!(registry.len(1_100), 64); + assert_eq!( + register_err(®istry, "claude-code", "full", 1_100), + Some(SessionError::Full) + ); + } + + #[test] + fn sweep_removes_expired_sessions_and_keeps_live_ones() { + let registry = SessionRegistry::default(); + let short = register_ok( + ®istry, + "claude-code", + "short", + Duration::from_secs(60), + 1_000, + ); + let long = register_ok( + ®istry, + "claude-code", + "long", + Duration::from_secs(100_000), + 1_000, + ); + registry.sweep(2_000); + assert_eq!(registry.len(2_000), 1); + assert!(registry.authorize(&short.pairing, 2_000).is_none()); + assert!(!registry.revoke(&short.id)); + assert!(registry.authorize(&long.pairing, 2_000).is_some()); + } + + #[test] + fn list_returns_unexpired_sessions_oldest_first_without_pairings() { + let registry = SessionRegistry::default(); + let late = register_ok(®istry, "claude-code", "late", TTL, 1_030); + let early = register_ok(®istry, "my_tool", "early", TTL, 1_010); + let middle = register_ok(®istry, "claude-code", "middle", TTL, 1_020); + register_ok( + ®istry, + "claude-code", + "gone", + Duration::from_secs(5), + 1_000, + ); + + let listed = registry.list(1_040); + assert_eq!( + listed, + vec![ + SessionInfo { + id: early.id.clone(), + client_id: "my_tool".into(), + label: "early".into(), + created_at_unix_seconds: 1_010, + expires_at_unix_seconds: 1_610, + }, + SessionInfo { + id: middle.id.clone(), + client_id: "claude-code".into(), + label: "middle".into(), + created_at_unix_seconds: 1_020, + expires_at_unix_seconds: 1_620, + }, + SessionInfo { + id: late.id.clone(), + client_id: "claude-code".into(), + label: "late".into(), + created_at_unix_seconds: 1_030, + expires_at_unix_seconds: 1_630, + }, + ] + ); + } + + #[test] + fn a_clone_shares_the_sessions_of_the_original() { + let registry = SessionRegistry::default(); + let clone = registry.clone(); + let session = register_ok(&clone, "claude-code", "lab", TTL, 1_000); + assert!(registry.authorize(&session.pairing, 1_001).is_some()); + assert!(registry.revoke(&session.id)); + assert!(clone.authorize(&session.pairing, 1_001).is_none()); + } + + #[test] + fn client_ids_are_validated_like_credential_policy_ids() { + let registry = SessionRegistry::default(); + for valid in ["my_tool", "a", "claude-code", "tool.v2", &"a".repeat(64)] { + assert!( + registry.register(valid, "lab", TTL, 1_000).is_ok(), + "{valid}" + ); + } + for invalid in ["", "bad id", "a/b", &"a".repeat(65)] { + assert_eq!( + register_err(®istry, invalid, "lab", 1_000), + Some(SessionError::InvalidClientId), + "{invalid:?}" + ); + } + } + + #[test] + fn labels_are_one_to_128_scalar_values_without_control_characters() { + let registry = SessionRegistry::default(); + let max = "x".repeat(128); + let multibyte = "é".repeat(128); + for valid in ["a", "lab session", "Sitzung ü 日本", &max, &multibyte] { + assert!(valid_label(valid), "{valid:?}"); + assert!( + registry.register("claude-code", valid, TTL, 1_000).is_ok(), + "{valid:?}" + ); + } + let too_long = "x".repeat(129); + let too_long_multibyte = "é".repeat(129); + for invalid in [ + "", + "a\u{7}b", + "line\nbreak", + "tab\there", + "\u{7f}", + &too_long, + &too_long_multibyte, + ] { + assert!(!valid_label(invalid), "{invalid:?}"); + assert_eq!( + register_err(®istry, "claude-code", invalid, 1_000), + Some(SessionError::InvalidLabel), + "{invalid:?}" + ); + } + } + + #[test] + fn debug_output_of_a_session_redacts_the_pairing() { + let registry = SessionRegistry::default(); + let session = register_ok(®istry, "claude-code", "lab", TTL, 1_000); + let debug = format!("{session:?}"); + assert!(debug.contains(""), "{debug}"); + assert!(!debug.contains(&*session.pairing), "{debug}"); + } + + // --- AC 4: the session API on the local API. + + fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + } + + /// Stub policy: records the ids it is asked about and answers `answer`. + struct RecordingPolicy { + seen: Mutex>, + answer: Result<(), (StatusCode, String)>, + } + + impl RecordingPolicy { + fn allowing() -> Arc { + Arc::new(Self { + seen: Mutex::default(), + answer: Ok(()), + }) + } + + fn refusing(status: StatusCode, message: &str) -> Arc { + Arc::new(Self { + seen: Mutex::default(), + answer: Err((status, message.to_owned())), + }) + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + } + + #[async_trait::async_trait] + impl SessionPolicy for RecordingPolicy { + async fn check(&self, client_id: &str) -> Result<(), (StatusCode, String)> { + self.seen.lock().unwrap().push(client_id.to_owned()); + self.answer.clone() + } + } + + struct Api { + registry: SessionRegistry, + policy: Arc, + app: Router, + } + + fn api_with( + proxy_address: Option, + policy: Arc, + registry: SessionRegistry, + ) -> Api { + let app = session_router(SessionApiState { + registry: registry.clone(), + proxy_address, + policy: policy.clone(), + }); + Api { + registry, + policy, + app, + } + } + + fn api() -> Api { + api_with( + Some("127.0.0.1:4000".parse().unwrap()), + RecordingPolicy::allowing(), + SessionRegistry::default(), + ) + } + + struct Reply { + status: u16, + headers: String, + body: String, + } + + impl Reply { + fn json(&self) -> T { + serde_json::from_str(&self.body) + .unwrap_or_else(|error| panic!("{error}: {}", self.body)) + } + } + + /// One request over an in-memory connection served like the local API. + async fn call(app: &Router, method: &str, path: &str, body: Option<(&str, &str)>) -> Reply { + let (mut client, server) = tokio::io::duplex(64 * 1024); + let connection = tokio::spawn(crate::daemon::serve_local_connection(server, app.clone())); + let mut request = + format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n"); + match body { + Some((content_type, body)) => request.push_str(&format!( + "Content-Type: {content_type}\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + )), + None => request.push_str("Content-Length: 0\r\n\r\n"), + } + client.write_all(request.as_bytes()).await.unwrap(); + let mut response = String::new(); + tokio::time::timeout( + Duration::from_secs(10), + client.read_to_string(&mut response), + ) + .await + .expect("response in time") + .unwrap(); + let _ = connection.await; + assert!(!response.is_empty(), "no response (the handler panicked?)"); + let (head, body) = response.split_once("\r\n\r\n").expect("response head"); + let status = head + .split_whitespace() + .nth(1) + .and_then(|status| status.parse().ok()) + .unwrap_or_else(|| panic!("status line in {head}")); + Reply { + status, + headers: head.to_ascii_lowercase(), + body: body.to_owned(), + } + } + + async fn post(app: &Router, body: &str) -> Reply { + call( + app, + "POST", + "/v1/llm-proxy/sessions", + Some(("application/json", body)), + ) + .await + } + + #[tokio::test] + async fn post_registers_a_session_and_returns_it_with_the_pairing_and_base_url() { + let api = api(); + let before = unix_now(); + let reply = post(&api.app, r#"{"clientId":"claude-code","label":"lab"}"#).await; + assert_eq!(reply.status, 201, "{}", reply.body); + let created: SessionCreated = reply.json(); + assert_eq!(created.id.len(), 16); + assert_eq!(created.pairing.len(), 43); + assert_eq!(created.client_id, "claude-code"); + assert_eq!(created.label, "lab"); + assert!(created.created_at_unix_seconds >= before); + assert!(created.created_at_unix_seconds <= unix_now()); + assert_eq!( + created.expires_at_unix_seconds - created.created_at_unix_seconds, + 28_800, + "default idle timeout" + ); + assert_eq!(created.base_url, "http://127.0.0.1:4000/sandbox"); + let json: serde_json::Value = serde_json::from_str(&reply.body).unwrap(); + assert!(json.get("pairing").is_some()); + assert!(json.get("baseUrl").is_some()); + assert_eq!(api.policy.seen(), vec!["claude-code".to_owned()]); + // The registry the router was built with holds the session. + let found = api + .registry + .authorize(&created.pairing, unix_now()) + .unwrap(); + assert_eq!(found.id, created.id); + assert_eq!(found.client_id, "claude-code"); + } + + #[tokio::test] + async fn post_brackets_an_ipv6_proxy_address_in_the_base_url() { + let api = api_with( + Some("[::1]:4000".parse().unwrap()), + RecordingPolicy::allowing(), + SessionRegistry::default(), + ); + let reply = post(&api.app, r#"{"clientId":"claude-code","label":"lab"}"#).await; + assert_eq!(reply.status, 201, "{}", reply.body); + assert_eq!( + reply.json::().base_url, + "http://[::1]:4000/sandbox" + ); + } + + #[tokio::test] + async fn post_honours_ttl_seconds_within_60_and_86400_and_rejects_the_rest() { + let api = api(); + for ttl in [60u64, 120, 86_400] { + let reply = post( + &api.app, + &format!(r#"{{"clientId":"claude-code","label":"lab","ttlSeconds":{ttl}}}"#), + ) + .await; + assert_eq!(reply.status, 201, "{ttl}: {}", reply.body); + let created: SessionCreated = reply.json(); + assert_eq!( + created.expires_at_unix_seconds - created.created_at_unix_seconds, + ttl + ); + } + for ttl in [0u64, 59, 86_401, 1_000_000] { + let reply = post( + &api.app, + &format!(r#"{{"clientId":"claude-code","label":"lab","ttlSeconds":{ttl}}}"#), + ) + .await; + assert_eq!(reply.status, 400, "{ttl}"); + assert!( + reply + .body + .contains("ttlSeconds must be between 60 and 86400"), + "{}", + reply.body + ); + } + assert_eq!( + api.registry.len(unix_now()), + 3, + "a rejected ttl registers nothing" + ); + } + + #[tokio::test] + async fn post_without_a_running_proxy_is_409_before_anything_else_is_checked() { + let api = api_with( + None, + RecordingPolicy::allowing(), + SessionRegistry::default(), + ); + // Even an invalid client id gets the 409: the proxy check comes first. + for body in [ + r#"{"clientId":"claude-code","label":"lab"}"#, + r#"{"clientId":"bad id","label":"lab"}"#, + ] { + let reply = post(&api.app, body).await; + assert_eq!(reply.status, 409, "{body}"); + assert!( + reply + .body + .contains("the local LLM proxy is not running (daemon.llmProxy.listen)"), + "{}", + reply.body + ); + } + assert!(api.policy.seen().is_empty()); + assert_eq!(api.registry.len(unix_now()), 0); + } + + #[tokio::test] + async fn post_rejects_an_invalid_client_id_with_400_before_the_policy_is_asked() { + let api = api_with( + Some("127.0.0.1:4000".parse().unwrap()), + RecordingPolicy::refusing(StatusCode::CONFLICT, "policy says no"), + SessionRegistry::default(), + ); + for client_id in ["", "bad id", &"a".repeat(65)] { + let reply = post( + &api.app, + &format!(r#"{{"clientId":"{client_id}","label":"lab"}}"#), + ) + .await; + assert_eq!(reply.status, 400, "{client_id:?}: {}", reply.body); + } + assert!(api.policy.seen().is_empty()); + } + + #[tokio::test] + async fn post_rejects_an_invalid_label_with_400_before_the_policy_is_asked() { + let api = api_with( + Some("127.0.0.1:4000".parse().unwrap()), + RecordingPolicy::refusing(StatusCode::CONFLICT, "policy says no"), + SessionRegistry::default(), + ); + let long = "x".repeat(129); + for label in ["", "bell\\u0007", "new\\nline", long.as_str()] { + let reply = post( + &api.app, + &format!(r#"{{"clientId":"claude-code","label":"{label}"}}"#), + ) + .await; + assert_eq!(reply.status, 400, "{label:?}: {}", reply.body); + } + assert!(api.policy.seen().is_empty()); + assert_eq!(api.registry.len(unix_now()), 0); + } + + #[tokio::test] + async fn post_checks_the_ttl_before_the_policy() { + let api = api_with( + Some("127.0.0.1:4000".parse().unwrap()), + RecordingPolicy::refusing(StatusCode::CONFLICT, "policy says no"), + SessionRegistry::default(), + ); + let reply = post( + &api.app, + r#"{"clientId":"claude-code","label":"lab","ttlSeconds":5}"#, + ) + .await; + assert_eq!(reply.status, 400); + assert!(api.policy.seen().is_empty()); + } + + #[tokio::test] + async fn post_passes_the_policy_refusal_through_and_registers_nothing() { + for (status, message) in [ + ( + StatusCode::CONFLICT, + "client id not-allowed is not in llmGateway.authentication.allowedClientIds", + ), + ( + StatusCode::INTERNAL_SERVER_ERROR, + "could not read the configuration", + ), + ] { + let api = api_with( + Some("127.0.0.1:4000".parse().unwrap()), + RecordingPolicy::refusing(status, message), + SessionRegistry::default(), + ); + let reply = post(&api.app, r#"{"clientId":"not-allowed","label":"lab"}"#).await; + assert_eq!(reply.status, status.as_u16()); + assert!(reply.body.contains(message), "{}", reply.body); + assert_eq!(api.policy.seen(), vec!["not-allowed".to_owned()]); + assert_eq!(api.registry.len(unix_now()), 0); + } + } + + #[tokio::test] + async fn post_is_429_when_64_sessions_exist_and_the_policy_is_asked_first() { + let api = api(); + let now = unix_now(); + for n in 0..64 { + register_ok( + &api.registry, + "claude-code", + &format!("s{n}"), + Duration::from_secs(3_600), + now, + ); + } + let reply = post( + &api.app, + r#"{"clientId":"claude-code","label":"one too many"}"#, + ) + .await; + assert_eq!(reply.status, 429, "{}", reply.body); + assert!( + reply + .body + .contains("too many sessions (64); revoke unused ones"), + "{}", + reply.body + ); + assert_eq!(api.policy.seen(), vec!["claude-code".to_owned()]); + + // With a refusing policy the refusal wins over the bound. + let refusing = api_with( + Some("127.0.0.1:4000".parse().unwrap()), + RecordingPolicy::refusing(StatusCode::CONFLICT, "policy says no"), + api.registry.clone(), + ); + let reply = post(&refusing.app, r#"{"clientId":"claude-code","label":"x"}"#).await; + assert_eq!(reply.status, 409); + } + + #[tokio::test] + async fn post_body_rejections_are_the_ones_axum_produces() { + let api = api(); + let wrong_type = call( + &api.app, + "POST", + "/v1/llm-proxy/sessions", + Some(("text/plain", r#"{"clientId":"claude-code","label":"lab"}"#)), + ) + .await; + assert_eq!(wrong_type.status, 415); + let malformed = post(&api.app, r#"{"clientId":"#).await; + assert_eq!(malformed.status, 400); + let missing_label = post(&api.app, r#"{"clientId":"claude-code"}"#).await; + assert_eq!(missing_label.status, 422); + let wrong_type_field = post( + &api.app, + r#"{"clientId":"claude-code","label":"x","ttlSeconds":"soon"}"#, + ) + .await; + assert_eq!(wrong_type_field.status, 422); + assert!(api.policy.seen().is_empty()); + assert_eq!(api.registry.len(unix_now()), 0); + } + + #[tokio::test] + async fn post_ignores_unknown_fields() { + let api = api(); + let reply = post( + &api.app, + r#"{"clientId":"claude-code","label":"lab","somethingNew":true}"#, + ) + .await; + assert_eq!(reply.status, 201, "{}", reply.body); + } + + #[tokio::test] + async fn get_lists_sessions_oldest_first_without_any_pairing() { + let api = api(); + let empty = call(&api.app, "GET", "/v1/llm-proxy/sessions", None).await; + assert_eq!(empty.status, 200); + assert_eq!(empty.json::>(), vec![]); + + let now = unix_now(); + let ttl = Duration::from_secs(3_600); + let newest = register_ok(&api.registry, "claude-code", "newest", ttl, now - 5); + let oldest = register_ok(&api.registry, "my_tool", "oldest", ttl, now - 20); + let middle = register_ok(&api.registry, "claude-code", "middle", ttl, now - 10); + let created = post(&api.app, r#"{"clientId":"claude-code","label":"via api"}"#).await; + assert_eq!(created.status, 201); + let created: SessionCreated = created.json(); + + let reply = call(&api.app, "GET", "/v1/llm-proxy/sessions", None).await; + assert_eq!(reply.status, 200); + assert!( + reply.headers.contains("application/json"), + "{}", + reply.headers + ); + let listed: Vec = reply.json(); + let ids: Vec<&str> = listed.iter().map(|info| info.id.as_str()).collect(); + assert_eq!( + ids, + [ + oldest.id.as_str(), + middle.id.as_str(), + newest.id.as_str(), + created.id.as_str() + ] + ); + assert_eq!(listed[0].client_id, "my_tool"); + assert_eq!(listed[0].label, "oldest"); + assert_eq!(listed[0].created_at_unix_seconds, now - 20); + // No pairing value or key anywhere in the listing. + let json: serde_json::Value = serde_json::from_str(&reply.body).unwrap(); + for entry in json.as_array().unwrap() { + assert!(entry.get("pairing").is_none(), "{entry}"); + } + for session in [&newest, &oldest, &middle] { + assert!(!reply.body.contains(&*session.pairing)); + } + assert!(!reply.body.contains(&created.pairing)); + } + + #[tokio::test] + async fn delete_revokes_with_204_and_answers_404_for_an_unknown_session() { + let api = api(); + let session = register_ok(&api.registry, "claude-code", "lab", TTL, unix_now()); + let other = register_ok(&api.registry, "claude-code", "kept", TTL, unix_now()); + + let gone = call( + &api.app, + "DELETE", + &format!("/v1/llm-proxy/sessions/{}", session.id), + None, + ) + .await; + assert_eq!(gone.status, 204, "{}", gone.body); + assert!(gone.body.is_empty()); + assert!( + api.registry + .authorize(&session.pairing, unix_now()) + .is_none() + ); + assert!(api.registry.authorize(&other.pairing, unix_now()).is_some()); + + let again = call( + &api.app, + "DELETE", + &format!("/v1/llm-proxy/sessions/{}", session.id), + None, + ) + .await; + assert_eq!(again.status, 404); + assert!( + again.body.contains(&format!("no session {}", session.id)), + "{}", + again.body + ); + let unknown = call( + &api.app, + "DELETE", + "/v1/llm-proxy/sessions/0000000000000000", + None, + ) + .await; + assert_eq!(unknown.status, 404); + assert!( + unknown.body.contains("no session 0000000000000000"), + "{}", + unknown.body + ); + } + + #[tokio::test] + async fn other_methods_on_the_session_routes_are_405() { + let api = api(); + assert_eq!( + call(&api.app, "PUT", "/v1/llm-proxy/sessions", None) + .await + .status, + 405 + ); + assert_eq!( + call(&api.app, "GET", "/v1/llm-proxy/sessions/abc", None) + .await + .status, + 405 + ); + } + + #[tokio::test] + async fn the_pairing_is_in_the_post_body_only_and_never_in_debug_output() { + let api = api(); + let reply = post(&api.app, r#"{"clientId":"claude-code","label":"lab"}"#).await; + let created: SessionCreated = reply.json(); + let debug = format!("{created:?}"); + assert!(debug.contains(""), "{debug}"); + assert!(!debug.contains(&created.pairing), "{debug}"); + let listing = call(&api.app, "GET", "/v1/llm-proxy/sessions", None).await; + assert!(!listing.body.contains(&created.pairing)); + } + + // --- the production policy: the effective configuration's rules. + + fn config_policy(yaml: &str) -> (tempfile::TempDir, ConfigPolicy) { + let dir = tempfile::tempdir().unwrap(); + let policy = ConfigPolicy { + config: agentdesktop_core::config::parse_daemon(yaml).expect("test configuration"), + state_dir: dir.path().to_owned(), + }; + (dir, policy) + } + + const CONTROLLER_JWT: &str = "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: controllerJwt\n audience: agentgateway\n allowedClientIds: [claude-code, copilot-cli]\n"; + const OIDC: &str = "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\n"; + + #[tokio::test] + async fn config_policy_admits_a_client_id_the_controller_policy_allows() { + let (_dir, policy) = config_policy(CONTROLLER_JWT); + assert_eq!(policy.check("claude-code").await, Ok(())); + assert_eq!(policy.check("copilot-cli").await, Ok(())); + } + + #[tokio::test] + async fn config_policy_refuses_a_client_id_missing_from_allowed_client_ids() { + let (_dir, policy) = config_policy(CONTROLLER_JWT); + let (status, message) = policy.check("codex").await.unwrap_err(); + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!( + message, + "client id codex is not in llmGateway.authentication.allowedClientIds" + ); + } + + #[tokio::test] + async fn config_policy_does_not_check_client_ids_with_oidc_or_without_authentication() { + let (_dir, policy) = config_policy(OIDC); + assert_eq!(policy.check("anything-goes").await, Ok(())); + let (_dir, policy) = config_policy("llmGateway:\n url: https://gateway.example.com\n"); + assert_eq!(policy.check("anything-goes").await, Ok(())); + let (_dir, policy) = config_policy("{}"); + assert_eq!(policy.check("anything-goes").await, Ok(())); + } + + #[tokio::test] + async fn config_policy_refuses_a_program_in_subscription_mode() { + let (_dir, policy) = config_policy(&format!( + "{OIDC}programs:\n claudeCode:\n auth: subscription\n claudeDesktop:\n auth: subscription\n" + )); + for client_id in ["claude-code", "claude-desktop"] { + let (status, message) = policy.check(client_id).await.unwrap_err(); + assert_eq!(status, StatusCode::CONFLICT, "{client_id}"); + assert_eq!( + message, "subscription mode is not supported on the sandbox route", + "{client_id}" + ); + } + let (_dir, policy) = config_policy(&format!("{OIDC}programs:\n claudeCode: {{}}\n")); + assert_eq!(policy.check("claude-code").await, Ok(())); + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 2f3217b5..abef762c 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -160,37 +160,38 @@ where Ok(body) } -/// A non-2xx answer from the daemon, as the error of [`post_json_response`]. -/// `Display` is `daemon returned : `. -#[derive(Debug, Clone, PartialEq, Eq)] +/// A non-2xx response from the daemon's local API. +#[derive(Debug)] pub struct StatusError { pub status: u16, pub body: String, } impl std::fmt::Display for StatusError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let reason = hyper::StatusCode::from_u16(self.status) + /// `daemon returned : `, the shape of the other + /// helpers' errors, so a caller can show it as is. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "daemon returned {}", self.status)?; + if let Some(reason) = hyper::StatusCode::from_u16(self.status) .ok() .and_then(|status| status.canonical_reason()) - .unwrap_or(""); - write!( - formatter, - "daemon returned {} {reason}: {}", - self.status, self.body - ) + { + write!(f, " {reason}")?; + } + write!(f, ": {}", self.body) } } impl std::error::Error for StatusError {} -/// Sends a POST with a JSON body and decodes the JSON response. A non-2xx -/// answer is an error that carries a [`StatusError`]. -pub async fn post_json_response(endpoint: &Path, path: &str, body: &B) -> anyhow::Result -where - B: Serialize, - T: DeserializeOwned, -{ +/// Sends a request with an optional JSON body and returns the status and the +/// raw response body. +async fn exchange( + endpoint: &Path, + method: &str, + path: &str, + json: Option>, +) -> anyhow::Result<(hyper::StatusCode, Bytes)> { let stream = connect(endpoint).await?; let (mut sender, connection) = http1::handshake(TokioIo::new(stream)) .await @@ -202,188 +203,249 @@ where } }); - let encoded = serde_json::to_vec(body).context("encode request body")?; - let request = Request::builder() - .method("POST") + let mut request = Request::builder() + .method(method) .uri(path) - .header("host", "localhost") - .header("content-type", "application/json") - .body(Full::new(Bytes::from(encoded)))?; + .header("host", "localhost"); + if json.is_some() { + request = request.header("content-type", "application/json"); + } + let request = request.body(Full::new(Bytes::from(json.unwrap_or_default())))?; let response = sender.send_request(request).await.context("send request")?; let status = response.status(); - let bytes = response + let body = response .into_body() .collect() .await .context("read response")? .to_bytes(); + Ok((status, body)) +} + +fn status_error(status: hyper::StatusCode, body: &Bytes) -> anyhow::Error { + StatusError { + status: status.as_u16(), + body: String::from_utf8_lossy(body).into_owned(), + } + .into() +} + +/// Sends a POST with a JSON body and decodes the JSON response. A non-2xx +/// response is an error carrying a [`StatusError`], so the caller can map +/// statuses to messages. +pub async fn post_json_response(endpoint: &Path, path: &str, body: &B) -> anyhow::Result +where + B: Serialize, + T: DeserializeOwned, +{ + let json = serde_json::to_vec(body).context("encode request body")?; + let (status, body) = exchange(endpoint, "POST", path, Some(json)).await?; if !status.is_success() { - return Err(StatusError { - status: status.as_u16(), - body: String::from_utf8_lossy(&bytes).into_owned(), - } - .into()); + return Err(status_error(status, &body)); } - serde_json::from_slice(&bytes).context("decode daemon response") + serde_json::from_slice(&body).context("decode daemon response") +} + +/// Sends a DELETE; any 2xx is success and the body is not decoded. A non-2xx +/// response is an error carrying a [`StatusError`]. +pub async fn delete(endpoint: &Path, path: &str) -> anyhow::Result<()> { + let (status, body) = exchange(endpoint, "DELETE", path, None).await?; + if !status.is_success() { + return Err(status_error(status, &body)); + } + Ok(()) } #[cfg(test)] mod tests { use std::path::Path; - use super::{StatusError, connect_error}; + use super::connect_error; #[test] - fn status_error_display_names_the_status_and_carries_the_body() { - let error = StatusError { - status: 409, - body: "executor not found: C:\\missing".into(), - }; - assert_eq!( - error.to_string(), - "daemon returned 409 Conflict: executor not found: C:\\missing" - ); + fn connection_error_suggests_checking_the_daemon() { + let endpoint = Path::new("/tmp/agentdesktop.sock"); + assert_eq!( - StatusError { - status: 404, - body: String::new(), - } - .to_string(), - "daemon returned 404 Not Found: " + connect_error(endpoint), + "connect to /tmp/agentdesktop.sock\nCheck that the Agentdesktop daemon is running." ); } - #[cfg(unix)] - mod unix { - use std::path::Path; + #[cfg(target_os = "linux")] + #[test] + fn status_error_display_names_the_status_and_the_body() { + let error = super::StatusError { + status: 404, + body: "no session abc".to_owned(), + }; + let text = error.to_string(); + assert!(text.contains("404"), "{text}"); + assert!(text.contains("no session abc"), "{text}"); + } + + #[cfg(target_os = "linux")] + mod socket { + use std::{ + convert::Infallible, + path::PathBuf, + sync::{Arc, Mutex}, + }; + use bytes::Bytes; + use http_body_util::{BodyExt, Full}; + use hyper::{Request, Response, service::service_fn}; + use hyper_util::rt::TokioIo; use serde::{Deserialize, Serialize}; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::UnixListener, - }; + use tokio::net::UnixListener; - use super::super::{StatusError, post_json_response}; + use crate::{StatusError, delete, post_json_response}; - #[derive(Serialize)] - struct Body { - program: String, + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct Payload { + value: String, } - #[derive(Debug, Deserialize, PartialEq)] - struct Answer { - id: String, + /// (method, path, content-type, body) + type Seen = Arc>>; + + /// A daemon stand-in on a Unix socket that answers every request with + /// `status` and `body`. + struct FakeDaemon { + socket: PathBuf, + seen: Seen, + task: tokio::task::JoinHandle<()>, } - /// Accepts one connection, returns the raw request text, answers with - /// `status_line` and `body`. - async fn serve_once( - socket: &Path, - status_line: &'static str, - body: &'static str, - ) -> tokio::task::JoinHandle { - let listener = UnixListener::bind(socket).unwrap(); - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let mut request = Vec::new(); - let mut buffer = [0u8; 1024]; + impl Drop for FakeDaemon { + fn drop(&mut self) { + self.task.abort(); + let _ = std::fs::remove_file(&self.socket); + } + } + + fn fake_daemon(name: &str, status: u16, canned: &'static str) -> FakeDaemon { + let socket = std::env::temp_dir().join(format!( + "agentdesktop-client-test-{}-{name}.sock", + std::process::id() + )); + let _ = std::fs::remove_file(&socket); + let listener = UnixListener::bind(&socket).unwrap(); + let seen: Seen = Arc::default(); + let record = seen.clone(); + let task = tokio::spawn(async move { loop { - let read = stream.read(&mut buffer).await.unwrap(); - request.extend_from_slice(&buffer[..read]); - let text = String::from_utf8_lossy(&request).to_string(); - if let Some((head, rest)) = text.split_once("\r\n\r\n") { - let length = head - .lines() - .find_map(|line| { - line.to_ascii_lowercase() - .strip_prefix("content-length:") - .map(|value| value.trim().parse::().unwrap()) - }) - .unwrap_or(0); - if rest.len() >= length { - break; - } - } - if read == 0 { - break; - } + let (stream, _) = listener.accept().await.unwrap(); + let record = record.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(stream), + service_fn(move |request: Request| { + let record = record.clone(); + async move { + let method = request.method().to_string(); + let path = request.uri().path().to_owned(); + let content_type = request + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_owned(); + let body = + request.into_body().collect().await.unwrap().to_bytes(); + record.lock().unwrap().push(( + method, + path, + content_type, + String::from_utf8_lossy(&body).into_owned(), + )); + Ok::<_, Infallible>( + Response::builder() + .status(status) + .body(Full::new(Bytes::from_static( + canned.as_bytes(), + ))) + .unwrap(), + ) + } + }), + ) + .await; + }); } - let response = format!( - "HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.unwrap(); - String::from_utf8_lossy(&request).to_string() - }) + }); + FakeDaemon { socket, seen, task } } #[tokio::test] - async fn post_json_response_sends_the_json_body_and_decodes_the_answer() { - let dir = tempfile::tempdir().unwrap(); - let socket = dir.path().join("daemon.sock"); - let server = serve_once(&socket, "201 Created", r#"{"id":"abc"}"#).await; - - let answer: Answer = post_json_response( - &socket, - "/v1/launch", - &Body { - program: "claude-code".into(), + async fn post_json_response_sends_json_and_decodes_the_reply() { + let daemon = fake_daemon("post-ok", 201, r#"{"value":"created"}"#); + let reply: Payload = post_json_response( + &daemon.socket, + "/v1/things", + &Payload { + value: "asked".into(), }, ) .await .unwrap(); - - assert_eq!(answer, Answer { id: "abc".into() }); - let request = server.await.unwrap(); - assert!(request.starts_with("POST /v1/launch HTTP/1.1"), "{request}"); - assert!( - request - .to_ascii_lowercase() - .contains("content-type: application/json"), - "{request}" - ); - assert!( - request.ends_with(r#"{"program":"claude-code"}"#), - "{request}" + assert_eq!( + reply, + Payload { + value: "created".into() + } ); + let seen = daemon.seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, "POST"); + assert_eq!(seen[0].1, "/v1/things"); + assert!(seen[0].2.starts_with("application/json"), "{}", seen[0].2); + assert_eq!(seen[0].3, r#"{"value":"asked"}"#); } #[tokio::test] - async fn post_json_response_reports_a_non_2xx_answer_as_a_status_error() { - let dir = tempfile::tempdir().unwrap(); - let socket = dir.path().join("daemon.sock"); - let server = serve_once(&socket, "409 Conflict", "executor not found: x").await; - - let error = post_json_response::<_, Answer>( - &socket, - "/v1/launch", - &Body { - program: "claude-code".into(), - }, - ) - .await - .unwrap_err(); - - let status = error - .downcast_ref::() - .unwrap_or_else(|| panic!("a StatusError in {error:#}")); - assert_eq!(status.status, 409); - assert_eq!(status.body, "executor not found: x"); - assert_eq!( - error.to_string(), - "daemon returned 409 Conflict: executor not found: x" - ); - server.await.unwrap(); + async fn post_json_response_carries_a_status_error_for_non_2xx() { + for (status, body) in [(409u16, "the proxy is not running"), (429, "too many")] { + let daemon = fake_daemon(&format!("post-{status}"), status, body); + let error = post_json_response::<_, Payload>( + &daemon.socket, + "/v1/things", + &Payload { value: "x".into() }, + ) + .await + .expect_err("a non-2xx response is an error"); + let status_error = error + .downcast_ref::() + .unwrap_or_else(|| panic!("no StatusError in {error:#}")); + assert_eq!(status_error.status, status); + assert_eq!(status_error.body, body); + } } - } - #[test] - fn connection_error_suggests_checking_the_daemon() { - let endpoint = Path::new("/tmp/agentdesktop.sock"); + #[tokio::test] + async fn delete_succeeds_on_any_2xx_without_decoding_the_body() { + for (status, body) in [(204u16, ""), (200, "not json at all")] { + let daemon = fake_daemon(&format!("delete-{status}"), status, body); + delete(&daemon.socket, "/v1/things/abc").await.unwrap(); + let seen = daemon.seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, "DELETE"); + assert_eq!(seen[0].1, "/v1/things/abc"); + } + } - assert_eq!( - connect_error(endpoint), - "connect to /tmp/agentdesktop.sock\nCheck that the Agentdesktop daemon is running." - ); + #[tokio::test] + async fn delete_carries_a_status_error_for_non_2xx() { + let daemon = fake_daemon("delete-404", 404, "no session abc"); + let error = delete(&daemon.socket, "/v1/things/abc") + .await + .expect_err("a 404 is an error"); + let status_error = error + .downcast_ref::() + .unwrap_or_else(|| panic!("no StatusError in {error:#}")); + assert_eq!(status_error.status, 404); + assert_eq!(status_error.body, "no session abc"); + } } } diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index f436b91f..3994dbf1 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -430,11 +430,67 @@ pub enum TelemetryEventKind { }, } +/// Request to register a sandbox session. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionRequest { + /// Credential policy client ID the session's requests present to the gateway. + pub client_id: String, + /// Human-readable name for the session, shown in listings. + pub label: String, + /// Seconds without a request after which the session expires. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, +} + +/// A newly registered sandbox session. The pairing is shown here and nowhere else. +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionCreated { + /// Identifier used to list and revoke the session. + pub id: String, + /// Secret the session's client presents; never shown again. + pub pairing: String, + pub client_id: String, + pub label: String, + pub created_at_unix_seconds: u64, + /// When the session expires if it receives no further request. + pub expires_at_unix_seconds: u64, + /// Base URL the session's client sends its requests to. + pub base_url: String, +} + +impl std::fmt::Debug for SessionCreated { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionCreated") + .field("id", &self.id) + .field("pairing", &"") + .field("client_id", &self.client_id) + .field("label", &self.label) + .field("created_at_unix_seconds", &self.created_at_unix_seconds) + .field("expires_at_unix_seconds", &self.expires_at_unix_seconds) + .field("base_url", &self.base_url) + .finish() + } +} + +/// A registered sandbox session, without its pairing. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + pub id: String, + pub client_id: String, + pub label: String, + pub created_at_unix_seconds: u64, + /// When the session expires if it receives no further request. + pub expires_at_unix_seconds: u64, +} + #[cfg(test)] mod tests { use super::{ ContainmentInfo, ControllerConnectionError, ControllerConnectionStatus, Launch, - LaunchRequest, LaunchStatus, ProgramChoice, + LaunchRequest, LaunchStatus, ProgramChoice, SessionCreated, SessionInfo, SessionRequest, }; #[test] @@ -556,4 +612,114 @@ mod tests { assert_eq!(json["endedAtUnixSeconds"], 1_700_000_100u64); assert_eq!(serde_json::from_value::(json).unwrap(), exited); } + + #[test] + fn session_request_uses_camel_case_and_ttl_seconds_is_optional() { + let request = SessionRequest { + client_id: "claude-code".into(), + label: "lab".into(), + ttl_seconds: None, + }; + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({"clientId": "claude-code", "label": "lab"}) + ); + let with_ttl = SessionRequest { + ttl_seconds: Some(120), + ..request.clone() + }; + assert_eq!( + serde_json::to_value(&with_ttl).unwrap(), + serde_json::json!({"clientId": "claude-code", "label": "lab", "ttlSeconds": 120}) + ); + + let parsed: SessionRequest = + serde_json::from_str(r#"{"clientId":"claude-code","label":"lab"}"#).unwrap(); + assert_eq!(parsed, request); + let parsed: SessionRequest = + serde_json::from_str(r#"{"clientId":"claude-code","label":"lab","ttlSeconds":120}"#) + .unwrap(); + assert_eq!(parsed, with_ttl); + // A newer client may send fields this build does not know. + let parsed: SessionRequest = serde_json::from_str( + r#"{"clientId":"claude-code","label":"lab","somethingNew":{"a":1}}"#, + ) + .unwrap(); + assert_eq!(parsed, request); + // The wire names are camelCase only. + assert!( + serde_json::from_str::(r#"{"client_id":"claude-code","label":"lab"}"#) + .is_err() + ); + } + + #[test] + fn session_created_round_trips_with_camel_case_field_names() { + let created = SessionCreated { + id: "0123456789abcdef".into(), + pairing: "p".repeat(43), + client_id: "claude-code".into(), + label: "lab".into(), + created_at_unix_seconds: 1_000, + expires_at_unix_seconds: 29_800, + base_url: "http://127.0.0.1:4000/sandbox".into(), + }; + let json = serde_json::to_value(&created).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "id": "0123456789abcdef", + "pairing": "p".repeat(43), + "clientId": "claude-code", + "label": "lab", + "createdAtUnixSeconds": 1_000, + "expiresAtUnixSeconds": 29_800, + "baseUrl": "http://127.0.0.1:4000/sandbox", + }) + ); + assert_eq!( + serde_json::from_value::(json).unwrap(), + created + ); + } + + #[test] + fn session_created_debug_redacts_the_pairing() { + let created = SessionCreated { + id: "0123456789abcdef".into(), + pairing: "secret-pairing-value".into(), + client_id: "claude-code".into(), + label: "lab".into(), + created_at_unix_seconds: 1_000, + expires_at_unix_seconds: 29_800, + base_url: "http://127.0.0.1:4000/sandbox".into(), + }; + let debug = format!("{created:?}"); + assert!(debug.contains(""), "{debug}"); + assert!(!debug.contains("secret-pairing-value"), "{debug}"); + } + + #[test] + fn session_info_has_no_pairing_and_uses_camel_case() { + let info = SessionInfo { + id: "0123456789abcdef".into(), + client_id: "claude-code".into(), + label: "lab".into(), + created_at_unix_seconds: 1_000, + expires_at_unix_seconds: 29_800, + }; + let json = serde_json::to_value(&info).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "id": "0123456789abcdef", + "clientId": "claude-code", + "label": "lab", + "createdAtUnixSeconds": 1_000, + "expiresAtUnixSeconds": 29_800, + }) + ); + assert_eq!(serde_json::from_value::(json).unwrap(), info); + assert_eq!(info.clone(), info); + } } From 2f07799eb0b07fb02f10ff53559d4c1898116fc7 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Tue, 20 Oct 2026 18:37:43 +0200 Subject: [PATCH 04/19] launch: report changes to a sandboxed session's executable configuration Read-only grants stop in-place writes, but on Windows a read-only file inside a writable folder can be replaced from the box (temp file + rename), a residual risk the launcher documents. Make such changes visible: before the box spawns, the daemon snapshots the launch's executable configuration (the read-only granted settings, hooks, plugins, `.mcp.json`, the workspace-root `HEAD`, and the repository's `config`, `hooks`, `commondir`, including `.git` itself); it verifies the snapshot every 60 seconds while the box runs and again when it exits, including early exits. Alert-first: nothing is restored or blocked. - Root files carry content hash and file identity, so a replacement with identical bytes still registers (`replaced`, equal hashes). In recursively hashed folders every entry carries its identity, so the same replacement of a child registers as `modified` on the folder. - Folders hash recursively with bounds: snapshot reads are bounded, the walker depth is bounded (deep trees degrade to too-large instead of overflowing the stack), and a tree over the content budget is recorded by structure (names, kinds, sizes, identities), so a changed manifest or an atomic save registers while an in-place same-size edit does not. - Every integrity transition is reported while the box runs; the periodic check has a distinct log line; `GET /v1/launches` carries the drift flag. Co-Authored-By: Claude Fable 5 --- README.md | 30 +- crates/agent/Cargo.toml | 1 + crates/agent/src/launch/integrity.rs | 805 +++++++++ crates/agent/src/launch/integrity/tests.rs | 1823 ++++++++++++++++++++ crates/agent/src/launch/mod.rs | 154 +- crates/agent/src/launch/tests.rs | 293 ++++ crates/core/src/model.rs | 37 + 7 files changed, 3137 insertions(+), 6 deletions(-) create mode 100644 crates/agent/src/launch/integrity.rs create mode 100644 crates/agent/src/launch/integrity/tests.rs diff --git a/README.md b/README.md index 84dd93e9..7e147738 100644 --- a/README.md +++ b/README.md @@ -782,7 +782,8 @@ 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). A daemon restart keeps running +(running ones plus the last 32 that exited); each +entry carries `integrityDrift: true` once a change was detected. A daemon restart keeps running boxes alive but stops refreshing their credential: at startup the daemon removes the credential and 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 @@ -796,13 +797,32 @@ and `.git\commondir`. A `.git` file (worktree or submodule) is box-writable (a r 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's own +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, and editor task files such as +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; managed `allowManaged*Only` settings arrive with the policy profile. diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 96fa497c..5f714c52 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -89,6 +89,7 @@ windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Pipes", "Win32_System_Registry", diff --git a/crates/agent/src/launch/integrity.rs b/crates/agent/src/launch/integrity.rs new file mode 100644 index 00000000..2883d7cd --- /dev/null +++ b/crates/agent/src/launch/integrity.rs @@ -0,0 +1,805 @@ +//! Executable-configuration integrity: snapshot the read-only granted files +//! of a launch before its box spawns, verify them while the box runs and when +//! it exits, and report every change. Alert-first: nothing is restored or +//! blocked here. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(test)] +mod tests; + +/// File identity across platforms. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FileIdentity { + pub volume: u64, + pub file_id: u128, +} + +/// Entry kind with file size. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryKind { + File { len: u64 }, + Folder, + Link, +} + +/// Snapshot of a file or folder at a point in time. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FileSnapshot { + Absent, + File { + sha256: [u8; 32], + identity: FileIdentity, + }, + Folder { + sha256: [u8; 32], + identity: FileIdentity, + }, + FolderIdentity { + identity: FileIdentity, + }, + Link { + target_sha256: [u8; 32], + identity: FileIdentity, + }, + /// Over the content budget: names, kinds, sizes and identities are + /// hashed recursively, file contents are not read. + TooLarge { + identity: FileIdentity, + structure_sha256: [u8; 32], + }, + Unreadable { + kind: io::ErrorKind, + }, +} + +/// Mode for integrity root checking. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RootMode { + Recursive, + IdentityOnly, +} + +/// An integrity check root path with its mode. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IntegrityRoot { + pub path: PathBuf, + pub mode: RootMode, +} + +/// Kind of change detected. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChangeKind { + Modified, + Replaced, + Appeared, + Disappeared, + KindChanged, +} + +impl std::fmt::Display for ChangeKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ChangeKind::Modified => write!(f, "modified"), + ChangeKind::Replaced => write!(f, "replaced"), + ChangeKind::Appeared => write!(f, "appeared"), + ChangeKind::Disappeared => write!(f, "disappeared"), + ChangeKind::KindChanged => write!(f, "kind_changed"), + } + } +} + +/// A detected change between snapshots. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Change { + pub kind: ChangeKind, + pub before: FileSnapshot, + pub after: FileSnapshot, +} + +/// Result of verification. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifyResult { + pub changed: Vec<(PathBuf, Change)>, +} + +/// Filesystem operations for integrity checking. +pub trait IntegrityFs: Send + Sync { + fn metadata(&self, p: &Path) -> io::Result; + fn read_bytes(&self, p: &Path) -> io::Result>; + fn read_dir_names(&self, p: &Path) -> io::Result>; + fn file_identity(&self, p: &Path) -> io::Result; + fn read_link(&self, p: &Path) -> io::Result; +} + +/// Platform encoding of a file name or link target, as the spec fixes it: +/// `OsStr::as_encoded_bytes` (stable, deterministic per platform). +fn os_bytes(s: &std::ffi::OsStr) -> &[u8] { + s.as_encoded_bytes() +} + +/// Real filesystem implementation. +pub struct RealIntegrityFs; + +pub const REAL_FS: RealIntegrityFs = RealIntegrityFs; + +const MAX_ENTRIES: usize = 1000; +const MAX_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB +/// Entry cap for the structure-only walk of an over-budget tree. +const STRUCTURE_MAX_ENTRIES: usize = 10_000; +/// Maximum nesting depth for recursive folder walks. This prevents stack +/// overflow on the 2 MiB blocking-thread stack (found by external review). +const MAX_DEPTH: usize = 64; + +impl IntegrityFs for RealIntegrityFs { + fn metadata(&self, p: &Path) -> io::Result { + let meta = std::fs::symlink_metadata(p)?; + if meta.is_symlink() { + Ok(EntryKind::Link) + } else if meta.is_dir() { + Ok(EntryKind::Folder) + } else { + Ok(EntryKind::File { len: meta.len() }) + } + } + + fn read_bytes(&self, p: &Path) -> io::Result> { + use std::io::Read; + let mut buf = Vec::new(); + std::fs::File::open(p)? + .take(MAX_BYTES + 1) + .read_to_end(&mut buf)?; + Ok(buf) + } + + fn read_dir_names(&self, p: &Path) -> io::Result> { + let mut names = Vec::new(); + for entry in std::fs::read_dir(p)? { + let entry = entry?; + names.push(entry.file_name()); + } + Ok(names) + } + + fn file_identity(&self, p: &Path) -> io::Result { + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FileIdInfo, + GetFileInformationByHandleEx, + }; + + // Attribute-only access: it cannot hit a sharing violation on a + // file another process holds open exclusively. + const FILE_READ_ATTRIBUTES: u32 = 0x0080; + let file = std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .open(p)?; + + // SAFETY: FILE_ID_INFO is a plain-old-data struct; all-zero bytes + // are a valid initial value for it. + let mut info: FILE_ID_INFO = unsafe { std::mem::zeroed() }; + // SAFETY: the handle is open, the buffer is a FILE_ID_INFO and + // the size passed is exactly its size, as the API requires. + let success = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle() as _, + FileIdInfo, + &mut info as *mut _ as *mut _, + std::mem::size_of::() as u32, + ) + }; + + if success == 0 { + return Err(io::Error::last_os_error()); + } + + let file_id = u128::from_le_bytes(info.FileId.Identifier); + let volume = info.VolumeSerialNumber as u64; + + Ok(FileIdentity { volume, file_id }) + } + + #[cfg(not(windows))] + { + use std::os::unix::fs::MetadataExt; + let meta = std::fs::symlink_metadata(p)?; + let volume = meta.dev(); + let file_id = meta.ino() as u128; + Ok(FileIdentity { volume, file_id }) + } + } + + fn read_link(&self, p: &Path) -> io::Result { + std::fs::read_link(p).map(|pb| pb.into_os_string()) + } +} + +/// Snapshot the given roots. +pub fn snapshot(roots: &[IntegrityRoot], fs: &dyn IntegrityFs) -> BTreeMap { + let mut result = BTreeMap::new(); + for root in roots { + let snap = snapshot_one(&root.path, root.mode, fs); + result.insert(root.path.clone(), snap); + } + result +} + +fn snapshot_one(path: &Path, mode: RootMode, fs: &dyn IntegrityFs) -> FileSnapshot { + // One retry after 100 ms covers transient locks on any read the attempt + // makes, not only the first metadata call. + match snapshot_one_attempt(path, mode, fs) { + FileSnapshot::Unreadable { .. } => { + std::thread::sleep(Duration::from_millis(100)); + snapshot_one_attempt(path, mode, fs) + } + snap => snap, + } +} + +fn snapshot_one_attempt(path: &Path, mode: RootMode, fs: &dyn IntegrityFs) -> FileSnapshot { + let kind = match fs.metadata(path) { + Ok(k) => k, + Err(e) if e.kind() == io::ErrorKind::NotFound => return FileSnapshot::Absent, + Err(e) if e.kind() == io::ErrorKind::NotADirectory => return FileSnapshot::Absent, + Err(e) => return FileSnapshot::Unreadable { kind: e.kind() }, + }; + + match kind { + EntryKind::File { len } => { + let identity = match fs.file_identity(path) { + Ok(id) => id, + Err(e) => return FileSnapshot::Unreadable { kind: e.kind() }, + }; + if len > MAX_BYTES { + // A root file over the budget: its size stands in for content. + return FileSnapshot::TooLarge { + identity, + structure_sha256: sha256_hash(&len.to_le_bytes()), + }; + } + let bytes = match fs.read_bytes(path) { + Ok(b) => b, + Err(e) => return FileSnapshot::Unreadable { kind: e.kind() }, + }; + if bytes.len() as u64 > MAX_BYTES { + // The file grew between the size check and the read. + return FileSnapshot::TooLarge { + identity, + structure_sha256: sha256_hash(&(bytes.len() as u64).to_le_bytes()), + }; + } + let sha256 = sha256_hash(&bytes); + FileSnapshot::File { sha256, identity } + } + EntryKind::Folder => { + let identity = match fs.file_identity(path) { + Ok(id) => id, + Err(e) => return FileSnapshot::Unreadable { kind: e.kind() }, + }; + if mode == RootMode::IdentityOnly { + return FileSnapshot::FolderIdentity { identity }; + } + // Recursive mode + let mut entries_count = 0; + let mut cumulative_bytes = 0u64; + match snapshot_folder_recursive(path, fs, &mut entries_count, &mut cumulative_bytes, 0) + { + Ok(sha256) => FileSnapshot::Folder { sha256, identity }, + Err(FolderError::TooLarge) => { + // Over the content budget: hash the structure instead + // (names, kinds, sizes, identities), so a changed manifest + // or an atomic save still shows up. In-place edits that + // keep a file's size do not. + let mut entries = 0usize; + let structure_sha256 = match structure_hash(path, fs, &mut entries, 0) { + Ok(hash) => hash, + Err(FolderError::TooLarge) => match fs.read_dir_names(path) { + Ok(names) => hash_dir_names(&names), + Err(_) => [0; 32], + }, + Err(FolderError::Unreadable(kind)) => { + return FileSnapshot::Unreadable { kind }; + } + }; + FileSnapshot::TooLarge { + identity, + structure_sha256, + } + } + Err(FolderError::Unreadable(kind)) => FileSnapshot::Unreadable { kind }, + } + } + EntryKind::Link => { + let identity = match fs.file_identity(path) { + Ok(id) => id, + Err(e) => return FileSnapshot::Unreadable { kind: e.kind() }, + }; + let target_sha256 = match fs.read_link(path) { + Ok(target) => sha256_hash(os_bytes(&target)), + Err(_) => [0; 32], + }; + FileSnapshot::Link { + target_sha256, + identity, + } + } + } +} + +enum FolderError { + TooLarge, + Unreadable(io::ErrorKind), +} + +fn snapshot_folder_recursive( + path: &Path, + fs: &dyn IntegrityFs, + entries_count: &mut usize, + cumulative_bytes: &mut u64, + depth: usize, +) -> Result<[u8; 32], FolderError> { + if depth > MAX_DEPTH { + return Err(FolderError::TooLarge); + } + + let names = fs + .read_dir_names(path) + .map_err(|e| FolderError::Unreadable(e.kind()))?; + + let mut children: Vec<(OsString, u8, [u8; 32], Option)> = Vec::new(); + + for name in names { + // Each entry counts once, whatever its kind. + *entries_count += 1; + if *entries_count > MAX_ENTRIES { + return Err(FolderError::TooLarge); + } + + let child_path = path.join(&name); + let kind = match fs.metadata(&child_path) { + Ok(k) => k, + Err(e) if e.kind() == io::ErrorKind::NotFound => continue, // removed mid-walk + Err(e) => return Err(FolderError::Unreadable(e.kind())), + }; + + let (kind_byte, hash) = match kind { + EntryKind::File { len } => { + if *cumulative_bytes + len > MAX_BYTES { + return Err(FolderError::TooLarge); + } + let bytes = fs + .read_bytes(&child_path) + .map_err(|e| FolderError::Unreadable(e.kind()))?; + *cumulative_bytes += bytes.len() as u64; + if *cumulative_bytes > MAX_BYTES { + // The file grew between the size check and the read. + return Err(FolderError::TooLarge); + } + (0u8, sha256_hash(&bytes)) + } + EntryKind::Folder => { + let hash = snapshot_folder_recursive( + &child_path, + fs, + entries_count, + cumulative_bytes, + depth + 1, + )?; + (1u8, hash) + } + EntryKind::Link => { + let target_hash = match fs.read_link(&child_path) { + Ok(target) => sha256_hash(os_bytes(&target)), + Err(_) => [0; 32], + }; + (2u8, target_hash) + } + }; + + let identity = fs.file_identity(&child_path).ok(); + children.push((name, kind_byte, hash, identity)); + } + + children.sort_by(|a, b| os_bytes(&a.0).cmp(os_bytes(&b.0))); + + // Build the hash from sorted children + let mut hasher = sha2::Sha256::new(); + use sha2::Digest; + + for (name, kind_byte, hash, identity) in children { + let name_bytes = os_bytes(&name); + hasher.update((name_bytes.len() as u32).to_le_bytes()); + hasher.update(name_bytes); + hasher.update([kind_byte]); + hasher.update(hash); + if let Some(id) = identity { + hasher.update([1u8]); // presence marker + hasher.update(id.volume.to_le_bytes()); + hasher.update(id.file_id.to_le_bytes()); + } else { + hasher.update([0u8]); // absence marker + } + } + + Ok(hasher.finalize().into()) +} + +/// Hash of an over-budget tree's structure: sorted names with kind, size and +/// identity per entry, recursing into subfolders, no file contents. +fn structure_hash( + path: &Path, + fs: &dyn IntegrityFs, + entries_count: &mut usize, + depth: usize, +) -> Result<[u8; 32], FolderError> { + if depth > MAX_DEPTH { + return Err(FolderError::TooLarge); + } + + use sha2::Digest; + let names = fs + .read_dir_names(path) + .map_err(|e| FolderError::Unreadable(e.kind()))?; + let mut sorted = names; + sorted.sort_by(|a, b| os_bytes(a).cmp(os_bytes(b))); + + let mut hasher = sha2::Sha256::new(); + for name in sorted { + *entries_count += 1; + if *entries_count > STRUCTURE_MAX_ENTRIES { + return Err(FolderError::TooLarge); + } + let child_path = path.join(&name); + let name_bytes = os_bytes(&name); + hasher.update((name_bytes.len() as u32).to_le_bytes()); + hasher.update(name_bytes); + match fs.metadata(&child_path) { + Ok(EntryKind::File { len }) => { + hasher.update([0u8]); + hasher.update(len.to_le_bytes()); + if let Ok(identity) = fs.file_identity(&child_path) { + hasher.update([1u8]); // presence marker + hasher.update(identity.volume.to_le_bytes()); + hasher.update(identity.file_id.to_le_bytes()); + } else { + hasher.update([0u8]); // absence marker + } + } + Ok(EntryKind::Folder) => { + hasher.update([1u8]); + let child_hash = structure_hash(&child_path, fs, entries_count, depth + 1)?; + hasher.update(child_hash); + if let Ok(identity) = fs.file_identity(&child_path) { + hasher.update([1u8]); // presence marker + hasher.update(identity.volume.to_le_bytes()); + hasher.update(identity.file_id.to_le_bytes()); + } else { + hasher.update([0u8]); // absence marker + } + } + Ok(EntryKind::Link) => { + hasher.update([2u8]); + if let Ok(target) = fs.read_link(&child_path) { + hasher.update(sha256_hash(os_bytes(&target))); + } + if let Ok(identity) = fs.file_identity(&child_path) { + hasher.update([1u8]); // presence marker + hasher.update(identity.volume.to_le_bytes()); + hasher.update(identity.file_id.to_le_bytes()); + } else { + hasher.update([0u8]); // absence marker + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { + hasher.update([3u8]); // removed mid-walk + } + Err(e) => return Err(FolderError::Unreadable(e.kind())), + } + } + Ok(hasher.finalize().into()) +} + +fn hash_dir_names(names: &[OsString]) -> [u8; 32] { + use sha2::Digest; + let mut sorted = names.to_vec(); + sorted.sort_by(|a, b| os_bytes(a).cmp(os_bytes(b))); + let mut hasher = sha2::Sha256::new(); + for name in sorted { + let name_bytes = os_bytes(&name); + hasher.update((name_bytes.len() as u32).to_le_bytes()); + hasher.update(name_bytes); + } + hasher.finalize().into() +} + +fn sha256_hash(bytes: &[u8]) -> [u8; 32] { + use sha2::Digest; + sha2::Sha256::digest(bytes).into() +} + +/// Compute the difference between two snapshots. +pub fn diff(before: &FileSnapshot, after: &FileSnapshot) -> Option { + use FileSnapshot::*; + + match (before, after) { + (Absent, Absent) => None, + (Absent, _) => Some(Change { + kind: ChangeKind::Appeared, + before: before.clone(), + after: after.clone(), + }), + (_, Absent) => Some(Change { + kind: ChangeKind::Disappeared, + before: before.clone(), + after: after.clone(), + }), + ( + File { + identity: id1, + sha256: h1, + }, + File { + identity: id2, + sha256: h2, + }, + ) => { + if id1 != id2 { + Some(Change { + kind: ChangeKind::Replaced, + before: before.clone(), + after: after.clone(), + }) + } else if h1 != h2 { + Some(Change { + kind: ChangeKind::Modified, + before: before.clone(), + after: after.clone(), + }) + } else { + None + } + } + ( + Folder { + identity: id1, + sha256: h1, + }, + Folder { + identity: id2, + sha256: h2, + }, + ) => { + if id1 != id2 { + Some(Change { + kind: ChangeKind::Replaced, + before: before.clone(), + after: after.clone(), + }) + } else if h1 != h2 { + Some(Change { + kind: ChangeKind::Modified, + before: before.clone(), + after: after.clone(), + }) + } else { + None + } + } + (FolderIdentity { identity: id1 }, FolderIdentity { identity: id2 }) => { + if id1 != id2 { + Some(Change { + kind: ChangeKind::Replaced, + before: before.clone(), + after: after.clone(), + }) + } else { + None + } + } + ( + Link { + identity: id1, + target_sha256: h1, + }, + Link { + identity: id2, + target_sha256: h2, + }, + ) => { + if id1 != id2 { + Some(Change { + kind: ChangeKind::Replaced, + before: before.clone(), + after: after.clone(), + }) + } else if h1 != h2 { + Some(Change { + kind: ChangeKind::Modified, + before: before.clone(), + after: after.clone(), + }) + } else { + None + } + } + ( + TooLarge { + identity: id1, + structure_sha256: h1, + }, + TooLarge { + identity: id2, + structure_sha256: h2, + }, + ) => { + if id1 != id2 { + Some(Change { + kind: ChangeKind::Replaced, + before: before.clone(), + after: after.clone(), + }) + } else if h1 != h2 { + Some(Change { + kind: ChangeKind::Modified, + before: before.clone(), + after: after.clone(), + }) + } else { + None + } + } + (Unreadable { kind: k1 }, Unreadable { kind: k2 }) => { + if k1 != k2 { + Some(Change { + kind: ChangeKind::KindChanged, + before: before.clone(), + after: after.clone(), + }) + } else { + None + } + } + _ => Some(Change { + kind: ChangeKind::KindChanged, + before: before.clone(), + after: after.clone(), + }), + } +} + +/// Verify the current state of the roots against an expected snapshot map. +/// Each root keeps its declared mode, so a root that was absent when the +/// expectation was taken is still checked in that mode once it appears. +pub fn verify( + roots: &[IntegrityRoot], + expected: &BTreeMap, + fs: &dyn IntegrityFs, +) -> VerifyResult { + let mut changed = Vec::new(); + for root in roots { + let before = expected + .get(&root.path) + .cloned() + .unwrap_or(FileSnapshot::Absent); + let after = snapshot_one(&root.path, root.mode, fs); + if let Some(change) = diff(&before, &after) { + changed.push((root.path.clone(), change)); + } + } + VerifyResult { changed } +} + +/// Return the list of executable-config paths to check. +/// Every executable-config entry of `build_request`, plus workspace `.claude` and `.git` (identity only). +pub fn integrity_paths(home: &Path, cwd: &Path) -> Vec { + vec![ + IntegrityRoot { + path: home.join(".claude").join("settings.json"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: home.join(".claude").join("hooks"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: home.join(".claude").join("plugins"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: cwd.join(".claude"), + mode: RootMode::IdentityOnly, + }, + IntegrityRoot { + path: cwd.join(".claude").join("settings.json"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: cwd.join(".claude").join("settings.local.json"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: cwd.join(".claude").join("hooks"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: cwd.join(".mcp.json"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: cwd.join(".git"), + mode: RootMode::IdentityOnly, + }, + IntegrityRoot { + path: cwd.join("HEAD"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: cwd.join(".git").join("config"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: cwd.join(".git").join("hooks"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: cwd.join(".git").join("commondir"), + mode: RootMode::Recursive, + }, + ] +} + +/// Verifies the roots every `period` while the launch runs. Each pass is +/// compared with the last observed state, so every transition is reported +/// once: a change, the revert of that change, and the same change made again +/// each produce one `on_drift` call. +pub async fn run_periodic( + roots: Vec, + baseline: BTreeMap, + fs: Arc, + period: Duration, + on_drift: Arc, +) { + let mut last_observed = baseline; + + // A fixed schedule: the first check is one period after the task starts, + // and later checks stay on that schedule regardless of how long a verify + // pass takes. Missed ticks are skipped, so after a long suspend one check + // runs instead of a catch-up burst. + let mut ticker = tokio::time::interval_at(tokio::time::Instant::now() + period, period); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + + let roots_clone = roots.clone(); + let fs_clone = fs.clone(); + let current = + match tokio::task::spawn_blocking(move || snapshot(&roots_clone, fs_clone.as_ref())) + .await + { + Ok(current) => current, + Err(error) => { + tracing::warn!(%error, "periodic integrity check failed; retrying next period"); + continue; + } + }; + + for (path, after) in ¤t { + let before = last_observed.get(path).unwrap_or(&FileSnapshot::Absent); + if let Some(change) = diff(before, after) { + on_drift(path.clone(), change); + } + } + last_observed = current; + } +} diff --git a/crates/agent/src/launch/integrity/tests.rs b/crates/agent/src/launch/integrity/tests.rs new file mode 100644 index 00000000..45a083f6 --- /dev/null +++ b/crates/agent/src/launch/integrity/tests.rs @@ -0,0 +1,1823 @@ +use super::*; +use std::collections::BTreeMap; +use std::io; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Mock filesystem for testing +struct MockFs { + entries: Mutex>, + metadata_calls: Mutex>, +} + +enum MockEntry { + File { + content: Vec, + identity: FileIdentity, + }, + Folder { + children: Vec, + identity: FileIdentity, + }, + Link { + target: OsString, + identity: FileIdentity, + }, + Absent, + Unreadable(io::ErrorKind), +} + +impl MockFs { + fn new() -> Self { + Self { + entries: Mutex::new(BTreeMap::new()), + metadata_calls: Mutex::new(Vec::new()), + } + } + + fn set_file(&self, path: PathBuf, content: Vec, identity: FileIdentity) { + self.entries + .lock() + .unwrap() + .insert(path, MockEntry::File { content, identity }); + } + + fn set_folder(&self, path: PathBuf, children: Vec, identity: FileIdentity) { + self.entries + .lock() + .unwrap() + .insert(path, MockEntry::Folder { children, identity }); + } + + fn set_link(&self, path: PathBuf, target: OsString, identity: FileIdentity) { + self.entries + .lock() + .unwrap() + .insert(path, MockEntry::Link { target, identity }); + } + + fn set_absent(&self, path: PathBuf) { + self.entries.lock().unwrap().insert(path, MockEntry::Absent); + } + + fn set_unreadable(&self, path: PathBuf, kind: io::ErrorKind) { + self.entries + .lock() + .unwrap() + .insert(path, MockEntry::Unreadable(kind)); + } + + fn metadata_call_count(&self) -> usize { + self.metadata_calls.lock().unwrap().len() + } +} + +impl IntegrityFs for MockFs { + fn metadata(&self, p: &Path) -> io::Result { + self.metadata_calls.lock().unwrap().push(p.to_path_buf()); + let entries = self.entries.lock().unwrap(); + match entries.get(p) { + Some(MockEntry::File { content, .. }) => Ok(EntryKind::File { + len: content.len() as u64, + }), + Some(MockEntry::Folder { .. }) => Ok(EntryKind::Folder), + Some(MockEntry::Link { .. }) => Ok(EntryKind::Link), + Some(MockEntry::Absent) => Err(io::Error::new(io::ErrorKind::NotFound, "not found")), + Some(MockEntry::Unreadable(kind)) => Err(io::Error::new(*kind, "unreadable")), + None => Err(io::Error::new(io::ErrorKind::NotFound, "not found")), + } + } + + fn read_bytes(&self, p: &Path) -> io::Result> { + let entries = self.entries.lock().unwrap(); + match entries.get(p) { + Some(MockEntry::File { content, .. }) => Ok(content.clone()), + Some(MockEntry::Unreadable(kind)) => Err(io::Error::new(*kind, "unreadable")), + _ => Err(io::Error::new(io::ErrorKind::NotFound, "not found")), + } + } + + fn read_dir_names(&self, p: &Path) -> io::Result> { + let entries = self.entries.lock().unwrap(); + match entries.get(p) { + Some(MockEntry::Folder { children, .. }) => Ok(children.clone()), + Some(MockEntry::Unreadable(kind)) => Err(io::Error::new(*kind, "unreadable")), + _ => Err(io::Error::new(io::ErrorKind::NotFound, "not found")), + } + } + + fn file_identity(&self, p: &Path) -> io::Result { + let entries = self.entries.lock().unwrap(); + match entries.get(p) { + Some(MockEntry::File { identity, .. }) => Ok(*identity), + Some(MockEntry::Folder { identity, .. }) => Ok(*identity), + Some(MockEntry::Link { identity, .. }) => Ok(*identity), + Some(MockEntry::Unreadable(kind)) => Err(io::Error::new(*kind, "unreadable")), + _ => Err(io::Error::new(io::ErrorKind::NotFound, "not found")), + } + } + + fn read_link(&self, p: &Path) -> io::Result { + let entries = self.entries.lock().unwrap(); + match entries.get(p) { + Some(MockEntry::Link { target, .. }) => Ok(target.clone()), + Some(MockEntry::Unreadable(kind)) => Err(io::Error::new(*kind, "unreadable")), + _ => Err(io::Error::new(io::ErrorKind::NotFound, "not found")), + } + } +} + +// AC 1 Tests: snapshot + +#[test] +fn snapshot_file_present() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/file.txt"); + let identity = FileIdentity { + volume: 1, + file_id: 100, + }; + fs.set_file(path.clone(), b"content".to_vec(), identity); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + assert!(matches!(result.get(&path), Some(FileSnapshot::File { .. }))); +} + +#[test] +fn snapshot_file_absent() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/missing.txt"); + fs.set_absent(path.clone()); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + assert_eq!(result.get(&path), Some(&FileSnapshot::Absent)); +} + +#[test] +fn snapshot_folder() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/folder"); + let identity = FileIdentity { + volume: 1, + file_id: 200, + }; + fs.set_folder(path.clone(), vec![], identity); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + assert!(matches!( + result.get(&path), + Some(FileSnapshot::Folder { .. }) + )); +} + +#[test] +fn snapshot_nonexistent() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/nonexistent"); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + assert_eq!(result.get(&path), Some(&FileSnapshot::Absent)); +} + +#[test] +fn snapshot_unreadable() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/unreadable"); + fs.set_unreadable(path.clone(), io::ErrorKind::PermissionDenied); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + assert!(matches!( + result.get(&path), + Some(FileSnapshot::Unreadable { .. }) + )); +} + +#[test] +fn snapshot_folder_too_large_1001_entries() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/large_folder"); + let identity = FileIdentity { + volume: 1, + file_id: 300, + }; + + // Create 1001 child entries + let mut children = Vec::new(); + for i in 0..1001 { + children.push(OsString::from(format!("file{}", i))); + } + fs.set_folder(path.clone(), children, identity); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + assert!(matches!( + result.get(&path), + Some(FileSnapshot::TooLarge { .. }) + )); +} + +#[test] +fn snapshot_folder_too_large_10mib_plus_1() { + let fs = MockFs::new(); + let folder_path = PathBuf::from("/test/folder"); + let folder_identity = FileIdentity { + volume: 1, + file_id: 400, + }; + + // Create a child file that's 10 MiB + 1 byte + let file_path = PathBuf::from("/test/folder/large_file"); + let file_identity = FileIdentity { + volume: 1, + file_id: 401, + }; + let large_content = vec![0u8; 10 * 1024 * 1024 + 1]; + fs.set_file(file_path, large_content, file_identity); + fs.set_folder( + folder_path.clone(), + vec![OsString::from("large_file")], + folder_identity, + ); + + let roots = vec![IntegrityRoot { + path: folder_path.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + assert!(matches!( + result.get(&folder_path), + Some(FileSnapshot::TooLarge { .. }) + )); +} + +#[test] +fn snapshot_root_file_too_large_10mib_plus_1() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/large_file.bin"); + let identity = FileIdentity { + volume: 1, + file_id: 500, + }; + let large_content = vec![0u8; 10 * 1024 * 1024 + 1]; + fs.set_file(path.clone(), large_content, identity); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + assert!(matches!( + result.get(&path), + Some(FileSnapshot::TooLarge { structure_sha256, .. }) + if *structure_sha256 != [0; 32] + )); +} + +// AC 1 Tests: hash stability + +#[test] +fn hash_stability_same_file_twice() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/file.txt"); + let identity = FileIdentity { + volume: 1, + file_id: 600, + }; + fs.set_file(path.clone(), b"content".to_vec(), identity); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result1 = snapshot(&roots, &fs); + let result2 = snapshot(&roots, &fs); + + assert_eq!(result1.get(&path), result2.get(&path)); +} + +#[test] +fn hash_stability_folder_children_different_order() { + // Test that folder hash is the same regardless of the order children are returned + // (they should be sorted before hashing) + let fs1 = MockFs::new(); + let path1 = PathBuf::from("/test/folder"); + let identity = FileIdentity { + volume: 1, + file_id: 700, + }; + fs1.set_folder( + path1.clone(), + vec![OsString::from("a"), OsString::from("b")], + identity, + ); + + let fs2 = MockFs::new(); + let path2 = PathBuf::from("/test/folder"); + fs2.set_folder( + path2.clone(), + vec![OsString::from("b"), OsString::from("a")], + identity, + ); + + let roots1 = vec![IntegrityRoot { + path: path1.clone(), + mode: RootMode::Recursive, + }]; + let roots2 = vec![IntegrityRoot { + path: path2.clone(), + mode: RootMode::Recursive, + }]; + + let result1 = snapshot(&roots1, &fs1); + let result2 = snapshot(&roots2, &fs2); + + // Both should produce the same hash + assert_eq!(result1.get(&path1), result2.get(&path2)); +} + +#[test] +fn hash_stability_folder_one_child_changed() { + let fs1 = MockFs::new(); + let folder_path = PathBuf::from("/test/folder"); + let folder_identity = FileIdentity { + volume: 1, + file_id: 800, + }; + fs1.set_folder( + folder_path.clone(), + vec![OsString::from("child.txt")], + folder_identity, + ); + let child_path = PathBuf::from("/test/folder/child.txt"); + let child_identity = FileIdentity { + volume: 1, + file_id: 801, + }; + fs1.set_file(child_path.clone(), b"content1".to_vec(), child_identity); + + let fs2 = MockFs::new(); + fs2.set_folder( + folder_path.clone(), + vec![OsString::from("child.txt")], + folder_identity, + ); + fs2.set_file(child_path, b"content2".to_vec(), child_identity); + + let roots = vec![IntegrityRoot { + path: folder_path.clone(), + mode: RootMode::Recursive, + }]; + let result1 = snapshot(&roots, &fs1); + let result2 = snapshot(&roots, &fs2); + + // Hashes should differ + assert_ne!(result1.get(&folder_path), result2.get(&folder_path)); +} + +// AC 1 Tests: diff + +#[test] +fn diff_table_driven_all_transitions() { + // Table-driven test covering all ChangeKind values and FileSnapshot variant pairs + let identity1 = FileIdentity { + volume: 1, + file_id: 1000, + }; + let identity2 = FileIdentity { + volume: 1, + file_id: 2000, + }; + + let test_cases = vec![ + // Absent transitions + (FileSnapshot::Absent, FileSnapshot::Absent, None), + ( + FileSnapshot::Absent, + FileSnapshot::File { + sha256: [1; 32], + identity: identity1, + }, + Some(ChangeKind::Appeared), + ), + ( + FileSnapshot::File { + sha256: [1; 32], + identity: identity1, + }, + FileSnapshot::Absent, + Some(ChangeKind::Disappeared), + ), + // Same variant, different identity -> Replaced + ( + FileSnapshot::File { + sha256: [1; 32], + identity: identity1, + }, + FileSnapshot::File { + sha256: [1; 32], + identity: identity2, + }, + Some(ChangeKind::Replaced), + ), + // Same identity, different hash -> Modified + ( + FileSnapshot::File { + sha256: [1; 32], + identity: identity1, + }, + FileSnapshot::File { + sha256: [2; 32], + identity: identity1, + }, + Some(ChangeKind::Modified), + ), + // Different variants -> KindChanged + ( + FileSnapshot::File { + sha256: [1; 32], + identity: identity1, + }, + FileSnapshot::Folder { + sha256: [1; 32], + identity: identity1, + }, + Some(ChangeKind::KindChanged), + ), + // Unreadable with different ErrorKind -> KindChanged + ( + FileSnapshot::Unreadable { + kind: io::ErrorKind::NotFound, + }, + FileSnapshot::Unreadable { + kind: io::ErrorKind::PermissionDenied, + }, + Some(ChangeKind::KindChanged), + ), + ]; + + for (before, after, expected_kind) in test_cases { + let result = diff(&before, &after); + match expected_kind { + None => assert!( + result.is_none(), + "Expected no change for {:?} -> {:?}", + before, + after + ), + Some(kind) => { + let change = result.expect("Expected a change"); + assert_eq!( + change.kind, kind, + "Wrong change kind for {:?} -> {:?}", + before, after + ); + } + } + } +} + +// AC 1 Tests: verify + +#[test] +fn verify_no_change() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/file.txt"); + let identity = FileIdentity { + volume: 1, + file_id: 1100, + }; + fs.set_file(path.clone(), b"content".to_vec(), identity); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let baseline = snapshot(&roots, &fs); + + let result = verify(&roots, &baseline, &fs); + assert!(result.changed.is_empty()); +} + +#[test] +fn verify_file_modified() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/file.txt"); + let identity = FileIdentity { + volume: 1, + file_id: 1200, + }; + fs.set_file(path.clone(), b"new_content".to_vec(), identity); + + let mut baseline = BTreeMap::new(); + baseline.insert( + path.clone(), + FileSnapshot::File { + sha256: [1; 32], + identity, + }, + ); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = verify(&roots, &baseline, &fs); + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Modified); +} + +#[test] +fn verify_file_identity_changed() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/file.txt"); + let old_identity = FileIdentity { + volume: 1, + file_id: 1300, + }; + let new_identity = FileIdentity { + volume: 1, + file_id: 1301, + }; + fs.set_file(path.clone(), b"content".to_vec(), new_identity); + + let mut baseline = BTreeMap::new(); + baseline.insert( + path.clone(), + FileSnapshot::File { + sha256: [1; 32], + identity: old_identity, + }, + ); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = verify(&roots, &baseline, &fs); + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Replaced); +} + +#[test] +fn verify_file_appeared() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/file.txt"); + let identity = FileIdentity { + volume: 1, + file_id: 1400, + }; + fs.set_file(path.clone(), b"content".to_vec(), identity); + + let mut baseline = BTreeMap::new(); + baseline.insert(path.clone(), FileSnapshot::Absent); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = verify(&roots, &baseline, &fs); + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Appeared); +} + +#[test] +fn verify_file_removed() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/file.txt"); + fs.set_absent(path.clone()); + + let mut baseline = BTreeMap::new(); + let identity = FileIdentity { + volume: 1, + file_id: 1500, + }; + baseline.insert( + path.clone(), + FileSnapshot::File { + sha256: [1; 32], + identity, + }, + ); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = verify(&roots, &baseline, &fs); + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Disappeared); +} + +#[test] +fn verify_folder_child_added() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/folder"); + let identity = FileIdentity { + volume: 1, + file_id: 1600, + }; + fs.set_folder(path.clone(), vec![OsString::from("new_child")], identity); + + let mut baseline = BTreeMap::new(); + baseline.insert( + path.clone(), + FileSnapshot::Folder { + sha256: [1; 32], + identity, + }, + ); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = verify(&roots, &baseline, &fs); + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Modified); +} + +#[test] +fn verify_folder_replaced() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/folder"); + let old_identity = FileIdentity { + volume: 1, + file_id: 1700, + }; + let new_identity = FileIdentity { + volume: 1, + file_id: 1701, + }; + fs.set_folder(path.clone(), vec![], new_identity); + + let mut baseline = BTreeMap::new(); + baseline.insert( + path.clone(), + FileSnapshot::Folder { + sha256: [1; 32], + identity: old_identity, + }, + ); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let result = verify(&roots, &baseline, &fs); + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Replaced); +} + +#[test] +fn verify_revert_to_baseline() { + let fs = MockFs::new(); + let path = PathBuf::from("/test/file.txt"); + let identity = FileIdentity { + volume: 1, + file_id: 1800, + }; + fs.set_file(path.clone(), b"original".to_vec(), identity); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let baseline = snapshot(&roots, &fs); + + // Change the file, then revert it in place to the baseline content. + fs.set_file(path.clone(), b"changed".to_vec(), identity); + fs.set_file(path.clone(), b"original".to_vec(), identity); + + let result = verify(&roots, &baseline, &fs); + assert!( + result.changed.is_empty(), + "content equal to the baseline is no drift: {:?}", + result.changed + ); +} + +// AC 1 Tests: integrity_paths + +#[test] +fn integrity_paths_returns_thirteen_roots() { + let home = PathBuf::from("/home/user"); + let cwd = PathBuf::from("/workspace"); + + let paths = integrity_paths(&home, &cwd); + + assert_eq!(paths.len(), 13); +} + +#[test] +fn integrity_paths_modes() { + let home = PathBuf::from("/home/user"); + let cwd = PathBuf::from("/workspace"); + + let paths = integrity_paths(&home, &cwd); + + // workspace .claude and .git should be IdentityOnly + let workspace_claude = paths + .iter() + .find(|r| r.path.ends_with(".claude") && r.path.starts_with(&cwd)); + assert!(matches!( + workspace_claude, + Some(IntegrityRoot { + mode: RootMode::IdentityOnly, + .. + }) + )); + + let workspace_git = paths + .iter() + .find(|r| r.path.ends_with(".git") && r.path.starts_with(&cwd)); + assert!(matches!( + workspace_git, + Some(IntegrityRoot { + mode: RootMode::IdentityOnly, + .. + }) + )); +} + +#[test] +fn integrity_paths_covers_build_request_entries() { + // This test verifies that every executable-config entry that build_request makes + // read-only or denied is among the twelve roots + let home = PathBuf::from("/home/user"); + let cwd = PathBuf::from("/workspace"); + + let paths = integrity_paths(&home, &cwd); + + // User scope + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".claude/settings.json") && r.path.starts_with(&home)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".claude/hooks") && r.path.starts_with(&home)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".claude/plugins") && r.path.starts_with(&home)) + ); + + // Workspace scope + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".claude") && r.path.starts_with(&cwd)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".claude/settings.json") && r.path.starts_with(&cwd)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".claude/settings.local.json") && r.path.starts_with(&cwd)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".claude/hooks") && r.path.starts_with(&cwd)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".mcp.json") && r.path.starts_with(&cwd)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".git") && r.path.starts_with(&cwd)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with("HEAD") && r.path.starts_with(&cwd)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".git/config") && r.path.starts_with(&cwd)) + ); + assert!( + paths + .iter() + .any(|r| r.path.ends_with(".git/hooks") && r.path.starts_with(&cwd)) + ); +} + +#[test] +fn integrity_paths_git_as_file_records_children_absent() { + let fs = MockFs::new(); + let git_path = PathBuf::from("/workspace/.git"); + let git_identity = FileIdentity { + volume: 1, + file_id: 1900, + }; + fs.set_file( + git_path.clone(), + b"gitdir: ../.git/worktrees/foo".to_vec(), + git_identity, + ); + + let roots = vec![ + IntegrityRoot { + path: PathBuf::from("/workspace/.git/config"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: PathBuf::from("/workspace/.git/hooks"), + mode: RootMode::Recursive, + }, + IntegrityRoot { + path: PathBuf::from("/workspace/.git/commondir"), + mode: RootMode::Recursive, + }, + ]; + + let result = snapshot(&roots, &fs); + + // All three should be Absent because .git is a file, not a directory + assert_eq!( + result.get(&PathBuf::from("/workspace/.git/config")), + Some(&FileSnapshot::Absent) + ); + assert_eq!( + result.get(&PathBuf::from("/workspace/.git/hooks")), + Some(&FileSnapshot::Absent) + ); + assert_eq!( + result.get(&PathBuf::from("/workspace/.git/commondir")), + Some(&FileSnapshot::Absent) + ); +} + +#[test] +fn integrity_paths_identity_only_child_added_no_change() { + let fs = MockFs::new(); + let path = PathBuf::from("/workspace/.claude"); + let identity = FileIdentity { + volume: 1, + file_id: 2000, + }; + + fs.set_folder(path.clone(), vec![], identity); + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::IdentityOnly, + }]; + let baseline = snapshot(&roots, &fs); + + // Add a child + fs.set_folder(path.clone(), vec![OsString::from("new_file")], identity); + let result = verify(&roots, &baseline, &fs); + + // Should be no change because it's IdentityOnly mode + assert!(result.changed.is_empty()); +} + +#[test] +fn integrity_paths_folder_renamed_and_recreated_replaced() { + let fs1 = MockFs::new(); + let path = PathBuf::from("/workspace/.claude"); + let identity1 = FileIdentity { + volume: 1, + file_id: 2100, + }; + fs1.set_folder(path.clone(), vec![], identity1); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::IdentityOnly, + }]; + let baseline = snapshot(&roots, &fs1); + + // Recreate with different identity + let fs2 = MockFs::new(); + let identity2 = FileIdentity { + volume: 1, + file_id: 2101, + }; + fs2.set_folder(path.clone(), vec![], identity2); + + let result = verify(&roots, &baseline, &fs2); + + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Replaced); +} + +// AC 1 Tests: link handling + +#[test] +fn link_child_never_followed_target_content_changes() { + let fs = MockFs::new(); + let folder_path = PathBuf::from("/test/folder"); + let folder_identity = FileIdentity { + volume: 1, + file_id: 2200, + }; + let link_path = PathBuf::from("/test/folder/link"); + let link_identity = FileIdentity { + volume: 1, + file_id: 2201, + }; + + fs.set_folder( + folder_path.clone(), + vec![OsString::from("link")], + folder_identity, + ); + fs.set_link(link_path.clone(), OsString::from("/target"), link_identity); + + let roots = vec![IntegrityRoot { + path: folder_path.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + // Link should be in snapshot but target should not be followed + // (tested by checking the parent hash doesn't change when target content changes) + assert!(result.contains_key(&folder_path)); +} + +#[test] +fn link_target_changes_parent_hash_changes() { + let fs1 = MockFs::new(); + let folder_path = PathBuf::from("/test/folder"); + let folder_identity = FileIdentity { + volume: 1, + file_id: 2300, + }; + let link_path = PathBuf::from("/test/folder/link"); + let link_identity = FileIdentity { + volume: 1, + file_id: 2301, + }; + + fs1.set_folder( + folder_path.clone(), + vec![OsString::from("link")], + folder_identity, + ); + fs1.set_link(link_path.clone(), OsString::from("/target1"), link_identity); + + let roots = vec![IntegrityRoot { + path: folder_path.clone(), + mode: RootMode::Recursive, + }]; + let result1 = snapshot(&roots, &fs1); + + // Change link target + let fs2 = MockFs::new(); + fs2.set_folder( + folder_path.clone(), + vec![OsString::from("link")], + folder_identity, + ); + fs2.set_link(link_path, OsString::from("/target2"), link_identity); + + let result2 = snapshot(&roots, &fs2); + + // Parent hash should change because link target changed + assert_ne!(result1.get(&folder_path), result2.get(&folder_path)); +} + +// AC 1 Tests: .git handling + +#[test] +fn git_file_content_changed_modified() { + let fs1 = MockFs::new(); + let path = PathBuf::from("/workspace/.git"); + let identity = FileIdentity { + volume: 1, + file_id: 2400, + }; + fs1.set_file(path.clone(), b"gitdir: ../foo".to_vec(), identity); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::IdentityOnly, + }]; + let baseline = snapshot(&roots, &fs1); + + let fs2 = MockFs::new(); + fs2.set_file(path.clone(), b"gitdir: ../bar".to_vec(), identity); + + let result = verify(&roots, &baseline, &fs2); + + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Modified); +} + +#[test] +fn git_folder_renamed_and_recreated_replaced() { + let fs1 = MockFs::new(); + let path = PathBuf::from("/workspace/.git"); + let identity1 = FileIdentity { + volume: 1, + file_id: 2500, + }; + fs1.set_folder(path.clone(), vec![], identity1); + + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::IdentityOnly, + }]; + let baseline = snapshot(&roots, &fs1); + + let fs2 = MockFs::new(); + let identity2 = FileIdentity { + volume: 1, + file_id: 2501, + }; + fs2.set_folder(path.clone(), vec![], identity2); + + let result = verify(&roots, &baseline, &fs2); + + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Replaced); +} + +// AC 1 Tests: Platform-specific file identity + +#[test] +fn a_file_renamed_over_another_changes_its_identity_and_is_a_replacement() { + // The F5 case on the real filesystem: identical bytes, new file identity. + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("settings.json"); + let other = temp.path().join("settings.json.tmp"); + std::fs::write(&target, b"{}").unwrap(); + + let roots = vec![IntegrityRoot { + path: target.clone(), + mode: RootMode::Recursive, + }]; + let baseline = snapshot(&roots, &REAL_FS); + let before_identity = REAL_FS.file_identity(&target).unwrap(); + + std::fs::write(&other, b"{}").unwrap(); + std::fs::rename(&other, &target).unwrap(); + + let after_identity = REAL_FS.file_identity(&target).unwrap(); + assert_ne!(before_identity, after_identity, "rename keeps the identity"); + + let result = verify(&roots, &baseline, &REAL_FS); + assert_eq!(result.changed.len(), 1, "{:?}", result.changed); + assert_eq!(result.changed[0].1.kind, ChangeKind::Replaced); +} + +// AC 2 Tests: run_periodic + +#[tokio::test] +async fn run_periodic_reports_a_revert_and_a_repeated_change() { + let fs = Arc::new(MockFs::new()); + let path = PathBuf::from("/test/settings.json"); + let identity = FileIdentity { + volume: 1, + file_id: 4100, + }; + fs.set_file(path.clone(), b"original".to_vec(), identity); + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let baseline = snapshot(&roots, fs.as_ref() as &dyn IntegrityFs); + + let drift_count = Arc::new(Mutex::new(0usize)); + let drift_count_clone = drift_count.clone(); + let on_drift = Arc::new(move |_path: PathBuf, _change: Change| { + *drift_count_clone.lock().unwrap() += 1; + }); + + tokio::time::pause(); + tokio::spawn(run_periodic( + roots, + baseline, + fs.clone(), + Duration::from_secs(60), + on_drift, + )); + // Poll the task once so its timer starts at t=0. + tokio::task::yield_now().await; + + for (content, expected) in [ + (&b"EVIL"[..], 1usize), // change + (&b"original"[..], 2), // revert to the launch state + (&b"EVIL"[..], 3), // the same change again + (&b"EVIL"[..], 3), // quiet period: no new alert + ] { + fs.set_file(path.clone(), content.to_vec(), identity); + tokio::time::advance(Duration::from_secs(61)).await; + for _ in 0..1000 { + if *drift_count.lock().unwrap() >= expected { + break; + } + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(*drift_count.lock().unwrap(), expected, "{content:?}"); + } +} + +#[tokio::test] +async fn run_periodic_reports_appear_disappear_and_reappear() { + let fs = Arc::new(MockFs::new()); + let path = PathBuf::from("/test/settings.json"); + let identity = FileIdentity { + volume: 1, + file_id: 4200, + }; + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let baseline = snapshot(&roots, fs.as_ref() as &dyn IntegrityFs); // absent + + let kinds = Arc::new(Mutex::new(Vec::new())); + let kinds_clone = kinds.clone(); + let on_drift = Arc::new(move |_path: PathBuf, change: Change| { + kinds_clone.lock().unwrap().push(change.kind); + }); + + tokio::time::pause(); + tokio::spawn(run_periodic( + roots, + baseline, + fs.clone(), + Duration::from_secs(60), + on_drift, + )); + // Poll the task once so its timer starts at t=0. + tokio::task::yield_now().await; + + for (step, expected) in [(0usize, 1usize), (1, 2), (2, 3)] { + match step { + 0 => fs.set_file(path.clone(), b"planted".to_vec(), identity), + 1 => fs.set_absent(path.clone()), + _ => fs.set_file(path.clone(), b"planted".to_vec(), identity), + } + tokio::time::advance(Duration::from_secs(61)).await; + for _ in 0..1000 { + if kinds.lock().unwrap().len() >= expected { + break; + } + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(kinds.lock().unwrap().len(), expected); + } + assert_eq!( + *kinds.lock().unwrap(), + vec![ + ChangeKind::Appeared, + ChangeKind::Disappeared, + ChangeKind::Appeared + ] + ); +} + +#[test] +fn an_identity_only_root_that_appears_keeps_its_mode() { + let fs = MockFs::new(); + let path = PathBuf::from("/workspace/.claude"); + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::IdentityOnly, + }]; + let baseline = snapshot(&roots, &fs); // absent at launch + + let identity = FileIdentity { + volume: 1, + file_id: 4300, + }; + fs.set_folder(path.clone(), vec!["settings.json".into()], identity); + fs.set_file( + path.join("settings.json"), + b"{}".to_vec(), + FileIdentity { + volume: 1, + file_id: 4301, + }, + ); + + let result = verify(&roots, &baseline, &fs); + assert_eq!(result.changed.len(), 1); + assert_eq!(result.changed[0].1.kind, ChangeKind::Appeared); + assert!( + matches!( + result.changed[0].1.after, + FileSnapshot::FolderIdentity { .. } + ), + "the declared mode is kept once the root appears: {:?}", + result.changed[0].1.after + ); +} + +#[tokio::test] +async fn run_periodic_scenario_a_no_call_at_59s_one_at_61s_two_at_121s() { + let fs = Arc::new(MockFs::new()); + let path = PathBuf::from("/test/file.txt"); + let identity = FileIdentity { + volume: 1, + file_id: 3000, + }; + fs.set_file(path.clone(), b"content".to_vec(), identity); + + let mut baseline = BTreeMap::new(); + baseline.insert( + path.clone(), + FileSnapshot::File { + sha256: [1; 32], + identity, + }, + ); + + let on_drift = Arc::new(|_path: PathBuf, _change: Change| {}); + + // Check at 59s - no call + tokio::time::pause(); + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + tokio::spawn(run_periodic( + roots, + baseline.clone(), + fs.clone(), + Duration::from_secs(60), + on_drift.clone(), + )); + // Poll the task once so its timer starts at t=0. + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(59)).await; + for _ in 0..10 { + tokio::task::yield_now().await; + } + assert_eq!(fs.metadata_call_count(), 0); + + // Check at 61s - one call + tokio::time::advance(Duration::from_secs(2)).await; + for _ in 0..1000 { + if fs.metadata_call_count() >= 1 { + break; + } + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(fs.metadata_call_count(), 1); + + // Check at 121s - two calls + tokio::time::advance(Duration::from_secs(60)).await; + for _ in 0..1000 { + if fs.metadata_call_count() >= 2 { + break; + } + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(fs.metadata_call_count(), 2); +} + +#[tokio::test] +async fn run_periodic_scenario_b_abort_at_90s_exactly_one_call() { + let fs = Arc::new(MockFs::new()); + let path = PathBuf::from("/test/file.txt"); + let identity = FileIdentity { + volume: 1, + file_id: 3100, + }; + fs.set_file(path.clone(), b"content".to_vec(), identity); + + let mut baseline = BTreeMap::new(); + baseline.insert( + path.clone(), + FileSnapshot::File { + sha256: [1; 32], + identity, + }, + ); + + let on_drift = Arc::new(|_path: PathBuf, _change: Change| {}); + + tokio::time::pause(); + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + let handle = tokio::spawn(run_periodic( + roots, + baseline, + fs.clone(), + Duration::from_secs(60), + on_drift, + )); + // Poll the task once so its timer starts at t=0. + tokio::task::yield_now().await; + + tokio::time::advance(Duration::from_secs(90)).await; + for _ in 0..1000 { + if fs.metadata_call_count() >= 1 { + break; + } + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + handle.abort(); + + // Should have exactly one call at t=60 + assert_eq!(fs.metadata_call_count(), 1); + + // No further call at 180s + tokio::time::advance(Duration::from_secs(90)).await; + for _ in 0..100 { + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(fs.metadata_call_count(), 1); +} + +#[tokio::test] +async fn run_periodic_scenario_c_change_revert_second_replacement() { + let fs = Arc::new(MockFs::new()); + let path = PathBuf::from("/test/file.txt"); + let identity = FileIdentity { + volume: 1, + file_id: 3200, + }; + fs.set_file(path.clone(), b"content".to_vec(), identity); + + let mut baseline = BTreeMap::new(); + baseline.insert( + path.clone(), + FileSnapshot::File { + sha256: [1; 32], + identity, + }, + ); + + let drift_count = Arc::new(Mutex::new(0)); + let drift_count_clone = drift_count.clone(); + let on_drift = Arc::new(move |_path: PathBuf, _change: Change| { + *drift_count_clone.lock().unwrap() += 1; + }); + + tokio::time::pause(); + let roots = vec![IntegrityRoot { + path: path.clone(), + mode: RootMode::Recursive, + }]; + tokio::spawn(run_periodic( + roots, + baseline, + fs.clone(), + Duration::from_secs(60), + on_drift, + )); + // Poll the task once so its timer starts at t=0. + tokio::task::yield_now().await; + + // Change before 60s + fs.set_file(path.clone(), b"changed".to_vec(), identity); + tokio::time::advance(Duration::from_secs(61)).await; + for _ in 0..1000 { + if *drift_count.lock().unwrap() >= 1 { + break; + } + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + + // One on_drift call + assert_eq!(*drift_count.lock().unwrap(), 1); + + // No second call at 120s and 180s (same state) + tokio::time::advance(Duration::from_secs(60)).await; + for _ in 0..100 { + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(*drift_count.lock().unwrap(), 1); + tokio::time::advance(Duration::from_secs(60)).await; + for _ in 0..100 { + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(*drift_count.lock().unwrap(), 1); + + // Second replacement + fs.set_file(path.clone(), b"changed_again".to_vec(), identity); + tokio::time::advance(Duration::from_secs(60)).await; + for _ in 0..1000 { + if *drift_count.lock().unwrap() >= 2 { + break; + } + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(*drift_count.lock().unwrap(), 2); + + // Revert + fs.set_file(path, b"changed".to_vec(), identity); + tokio::time::advance(Duration::from_secs(60)).await; + for _ in 0..1000 { + if *drift_count.lock().unwrap() >= 3 { + break; + } + tokio::task::yield_now().await; + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(*drift_count.lock().unwrap(), 3); +} + +// v5: child identity tests + +#[test] +fn child_replaced_with_identical_bytes_detected_via_identity() { + // A child inside a watched folder replaced with identical bytes (new identity) + // should be detected as Modified. + let temp = tempfile::tempdir().unwrap(); + let folder = temp.path().join("hooks"); + let child = folder.join("pre-commit"); + std::fs::create_dir(&folder).unwrap(); + std::fs::write(&child, b"#!/bin/sh\nexit 0\n").unwrap(); + + let roots = vec![IntegrityRoot { + path: folder.clone(), + mode: RootMode::Recursive, + }]; + let baseline = snapshot(&roots, &REAL_FS); + let before_identity = REAL_FS.file_identity(&child).unwrap(); + + // Replace with identical bytes via temp + rename + let temp_file = folder.join("pre-commit.tmp"); + std::fs::write(&temp_file, b"#!/bin/sh\nexit 0\n").unwrap(); + std::fs::rename(&temp_file, &child).unwrap(); + + let after_identity = REAL_FS.file_identity(&child).unwrap(); + assert_ne!( + before_identity, after_identity, + "rename should change identity" + ); + + let result = verify(&roots, &baseline, &REAL_FS); + assert_eq!(result.changed.len(), 1, "{:?}", result.changed); + assert_eq!( + result.changed[0].0, folder, + "the folder should be reported as changed" + ); + assert_eq!( + result.changed[0].1.kind, + ChangeKind::Modified, + "folder should be Modified due to child identity change" + ); +} + +#[test] +fn structure_hash_detects_recreated_nested_directory() { + // A nested directory removed and recreated (new identity) should be detected + // in the structure_hash path. + let fs = MockFs::new(); + let root = PathBuf::from("/test/plugins"); + let nested = PathBuf::from("/test/plugins/subdir"); + let root_identity = FileIdentity { + volume: 1, + file_id: 500, + }; + let nested_identity_before = FileIdentity { + volume: 1, + file_id: 501, + }; + + // Create a large folder to trigger structure_hash path + let mut children = Vec::new(); + for i in 0..1001 { + children.push(OsString::from(format!("file{}", i))); + } + children.push(OsString::from("subdir")); + + // Set up files to trigger TooLarge + for i in 0..1001 { + let file_path = PathBuf::from(format!("/test/plugins/file{}", i)); + let file_identity = FileIdentity { + volume: 1, + file_id: 600 + i as u128, + }; + fs.set_file(file_path, vec![0u8; 100], file_identity); + } + + fs.set_folder(nested.clone(), vec![], nested_identity_before); + fs.set_folder(root.clone(), children.clone(), root_identity); + + let roots = vec![IntegrityRoot { + path: root.clone(), + mode: RootMode::Recursive, + }]; + let baseline = snapshot(&roots, &fs); + + // Verify we're using structure (TooLarge) + assert!(matches!( + baseline.get(&root), + Some(FileSnapshot::TooLarge { .. }) + )); + + // Recreate the nested directory with new identity + let nested_identity_after = FileIdentity { + volume: 1, + file_id: 999, + }; + fs.set_folder(nested, vec![], nested_identity_after); + + let result = verify(&roots, &baseline, &fs); + assert_eq!(result.changed.len(), 1, "{:?}", result.changed); + assert_eq!( + result.changed[0].1.kind, + ChangeKind::Modified, + "recreated directory should be detected via structure_hash identity" + ); +} + +#[test] +fn read_bytes_bounded_detects_grown_file_as_too_large() { + // A file that grows past MAX_BYTES between metadata and read should be + // caught as TooLarge without unbounded allocation. + let fs = MockFs::new(); + let folder = PathBuf::from("/test/workspace"); + let file = PathBuf::from("/test/workspace/mcp.json"); + let folder_identity = FileIdentity { + volume: 1, + file_id: 700, + }; + let file_identity = FileIdentity { + volume: 1, + file_id: 701, + }; + + // Set file to MAX_BYTES + 1 (the read will see this size) + let oversized_content = vec![0u8; (MAX_BYTES + 1) as usize]; + fs.set_file(file.clone(), oversized_content, file_identity); + fs.set_folder( + folder.clone(), + vec![OsString::from("mcp.json")], + folder_identity, + ); + + let roots = vec![IntegrityRoot { + path: folder.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + // The bounded read limits to MAX_BYTES+1, the post-check catches it as TooLarge + assert!( + matches!(result.get(&folder), Some(FileSnapshot::TooLarge { .. })), + "oversized file should trigger TooLarge, not unbounded allocation" + ); +} + +#[test] +fn read_bytes_enforces_max_bytes_limit_on_real_file() { + // Verify that read_bytes actually limits the read to MAX_BYTES + 1, + // independent of metadata checks. A sparse file larger than the limit + // should yield exactly MAX_BYTES + 1 bytes. + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join(format!("integrity_read_test_{}", std::process::id())); + + // Create a sparse file of MAX_BYTES + 4096 + let file = std::fs::File::create(&test_file).unwrap(); + file.set_len(MAX_BYTES + 4096).unwrap(); + drop(file); + + let result = REAL_FS.read_bytes(&test_file).unwrap(); + std::fs::remove_file(&test_file).unwrap(); + + assert_eq!( + result.len() as u64, + MAX_BYTES + 1, + "read_bytes should limit read to MAX_BYTES + 1 even when file is larger" + ); +} + +#[cfg(unix)] +#[test] +fn structure_hash_detects_swapped_symlink_via_identity() { + // A symlink swapped to a different target with a different identity + // should be detected via identity in structure_hash. + let fs = MockFs::new(); + let root = PathBuf::from("/test/plugins"); + let link_path = PathBuf::from("/test/plugins/link1"); + let root_identity = FileIdentity { + volume: 1, + file_id: 800, + }; + let link_identity_before = FileIdentity { + volume: 1, + file_id: 801, + }; + + // Create a large folder to trigger structure_hash path + let mut children = Vec::new(); + for i in 0..1001 { + children.push(OsString::from(format!("file{}", i))); + } + children.push(OsString::from("link1")); + + // Set up files to trigger TooLarge + for i in 0..1001 { + let file_path = PathBuf::from(format!("/test/plugins/file{}", i)); + let file_identity = FileIdentity { + volume: 1, + file_id: 900 + i as u128, + }; + fs.set_file(file_path, vec![0u8; 100], file_identity); + } + + fs.set_link( + link_path.clone(), + OsString::from("/target1"), + link_identity_before, + ); + fs.set_folder(root.clone(), children.clone(), root_identity); + + let roots = vec![IntegrityRoot { + path: root.clone(), + mode: RootMode::Recursive, + }]; + let baseline = snapshot(&roots, &fs); + + // Verify we're using structure (TooLarge) + assert!(matches!( + baseline.get(&root), + Some(FileSnapshot::TooLarge { .. }) + )); + + // Swap the symlink with a different identity (same target) + let link_identity_after = FileIdentity { + volume: 1, + file_id: 802, + }; + fs.set_link(link_path, OsString::from("/target1"), link_identity_after); + + let result = verify(&roots, &baseline, &fs); + + // The identity change should be detected via structure_hash + assert!( + !result.changed.is_empty(), + "link identity change should be detected in structure_hash" + ); + assert_eq!( + result.changed[0].1.kind, + ChangeKind::Modified, + "link identity change should report as Modified" + ); +} + +#[test] +fn snapshot_folder_exceeds_depth_limit() { + // Regression test for external review finding: deeply nested folders can + // overflow the 2 MiB blocking-thread stack. With the depth limit, they + // degrade to TooLarge instead. This test creates a chain deeper than + // MAX_DEPTH and verifies the limit is enforced. Removing the depth check + // would cause this test to fail by allowing recursion to continue past + // the limit. + let fs = MockFs::new(); + + const TEST_DEPTH: usize = 100; // Well above MAX_DEPTH (64) + let mut path = PathBuf::from("/root"); + + // Build a deep chain of single-child folders + for i in 0..TEST_DEPTH { + let identity = FileIdentity { + volume: 1, + file_id: i as u128, + }; + + // Each folder has one child (the next level down) + if i < TEST_DEPTH - 1 { + let child_name = OsString::from(format!("level{}", i + 1)); + fs.set_folder(path.clone(), vec![child_name], identity); + path = path.join(format!("level{}", i + 1)); + } else { + // Leaf folder is empty + fs.set_folder(path.clone(), vec![], identity); + } + } + + let root = PathBuf::from("/root"); + let roots = vec![IntegrityRoot { + path: root.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + // The depth limit should trigger TooLarge, not a stack overflow + assert!( + matches!(result.get(&root), Some(FileSnapshot::TooLarge { .. })), + "deeply nested folder should degrade to TooLarge when depth exceeds MAX_DEPTH" + ); +} + +#[test] +fn an_over_budget_root_with_a_deep_chain_degrades_to_top_level_names() { + // The structure fallback has its own depth check. When the under-budget + // walker stops on the ENTRY budget (not on depth), only that check keeps + // a >MAX_DEPTH chain from overflowing the stack; past it, the snapshot + // holds the hash of the top-level names only. Removing the depth check in + // structure_hash makes the fallback hash the full structure instead, and + // the equality below fails. + let fs = MockFs::new(); + let root = PathBuf::from("/root"); + let root_identity = FileIdentity { + volume: 1, + file_id: 1, + }; + + // Over the entry budget at the top level: 1001 files plus the chain head. + let mut names: Vec = Vec::new(); + for i in 0..1001 { + let name = OsString::from(format!("file{i}")); + fs.set_file( + root.join(&name), + vec![0u8; 4], + FileIdentity { + volume: 1, + file_id: 1000 + i as u128, + }, + ); + names.push(name); + } + names.push(OsString::from("deep0")); + fs.set_folder(root.clone(), names.clone(), root_identity); + + // A chain deeper than MAX_DEPTH under the chain head. + let mut path = root.clone(); + for i in 0..100usize { + path = path.join(format!("deep{i}")); + let identity = FileIdentity { + volume: 1, + file_id: 10_000 + i as u128, + }; + if i < 99 { + fs.set_folder( + path.clone(), + vec![OsString::from(format!("deep{}", i + 1))], + identity, + ); + } else { + fs.set_folder(path.clone(), vec![], identity); + } + } + + let roots = vec![IntegrityRoot { + path: root.clone(), + mode: RootMode::Recursive, + }]; + let result = snapshot(&roots, &fs); + + assert_eq!( + result.get(&root), + Some(&FileSnapshot::TooLarge { + identity: root_identity, + structure_sha256: super::hash_dir_names(&names), + }), + "an over-budget root with a too-deep chain records top-level names only" + ); +} diff --git a/crates/agent/src/launch/mod.rs b/crates/agent/src/launch/mod.rs index ae29ee58..94fa7b19 100644 --- a/crates/agent/src/launch/mod.rs +++ b/crates/agent/src/launch/mod.rs @@ -36,6 +36,7 @@ use serde::Serialize; use crate::{api, secure_fs}; pub mod host; +pub mod integrity; #[cfg(test)] mod tests; #[cfg(test)] @@ -694,6 +695,64 @@ pub trait LaunchChild: Send { fn kill(&mut self) -> io::Result<()>; } +/// Hex of a file's content hash; "N/A" for everything that is not a file. +fn format_sha256(snapshot: &integrity::FileSnapshot) -> String { + match snapshot { + integrity::FileSnapshot::File { sha256, .. } => { + let mut hex = String::with_capacity(64); + for byte in sha256 { + hex.push_str(&format!("{:02x}", byte)); + } + hex + } + _ => "N/A".to_string(), + } +} + +/// The launch-time integrity state: the checked roots, their snapshot taken +/// before spawn, and the filesystem the checks read. +struct IntegrityCheck { + roots: Vec, + baseline: BTreeMap, + fs: Arc, +} + +impl IntegrityCheck { + /// Verifies against the launch-time snapshot off the async runtime, logs + /// each change, and returns whether anything changed. + async fn verify_and_log(&self, launch_id: &str) -> bool { + let roots = self.roots.clone(); + let baseline = self.baseline.clone(); + let fs = self.fs.clone(); + let result = match tokio::task::spawn_blocking(move || { + integrity::verify(&roots, &baseline, fs.as_ref()) + }) + .await + { + Ok(result) => result, + Err(error) => { + tracing::warn!(launch_id, %error, "integrity check failed"); + return false; + } + }; + let mut any = false; + for (path, change) in result.changed { + any = true; + let before_sha256 = format_sha256(&change.before); + let after_sha256 = format_sha256(&change.after); + tracing::warn!( + launch_id, + path = %path.display(), + change = %change.kind, + before_sha256, + after_sha256, + "executable config changed while launch ran" + ); + } + any + } +} + fn stop_executor(child: &mut dyn LaunchChild, launch_id: &str) { if let Err(error) = child.kill() { tracing::warn!(launch_id, %error, "failed to stop the executor process"); @@ -723,6 +782,10 @@ pub trait LaunchHost: Send + Sync { fn program_files_claude(&self) -> Option; fn client_exe_dir(&self) -> PathBuf; fn now(&self) -> u64; + + fn integrity_fs(&self) -> Arc { + Arc::new(integrity::REAL_FS) + } } /// Running launches plus the last 32 exited ones. Clones share state. @@ -791,6 +854,16 @@ impl LaunchRegistry { } } + pub fn set_integrity_drift(&self, launch_id: &str) { + if let Some(launch) = self + .lock() + .iter_mut() + .find(|launch| launch.launch_id == launch_id) + { + launch.integrity_drift = true; + } + } + /// Ordered by `started_at_unix_seconds` ascending. pub fn list(&self) -> Vec { let mut launches = self.lock().clone(); @@ -997,6 +1070,40 @@ async fn start_launch( Ok(path) => path, Err(error) => return fail(&launch_dir, format!("prepare launch: {error:#}")), }; + + // Take the integrity snapshot before spawn + let integrity_roots = integrity::integrity_paths(&host.home(), &input.cwd); + let integrity_fs = host.integrity_fs(); + let baseline = tokio::task::spawn_blocking({ + let roots = integrity_roots.clone(); + let fs = integrity_fs.clone(); + move || integrity::snapshot(&roots, fs.as_ref()) + }) + .await + .unwrap_or_else(|error| { + tracing::warn!(launch_id, %error, "integrity snapshot failed; every root will be reported as appeared"); + BTreeMap::new() + }); + + // Over-budget roots are watched by structure (names, sizes, identities), + // not content; that is expected on real machines (plugins), so debug. + for (path, snap) in &baseline { + match snap { + integrity::FileSnapshot::TooLarge { .. } => { + tracing::debug!(launch_id, path = %path.display(), "integrity check: over the content budget, structure only"); + } + integrity::FileSnapshot::Unreadable { kind } => { + tracing::warn!(launch_id, path = %path.display(), ?kind, "integrity check: root unreadable at launch; content unwatched while it stays so"); + } + _ => {} + } + } + let integrity_check = IntegrityCheck { + roots: integrity_roots, + baseline, + fs: integrity_fs, + }; + let mut child = match state .executor @@ -1013,6 +1120,7 @@ async fn start_launch( format!("{} {}", child.pid(), child.start_time()), ) { stop_executor(child.as_mut(), &launch_id); + integrity_check.verify_and_log(&launch_id).await; return fail(&launch_dir, format!("record the launch pid: {error}")); } @@ -1023,6 +1131,7 @@ async fn start_launch( match child.try_wait() { Ok(None) => {} Ok(Some(code)) => { + integrity_check.verify_and_log(&launch_id).await; let tail = log_tail(&log_path); return fail( &launch_dir, @@ -1034,6 +1143,7 @@ async fn start_launch( } Err(error) => { stop_executor(child.as_mut(), &launch_id); + integrity_check.verify_and_log(&launch_id).await; return fail(&launch_dir, format!("watch wxc-exec: {error}")); } } @@ -1053,9 +1163,37 @@ async fn start_launch( started_at_unix_seconds: started_at, ended_at_unix_seconds: None, credential_expires_at_unix_seconds: Some(input.credential.expires_at_unix_seconds), + integrity_drift: false, }; state.registry.insert(launch.clone()); tracing::info!(launch_id, cwd = %launch.cwd.display(), "sandboxed launch started"); + + // Start periodic integrity check + let periodic = { + let registry = state.registry.clone(); + let launch_id_clone = launch_id.clone(); + let on_drift = Arc::new(move |path: PathBuf, change: integrity::Change| { + let before_sha256 = format_sha256(&change.before); + let after_sha256 = format_sha256(&change.after); + tracing::warn!( + launch_id = launch_id_clone, + path = %path.display(), + change = %change.kind, + before_sha256, + after_sha256, + "executable config changed during launch" + ); + registry.set_integrity_drift(&launch_id_clone); + }); + tokio::spawn(integrity::run_periodic( + integrity_check.roots.clone(), + integrity_check.baseline.clone(), + integrity_check.fs.clone(), + Duration::from_secs(60), + on_drift, + )) + }; + let refresh = tokio::spawn(refresh_task( state.credentials.clone(), host.clone(), @@ -1067,11 +1205,13 @@ async fn start_launch( tokio::spawn(poll_task( child, refresh.abort_handle(), + periodic.abort_handle(), state.registry.clone(), - host, + host.clone(), launch_id, launch_dir, log_path, + integrity_check, )); Ok((StatusCode::CREATED, Json(launch))) } @@ -1097,14 +1237,17 @@ fn log_tail(log: &Path) -> String { /// Watches the executor: copies new log lines to the daemon log. When it exits, /// or when it can no longer be watched (then it is stopped first), records the /// exit, stops the refresh and removes the launch directory. +#[allow(clippy::too_many_arguments)] async fn poll_task( mut child: Box, refresh: tokio::task::AbortHandle, + periodic: tokio::task::AbortHandle, registry: LaunchRegistry, host: Arc, launch_id: String, launch_dir: PathBuf, log_path: PathBuf, + integrity_check: IntegrityCheck, ) { let mut log_offset = 0u64; loop { @@ -1119,7 +1262,16 @@ async fn poll_task( None } }; + + // Stop the background tasks, then verify once against the launch + // snapshot before the launch is recorded as exited. + periodic.abort(); refresh.abort(); + + if integrity_check.verify_and_log(&launch_id).await { + registry.set_integrity_drift(&launch_id); + } + registry.mark_exited(&launch_id, exit, host.now()); if let Err(error) = std::fs::remove_dir_all(&launch_dir) { tracing::warn!(launch_id, %error, "failed to remove the launch directory"); diff --git a/crates/agent/src/launch/tests.rs b/crates/agent/src/launch/tests.rs index 4fc2054b..5d0b804f 100644 --- a/crates/agent/src/launch/tests.rs +++ b/crates/agent/src/launch/tests.rs @@ -895,6 +895,61 @@ fn readwrite(value: &serde_json::Value) -> Vec { list(value, "/filesystem/readwritePaths") } +/// The integrity roots and the request's protections must not drift apart: +/// every recursively checked root is read-only or denied in the real request. +#[test] +fn the_integrity_roots_are_protected_in_the_request() { + let value = request_json(&snapshot_existing(), &snapshot_directories(), |_| {}); + let mut protected: std::collections::BTreeSet = readonly(&value).into_iter().collect(); + protected.extend(denied(&value)); + let roots = super::integrity::integrity_paths(&PathBuf::from(HOME), &PathBuf::from(CWD)); + let root_paths: std::collections::BTreeSet = roots + .iter() + .map(|root| root.path.to_str().unwrap().replace('/', "\\")) + .collect(); + for root in &roots { + if root.mode != super::integrity::RootMode::Recursive { + continue; // the identity-only folders are writable by design + } + let path = root.path.to_str().unwrap().replace('/', "\\"); + assert!( + protected.contains(&path), + "integrity root {path} is neither read-only nor denied in the request: {protected:?}" + ); + } + + // The reverse direction: every protected entry is either an integrity + // root or on this named list of protected entries outside the roots + // (installations, secret denials, and the git configuration files, which + // have their own grant). A new grant or denial must be classified here or + // in integrity_paths, never silently. + let outside_the_roots: Vec = [ + r"C:\Users\dev\.local\bin".into(), + r"C:\Users\dev\.local\share\claude".into(), + r"C:\Program Files\Git".into(), + r"C:\lab\agentdesktop".into(), + r"C:\Program Files\ClaudeCode".into(), + r"C:\Users\dev\.gitconfig".into(), + r"C:\Users\dev\.config\git\config".into(), + format!(r"{LAUNCH_DIR}\credential"), + r"C:\Users\dev\.claude\.credentials.json".into(), + r"C:\Users\dev\.claude\history.jsonl".into(), + r"C:\Users\dev\.claude\ide".into(), + r"C:\Users\dev\.ssh".into(), + r"C:\Users\dev\.aws".into(), + r"C:\Users\dev\.azure".into(), + r"C:\Users\dev\.kube".into(), + ] + .into_iter() + .collect(); + for entry in &protected { + assert!( + root_paths.contains(entry) || outside_the_roots.contains(entry), + "unclassified protected entry {entry}: add it to integrity_paths or name it here" + ); + } +} + /// Path lists are sets to the sandbox; the snapshot compares them sorted. fn sorted_paths(mut value: serde_json::Value) -> serde_json::Value { for key in ["readwritePaths", "readonlyPaths", "deniedPaths"] { @@ -1343,6 +1398,7 @@ fn launch(id: &str, started_at: u64) -> Launch { started_at_unix_seconds: started_at, ended_at_unix_seconds: None, credential_expires_at_unix_seconds: None, + integrity_drift: false, } } @@ -1464,6 +1520,34 @@ fn many_running_launches_are_never_dropped() { assert_eq!(registry.list().len(), 50); } +// AC 3: integrity_drift registry tests + +#[test] +fn integrity_drift_can_be_set_on_a_running_launch() { + let registry = LaunchRegistry::new(); + registry.insert(launch("a", 10)); + registry.set_integrity_drift("a"); + assert!(registry.list()[0].integrity_drift); +} + +#[test] +fn integrity_drift_stays_true_on_second_set() { + let registry = LaunchRegistry::new(); + registry.insert(launch("a", 10)); + registry.set_integrity_drift("a"); + assert!(registry.list()[0].integrity_drift); + registry.set_integrity_drift("a"); + assert!(registry.list()[0].integrity_drift); +} + +#[test] +fn setting_integrity_drift_on_unknown_id_is_a_noop() { + let registry = LaunchRegistry::new(); + registry.insert(launch("a", 10)); + registry.set_integrity_drift("nope"); + assert!(!registry.list()[0].integrity_drift); +} + // ================================================================== sweep (AC 9) fn make_launch_dir(state: &Path, id: &str, pid_file: Option<&str>) -> PathBuf { @@ -1583,11 +1667,48 @@ impl Clock { } } +/// Successful `read_bytes` calls recorded by `RecordingIntegrityFs`. +type IntegrityReads = Arc)>>>; + +/// Delegates to the real integrity filesystem and records every successful +/// `read_bytes`, so a test can see which bytes a verify pass read. +struct RecordingIntegrityFs { + reads: IntegrityReads, +} + +impl integrity::IntegrityFs for RecordingIntegrityFs { + fn metadata(&self, p: &Path) -> io::Result { + integrity::REAL_FS.metadata(p) + } + + fn read_bytes(&self, p: &Path) -> io::Result> { + let bytes = integrity::REAL_FS.read_bytes(p)?; + self.reads + .lock() + .unwrap() + .push((p.to_path_buf(), bytes.clone())); + Ok(bytes) + } + + fn read_dir_names(&self, p: &Path) -> io::Result> { + integrity::REAL_FS.read_dir_names(p) + } + + fn file_identity(&self, p: &Path) -> io::Result { + integrity::REAL_FS.file_identity(p) + } + + fn read_link(&self, p: &Path) -> io::Result { + integrity::REAL_FS.read_link(p) + } +} + struct FakeHost { home: PathBuf, state_dir: PathBuf, client_exe_dir: PathBuf, clock: Clock, + integrity_reads: IntegrityReads, } impl LaunchHost for FakeHost { @@ -1644,6 +1765,12 @@ impl LaunchHost for FakeHost { fn now(&self) -> u64 { self.clock.now() } + + fn integrity_fs(&self) -> Arc { + Arc::new(RecordingIntegrityFs { + reads: self.integrity_reads.clone(), + }) + } } struct StubCredentials { @@ -1719,6 +1846,8 @@ struct StubExecutor { pid_as_dir: AtomicBool, /// Records whether the launch directory existed when `kill` was called. dir_existed_at_kill: Arc>>, + /// Called at the start of `spawn` if set. + on_spawn: Mutex>>, } impl StubExecutor { @@ -1778,6 +1907,9 @@ impl Executor for StubExecutor { request: &Path, log: &Path, ) -> io::Result> { + if let Some(callback) = self.on_spawn.lock().unwrap().take() { + callback(); + } if let Some(error) = self.error.lock().unwrap().take() { return Err(error); } @@ -1851,6 +1983,7 @@ struct Harness { client: reqwest::Client, executor: Arc, credentials: Arc, + integrity_reads: IntegrityReads, } fn strip_verbatim(path: PathBuf) -> PathBuf { @@ -1898,6 +2031,7 @@ impl Harness { kills: Arc::new(AtomicU32::new(0)), pid_as_dir: AtomicBool::new(false), dir_existed_at_kill: Arc::new(Mutex::new(None)), + on_spawn: Mutex::new(None), }); let executor_path = client_exe_dir.join("wxc-exec.exe"); let availability = match setup.availability { @@ -1944,6 +2078,7 @@ impl Harness { programs: None, intercept: None, }; + let integrity_reads: IntegrityReads = Arc::new(Mutex::new(Vec::new())); let state = LaunchState { app, executor: executor.clone(), @@ -1953,6 +2088,7 @@ impl Harness { state_dir: state_dir.clone(), client_exe_dir, clock: clock.clone(), + integrity_reads: integrity_reads.clone(), }), registry: LaunchRegistry::new(), availability, @@ -1976,6 +2112,7 @@ impl Harness { client: reqwest::Client::new(), executor, credentials, + integrity_reads, } } @@ -2840,6 +2977,162 @@ async fn the_executor_is_not_stopped_after_spawn_failure_early_exit_or_normal_ex assert_eq!(h.executor.kills(), 0, "no kill after normal exit"); } +// AC 1: integrity check integration tests + +#[tokio::test(start_paused = true)] +async fn the_periodic_check_sets_the_drift_flag_while_the_box_runs() { + let h = Harness::start(Setup::default()).await; + std::fs::write(h.cwd.join(".mcp.json"), b"{}").unwrap(); + let launched = h.launch_ok().await; + + // No drift at launch. + assert!(!h.listed().await[0].integrity_drift); + + std::fs::write(h.cwd.join(".mcp.json"), b"{\"changed\":true}").unwrap(); + + // Past the first 60 s tick; the periodic check must flag the running launch. + tokio::time::sleep(Duration::from_secs(70)).await; + let mut flagged = false; + for _ in 0..1000 { + let listed = h.listed().await; + if listed[0].integrity_drift { + flagged = true; + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(flagged, "the periodic check flags a running launch"); + assert_eq!(h.listed().await[0].status, LaunchStatus::Running); + + h.executor.exit_with(0); + let _ = launched; +} + +#[tokio::test(start_paused = true)] +async fn the_periodic_check_stops_when_the_box_exits() { + let h = Harness::start(Setup::default()).await; + std::fs::write(h.cwd.join(".mcp.json"), b"{}").unwrap(); + let _launched = h.launch_ok().await; + h.executor.exit_with(0); + + for _ in 0..1000 { + tokio::time::sleep(Duration::from_millis(10)).await; + if h.listed().await[0].status == LaunchStatus::Exited { + break; + } + } + assert_eq!(h.listed().await[0].status, LaunchStatus::Exited); + assert!(!h.listed().await[0].integrity_drift, "clean exit, no drift"); + + // A change after the exit must not be flagged: the periodic task is gone. + std::fs::write(h.cwd.join(".mcp.json"), b"{\"changed\":true}").unwrap(); + tokio::time::sleep(Duration::from_secs(130)).await; + for _ in 0..100 { + tokio::task::yield_now().await; + } + assert!( + !h.listed().await[0].integrity_drift, + "no drift after the launch exited" + ); +} + +#[tokio::test(start_paused = true)] +async fn integrity_drift_detected_when_file_modified_during_launch() { + let h = Harness::start(Setup::default()).await; + let launched = h.launch_ok().await; + + // Modify a protected file + let mcp_path = launched.cwd.join(".mcp.json"); + std::fs::write(&mcp_path, b"{\"modified\":true}").unwrap(); + + // Wait for periodic check (60s + margin) + tokio::time::sleep(Duration::from_secs(80)).await; + + // Exit the launch + h.executor.exit_with(0); + + // Wait for launch to be marked exited + for _ in 0..1000 { + tokio::time::sleep(Duration::from_millis(10)).await; + let listed = h.listed().await; + if listed[0].status == LaunchStatus::Exited { + break; + } + } + + let listed = h.listed().await; + assert_eq!(listed[0].status, LaunchStatus::Exited); + assert!( + listed[0].integrity_drift, + "integrity drift should be detected" + ); +} + +#[tokio::test(start_paused = true)] +async fn integrity_drift_detected_when_file_replaced_before_spawn() { + let h = Harness::start(Setup::default()).await; + std::fs::write(h.cwd.join(".mcp.json"), b"{}").unwrap(); + + // The snapshot is taken before `spawn`; replacing the file inside `spawn` + // must therefore show up as drift (this fails if the snapshot moved after). + let mcp = h.cwd.join(".mcp.json"); + *h.executor.on_spawn.lock().unwrap() = Some(Box::new(move || { + std::fs::write(&mcp, b"{\"replaced\":true}").unwrap(); + })); + + let _launched = h.launch_ok().await; + h.executor.exit_with(0); + + // Wait for launch to be marked exited + for _ in 0..1000 { + tokio::time::sleep(Duration::from_millis(10)).await; + let listed = h.listed().await; + if listed[0].status == LaunchStatus::Exited { + break; + } + } + + let listed = h.listed().await; + assert_eq!(listed[0].status, LaunchStatus::Exited); + assert!( + listed[0].integrity_drift, + "drift should be detected even if changed during spawn" + ); +} + +#[tokio::test(start_paused = true)] +async fn integrity_drift_on_early_exit_verify() { + let h = Harness::start(Setup::default()).await; + std::fs::write(h.cwd.join(".mcp.json"), b"{}").unwrap(); + + let mcp = h.cwd.join(".mcp.json"); + *h.executor.on_spawn.lock().unwrap() = Some(Box::new(move || { + std::fs::write(&mcp, b"{\"replaced\":true}").unwrap(); + })); + h.executor.exit_with(1); + + let (status, _) = h.launch(&h.body()).await; + assert_eq!(status, 500, "early exit returns 500"); + + // An early exit leaves no launch record, so the verify pass is observed + // through the recording integrity filesystem: the pre-spawn snapshot read + // the original bytes, and the early-exit verify re-read the replaced ones. + let reads = h.integrity_reads.lock().unwrap(); + let mcp_reads: Vec<&[u8]> = reads + .iter() + .filter(|(path, _)| path.ends_with(".mcp.json")) + .map(|(_, bytes)| bytes.as_slice()) + .collect(); + assert!( + mcp_reads.contains(&b"{}".as_slice()), + "the snapshot before spawn read the original file: {mcp_reads:?}" + ); + assert!( + mcp_reads.contains(&b"{\"replaced\":true}".as_slice()), + "the early-exit verify re-read the replaced file: {mcp_reads:?}" + ); +} + // ----------------------------------------------------------------- Windows only #[cfg(windows)] diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 3994dbf1..d4267f1b 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -194,6 +194,10 @@ pub struct Launch { pub started_at_unix_seconds: u64, pub ended_at_unix_seconds: Option, pub credential_expires_at_unix_seconds: Option, + /// True once the executable configuration changed while this launch ran + /// (set by the periodic check or the exit check; never cleared). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub integrity_drift: bool, } /// State of the daemon's receiver for gateway usage. @@ -590,6 +594,7 @@ mod tests { started_at_unix_seconds: 1_700_000_000, ended_at_unix_seconds: None, credential_expires_at_unix_seconds: Some(1_700_003_600), + integrity_drift: false, }; let json = serde_json::to_value(&running).unwrap(); assert_eq!(json["launchId"], "0123456789abcdef"); @@ -597,6 +602,7 @@ mod tests { assert_eq!(json["status"], "running"); assert_eq!(json["startedAtUnixSeconds"], 1_700_000_000u64); assert_eq!(json["credentialExpiresAtUnixSeconds"], 1_700_003_600u64); + assert!(json.get("integrityDrift").is_none()); let again: Launch = serde_json::from_value(json).unwrap(); assert_eq!(again, running); @@ -722,4 +728,35 @@ mod tests { assert_eq!(serde_json::from_value::(json).unwrap(), info); assert_eq!(info.clone(), info); } + + #[test] + fn launch_integrity_drift_round_trip() { + let without_drift = 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, + credential_expires_at_unix_seconds: None, + integrity_drift: false, + }; + let json = serde_json::to_value(&without_drift).unwrap(); + assert!(json.get("integrityDrift").is_none()); + let parsed: Launch = serde_json::from_value(json).unwrap(); + assert_eq!(parsed, without_drift); + assert!(!parsed.integrity_drift); + + let with_drift = Launch { + integrity_drift: true, + ..without_drift + }; + let json = serde_json::to_value(&with_drift).unwrap(); + assert_eq!(json["integrityDrift"], true); + let parsed: Launch = serde_json::from_value(json).unwrap(); + assert_eq!(parsed, with_drift); + assert!(parsed.integrity_drift); + } } From ed920bc963fd42eaafd226d2b3873dd7fc9d0e38 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Tue, 20 Oct 2026 18:41:20 +0200 Subject: [PATCH 05/19] claude-code: managed-settings containment locks and posture Claude Code honours `allowManagedHooksOnly` and `allowManagedMcpServersOnly` only from managed (admin-writable) sources, which the user-mode daemon cannot deliver. Deliver the locks through the system-mode daemon's existing managed-settings drop-in and make the managed state visible: daemon-info reports which managed source Claude Code uses and whether the locks are on, read fresh on every request; each sandboxed launch records the snapshot it started under. - `programs.claudeCode` gains three typed keys (`allowManagedHooksOnly`, `allowManagedMcpServersOnly`, `allowedMcpServers` with typed entries), written by the system daemon into its drop-in exactly as Claude Code reads them and validated at parse time: the controller refuses an invalid configuration before devices see it. A device whose cached configuration fails the new validation does not start (the README has the recovery step). - Posture in daemon-info (Windows only, read per request, off the runtime): the selected source follows Claude Code's documented precedence; lock flags are counted from any admin source. A posture read error is logged as a warning at launch, and the launch records a null source with the lock flags false. Co-Authored-By: Claude Fable 5 --- README.md | 91 +- crates/agent/src/api.rs | 22 +- crates/agent/src/launch/host.rs | 4 + crates/agent/src/launch/mod.rs | 35 + crates/agent/src/launch/tests.rs | 162 +++ crates/agent/src/launch/writer_tests.rs | 7 + .../src/provider/claude_code/discovery.rs | 29 +- .../agent/src/provider/claude_code/managed.rs | 1053 +++++++++++++++++ crates/agent/src/provider/claude_code/mod.rs | 1 + .../src/provider/claude_code/reconcile.rs | 120 +- crates/core/src/config.rs | 280 +++++ crates/core/src/model.rs | 144 ++- frontend/desktop/src/stories/fixtures.ts | 3 + frontend/desktop/src/types.ts | 4 + schema/daemon-config.json | 71 ++ schema/daemon-config.md | 6 + 16 files changed, 2026 insertions(+), 6 deletions(-) create mode 100644 crates/agent/src/provider/claude_code/managed.rs diff --git a/README.md b/README.md index 7e147738..706e08be 100644 --- a/README.md +++ b/README.md @@ -825,7 +825,96 @@ repositories under the workspace, a renamed or recreated `.git` or `.claude` dir (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; managed `allowManaged*Only` settings arrive with the policy profile. +context; see "Managed settings for containment" below for delivery of managed `allowManaged*Only` locks. + +### 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. diff --git a/crates/agent/src/api.rs b/crates/agent/src/api.rs index 7ba7d940..1e939ed7 100644 --- a/crates/agent/src/api.rs +++ b/crates/agent/src/api.rs @@ -336,7 +336,27 @@ 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:#}"); + } + } + } + Json(info) } async fn config(State(state): State) -> Json { diff --git a/crates/agent/src/launch/host.rs b/crates/agent/src/launch/host.rs index 35df2de3..5e36f4a3 100644 --- a/crates/agent/src/launch/host.rs +++ b/crates/agent/src/launch/host.rs @@ -190,6 +190,10 @@ impl LaunchHost for HostEnvironment { .map(|elapsed| elapsed.as_secs()) .unwrap_or_default() } + + fn managed_sources(&self) -> crate::provider::claude_code::managed::ManagedSources { + crate::provider::claude_code::managed::managed_sources_now() + } } /// Runs `wxc-exec.exe ` with its output appended to the launch's diff --git a/crates/agent/src/launch/mod.rs b/crates/agent/src/launch/mod.rs index 94fa7b19..57436b07 100644 --- a/crates/agent/src/launch/mod.rs +++ b/crates/agent/src/launch/mod.rs @@ -58,6 +58,7 @@ const LOG_TAIL: usize = 4096; /// Startup snapshot for `daemon-info`. `None` when `daemon.containment` is /// unconfigured; otherwise `available` only with /// `user && windows && exists(executor) && !claude_settings_overridden`. +/// The managed-settings posture fields are filled per request by `api::daemon_info`. pub fn containment_info( cfg: Option<&ContainmentStartupConfig>, user: bool, @@ -85,6 +86,10 @@ pub fn containment_info( executor, available: error.is_none(), error, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + managed_settings_error: None, }) } @@ -786,6 +791,7 @@ pub trait LaunchHost: Send + Sync { fn integrity_fs(&self) -> Arc { Arc::new(integrity::REAL_FS) } + fn managed_sources(&self) -> crate::provider::claude_code::managed::ManagedSources; } /// Running launches plus the last 32 exited ones. Clones share state. @@ -1104,6 +1110,32 @@ async fn start_launch( fs: integrity_fs, }; + // Snapshot the managed-settings posture before spawning the executor + let managed_posture = { + let host_clone = host.clone(); + match tokio::task::spawn_blocking(move || { + crate::provider::claude_code::managed::managed_settings_state( + &host_clone.managed_sources(), + ) + }) + .await + { + Ok(posture) => posture, + Err(error) => { + tracing::warn!(launch_id = %launch_id, "failed to join managed-settings posture task: {error:#}"); + crate::provider::claude_code::managed::ManagedSettingsPosture::default() + } + } + }; + + if let Some(error) = &managed_posture.error { + tracing::warn!( + launch_id = %launch_id, + error = %error, + "managed-settings posture could not be determined at launch" + ); + } + let mut child = match state .executor @@ -1164,6 +1196,9 @@ async fn start_launch( ended_at_unix_seconds: None, credential_expires_at_unix_seconds: Some(input.credential.expires_at_unix_seconds), integrity_drift: false, + managed_settings_source: managed_posture.source, + managed_hooks_locked: managed_posture.hooks_locked, + managed_mcp_locked: managed_posture.mcp_locked, }; state.registry.insert(launch.clone()); tracing::info!(launch_id, cwd = %launch.cwd.display(), "sandboxed launch started"); diff --git a/crates/agent/src/launch/tests.rs b/crates/agent/src/launch/tests.rs index 5d0b804f..8e537f1d 100644 --- a/crates/agent/src/launch/tests.rs +++ b/crates/agent/src/launch/tests.rs @@ -68,6 +68,10 @@ fn containment_info_is_available_for_a_user_mode_windows_daemon_with_the_executo executor: r"C:\mxc\x64\wxc-exec.exe".into(), available: true, error: None, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + managed_settings_error: None, } ); } @@ -1399,6 +1403,9 @@ fn launch(id: &str, started_at: u64) -> Launch { ended_at_unix_seconds: None, credential_expires_at_unix_seconds: None, integrity_drift: false, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, } } @@ -1709,6 +1716,8 @@ struct FakeHost { client_exe_dir: PathBuf, clock: Clock, integrity_reads: IntegrityReads, + managed_file: Option<&'static str>, + managed_file_error: Option<&'static str>, } impl LaunchHost for FakeHost { @@ -1771,6 +1780,18 @@ impl LaunchHost for FakeHost { reads: self.integrity_reads.clone(), }) } + + fn managed_sources(&self) -> crate::provider::claude_code::managed::ManagedSources { + use crate::provider::claude_code::managed::ManagedSources; + ManagedSources { + hklm: None, + file: self + .managed_file_error + .map(|msg| Err(io::Error::other(msg))) + .or_else(|| self.managed_file.map(|s| Ok(s.to_owned()))), + drop_ins: Vec::new(), + } + } } struct StubCredentials { @@ -1957,6 +1978,8 @@ struct Setup { peer: Option, claude_installed: bool, credentials_must_not_be_reached: bool, + managed_file: Option<&'static str>, + managed_file_error: Option<&'static str>, } impl Default for Setup { @@ -1970,6 +1993,8 @@ impl Default for Setup { peer: None, claude_installed: true, credentials_must_not_be_reached: false, + managed_file: None, + managed_file_error: None, } } } @@ -2039,11 +2064,19 @@ impl Harness { executor: executor_path.display().to_string(), available: true, error: None, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + managed_settings_error: None, }), Availability::Unavailable(error) => Some(ContainmentInfo { executor: executor_path.display().to_string(), available: false, error: Some(error.to_owned()), + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + managed_settings_error: None, }), Availability::Missing => None, }; @@ -2089,6 +2122,8 @@ impl Harness { client_exe_dir, clock: clock.clone(), integrity_reads: integrity_reads.clone(), + managed_file: setup.managed_file, + managed_file_error: setup.managed_file_error, }), registry: LaunchRegistry::new(), availability, @@ -3133,6 +3168,133 @@ async fn integrity_drift_on_early_exit_verify() { ); } +// Per-launch managed-settings snapshot tests + +#[tokio::test(start_paused = true)] +async fn launch_records_managed_settings_source_and_locks_when_present() { + let h = Harness::start(Setup { + managed_file: Some(r#"{"allowManagedHooksOnly":true}"#), + ..Setup::default() + }) + .await; + + let launched = h.launch_ok().await; + assert_eq!( + launched.managed_settings_source, + Some(agentdesktop_core::model::ManagedSettingsSource::File) + ); + assert!(launched.managed_hooks_locked); + assert!(!launched.managed_mcp_locked); + + let listed = h.listed().await; + assert_eq!( + listed[0].managed_settings_source, + Some(agentdesktop_core::model::ManagedSettingsSource::File) + ); + assert!(listed[0].managed_hooks_locked); + assert!(!listed[0].managed_mcp_locked); +} + +#[tokio::test(start_paused = true)] +async fn launch_records_null_source_and_false_locks_when_managed_file_absent() { + let h = Harness::start(Setup { + managed_file: None, + ..Setup::default() + }) + .await; + + let launched = h.launch_ok().await; + assert_eq!(launched.managed_settings_source, None); + assert!(!launched.managed_hooks_locked); + assert!(!launched.managed_mcp_locked); + + let listed = h.listed().await; + assert_eq!(listed[0].managed_settings_source, None); + assert!(!listed[0].managed_hooks_locked); + assert!(!listed[0].managed_mcp_locked); +} + +#[tokio::test(start_paused = true)] +async fn launch_records_null_source_and_false_locks_when_posture_has_error() { + let h = Harness::start(Setup { + managed_file_error: Some("access denied"), + ..Setup::default() + }) + .await; + + let launched = h.launch_ok().await; + assert_eq!(launched.managed_settings_source, None); + assert!(!launched.managed_hooks_locked); + assert!(!launched.managed_mcp_locked); + + let listed = h.listed().await; + assert_eq!(listed[0].managed_settings_source, None); + assert!(!listed[0].managed_hooks_locked); + assert!(!listed[0].managed_mcp_locked); +} + +#[tokio::test(start_paused = true)] +async fn launch_logs_posture_error_as_warning() { + use std::sync::Arc; + use std::sync::Mutex; + use tracing_subscriber::fmt; + use tracing_subscriber::layer::SubscriberExt; + + // Capture logs in a shared buffer + let logs = Arc::new(Mutex::new(Vec::new())); + let logs_writer = logs.clone(); + + let subscriber = tracing_subscriber::registry().with( + fmt::layer() + .with_writer(move || LogWriter(logs_writer.clone())) + .with_ansi(false), + ); + + // Set as global default for this test's duration + let _guard = tracing::subscriber::set_default(subscriber); + + let h = Harness::start(Setup { + managed_file_error: Some("access denied"), + ..Setup::default() + }) + .await; + + let _launched = h.launch_ok().await; + + // Verify the posture-error warn was emitted + let log_bytes = logs.lock().unwrap(); + let log_output = String::from_utf8_lossy(&log_bytes); + assert!( + log_output.contains("WARN"), + "Expected WARN level in logs, got:\n{}", + log_output + ); + assert!( + log_output.contains("managed-settings posture could not be determined at launch"), + "Expected posture error message in logs" + ); + assert!( + log_output.contains("error=file:"), + "Expected error field to name the failing source" + ); + // The error message itself (like "cannot read: access denied") is fine; + // we're protecting against document-content leakage (e.g., raw JSON). + // This test uses a read-error scenario, so no document exists to leak. +} + +/// Writer adapter for capturing tracing output +struct LogWriter(Arc>>); + +impl std::io::Write for LogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.0.lock().unwrap().flush() + } +} + // ----------------------------------------------------------------- Windows only #[cfg(windows)] diff --git a/crates/agent/src/launch/writer_tests.rs b/crates/agent/src/launch/writer_tests.rs index 43ec8a4a..b13199e6 100644 --- a/crates/agent/src/launch/writer_tests.rs +++ b/crates/agent/src/launch/writer_tests.rs @@ -226,6 +226,9 @@ mod handler { fn now(&self) -> u64 { 1_700_000_000 } + fn managed_sources(&self) -> crate::provider::claude_code::managed::ManagedSources { + crate::provider::claude_code::managed::ManagedSources::default() + } } struct Credentials; @@ -322,6 +325,10 @@ mod handler { executor: "wxc-exec.exe".into(), available: true, error: None, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + managed_settings_error: None, }), daemon_sid: None, } diff --git a/crates/agent/src/provider/claude_code/discovery.rs b/crates/agent/src/provider/claude_code/discovery.rs index 25f4f0ae..3644c933 100644 --- a/crates/agent/src/provider/claude_code/discovery.rs +++ b/crates/agent/src/provider/claude_code/discovery.rs @@ -76,7 +76,7 @@ fn skill_roots() -> Vec { roots.into_iter().collect() } -fn managed_root() -> Option { +pub(super) fn managed_root() -> Option { #[cfg(target_os = "linux")] return Some(PathBuf::from("/etc/claude-code")); #[cfg(target_os = "macos")] @@ -87,7 +87,7 @@ fn managed_root() -> Option { /// `managed-settings.json` followed by the `managed-settings.d/*.json` drop-ins in the /// lexical order Claude Code merges them. -fn managed_settings_files(root: &Path) -> Vec { +pub(super) fn managed_settings_files(root: &Path) -> Vec { let mut files = vec![root.join("managed-settings.json")]; if let Ok(entries) = fs::read_dir(root.join("managed-settings.d")) { let mut drop_ins: Vec = entries @@ -96,6 +96,10 @@ fn managed_settings_files(root: &Path) -> Vec { .filter(|path| { path.extension() .is_some_and(|extension| extension == "json") + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| !name.starts_with('.')) }) .collect(); drop_ins.sort(); @@ -419,4 +423,25 @@ mod tests { assert!(managed_mcp_servers_from_value(&json!("\"nested\""), source).is_empty()); assert!(managed_mcp_servers_from_value(&json!(42), source).is_empty()); } + + #[test] + fn hidden_dropin_managed_mcp_servers_are_not_inventoried() { + let root = tempfile::tempdir().unwrap(); + let drop_in = root.path().join("managed-settings.d/50-agentdesktop.json"); + let hidden_drop_in = root.path().join("managed-settings.d/.50-agentdesktop.json"); + + write_managed_servers(&drop_in, json!({ "visible": { "command": "npx" } })); + write_managed_servers( + &hidden_drop_in, + json!({ "hidden": { "command": "secret" } }), + ); + + let servers = + merged_managed_mcp_servers(&managed_settings_sources(Some(root.path()), &drop_in)); + + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "visible"); + // Hidden drop-in servers should not be in the inventory + assert!(!servers.iter().any(|s| s.name == "hidden")); + } } diff --git a/crates/agent/src/provider/claude_code/managed.rs b/crates/agent/src/provider/claude_code/managed.rs new file mode 100644 index 00000000..22a61e43 --- /dev/null +++ b/crates/agent/src/provider/claude_code/managed.rs @@ -0,0 +1,1053 @@ +//! Managed-settings posture for Claude Code containment. + +use agentdesktop_core::model::ManagedSettingsSource; +use serde_json::Value; +use std::io; + +/// Sources for managed settings (file tier and HKLM). +#[derive(Debug, Default)] +pub struct ManagedSources { + pub hklm: Option>, + pub file: Option>, + pub drop_ins: Vec<(String, io::Result)>, +} + +/// Managed-settings posture snapshot. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ManagedSettingsPosture { + pub source: Option, + pub hooks_locked: bool, + pub mcp_locked: bool, + pub error: Option, +} + +/// Models Windows: HKLM and the managed-settings file tier, Claude Code 2.1.273+. +/// Never reports a lock Claude Code does not apply; may report a lock as off where Claude Code fails closed. +pub fn managed_settings_state(sources: &ManagedSources) -> ManagedSettingsPosture { + // Check HKLM + let hklm_doc = match &sources.hklm { + Some(Ok(content)) => { + if content.trim().is_empty() { + return ManagedSettingsPosture { + source: None, + hooks_locked: false, + mcp_locked: false, + error: Some("HKLM: broken (empty)".to_owned()), + }; + } + match serde_json::from_str::(content) { + Ok(value) if value.is_object() => Some(value), + _ => { + return ManagedSettingsPosture { + source: None, + hooks_locked: false, + mcp_locked: false, + error: Some("HKLM: broken (invalid JSON)".to_owned()), + }; + } + } + } + Some(Err(err)) => { + return ManagedSettingsPosture { + source: None, + hooks_locked: false, + mcp_locked: false, + error: Some(format!("HKLM: cannot read: {}", err)), + }; + } + None => None, + }; + + // Check file tier + let file_doc = match &sources.file { + Some(Ok(content)) => { + let trimmed = content.trim(); + if trimmed.is_empty() { + Some(serde_json::json!({})) + } else { + match serde_json::from_str::(trimmed) { + Ok(value) if value.is_object() => Some(value), + _ => { + return ManagedSettingsPosture { + source: None, + hooks_locked: false, + mcp_locked: false, + error: Some("file: broken (invalid JSON)".to_owned()), + }; + } + } + } + } + Some(Err(err)) => { + return ManagedSettingsPosture { + source: None, + hooks_locked: false, + mcp_locked: false, + error: Some(format!("file: cannot read: {}", err)), + }; + } + None => None, + }; + + // Merge drop-ins into file tier + let mut file_merged = file_doc.unwrap_or_else(|| serde_json::json!({})); + for (name, result) in &sources.drop_ins { + match result { + Ok(content) => { + let trimmed = content.trim(); + let drop_in_value = if trimmed.is_empty() { + serde_json::json!({}) + } else { + match serde_json::from_str::(trimmed) { + Ok(value) if value.is_object() => value, + _ => { + return ManagedSettingsPosture { + source: None, + hooks_locked: false, + mcp_locked: false, + error: Some(format!("{name}: broken (invalid JSON)")), + }; + } + } + }; + if let (Some(base), Some(drop_in)) = + (file_merged.as_object_mut(), drop_in_value.as_object()) + { + for (key, value) in drop_in { + base.insert(key.clone(), value.clone()); + } + } + } + Err(err) => { + return ManagedSettingsPosture { + source: None, + hooks_locked: false, + mcp_locked: false, + error: Some(format!("{name}: cannot read: {}", err)), + }; + } + } + } + + // Determine if we're in merge mode + let merge_mode = hklm_doc + .as_ref() + .and_then(|doc| { + doc.get("managedSourcesBehavior") + .and_then(Value::as_str) + .map(|s| s == "merge") + }) + .unwrap_or(false); + + // Helper to check if a document has policy keys + let has_policy_key = |doc: &Value| -> bool { + doc.as_object().is_some_and(|obj| { + obj.iter().any(|(key, value)| { + key != "managedSourcesBehavior" + && key != "wslInheritsWindowsSettings" + && !value.is_null() + }) + }) + }; + + // Determine the selected source + let (selected_source, selected_doc) = if let Some(ref hklm) = hklm_doc { + if has_policy_key(hklm) { + (Some(ManagedSettingsSource::Hklm), hklm) + } else if has_policy_key(&file_merged) { + (Some(ManagedSettingsSource::File), &file_merged) + } else { + (None, &file_merged) + } + } else if has_policy_key(&file_merged) { + (Some(ManagedSettingsSource::File), &file_merged) + } else { + (None, &file_merged) + }; + + // Check if policyHelper is present and not null + if selected_doc + .get("policyHelper") + .is_some_and(|v| !v.is_null()) + { + return ManagedSettingsPosture { + source: selected_source, + hooks_locked: false, + mcp_locked: false, + error: None, + }; + } + + // Determine hooks_locked + let hooks_locked = if merge_mode { + // In merge mode, check both sources + let hklm_hooks = hklm_doc + .as_ref() + .and_then(|doc| doc.get("allowManagedHooksOnly")) + .and_then(Value::as_bool) + .unwrap_or(false); + let file_hooks = file_merged + .get("allowManagedHooksOnly") + .and_then(Value::as_bool) + .unwrap_or(false); + hklm_hooks || file_hooks + } else { + selected_doc + .get("allowManagedHooksOnly") + .and_then(Value::as_bool) + .unwrap_or(false) + }; + + // Determine mcp_locked: flag must be true AND allowedMcpServers must be present (array, including empty) + // Cross-source: check both sources for flag and list (merge mode or first-wins) + let mcp_locked = { + let hklm_flag = hklm_doc + .as_ref() + .and_then(|doc| doc.get("allowManagedMcpServersOnly")) + .and_then(Value::as_bool) + .unwrap_or(false); + let file_flag = file_merged + .get("allowManagedMcpServersOnly") + .and_then(Value::as_bool) + .unwrap_or(false); + let has_flag = hklm_flag || file_flag; + + let hklm_list = hklm_doc + .as_ref() + .and_then(|doc| doc.get("allowedMcpServers")) + .is_some_and(|v| v.is_array()); + let file_list = file_merged + .get("allowedMcpServers") + .is_some_and(|v| v.is_array()); + let has_list = hklm_list || file_list; + + has_flag && has_list + }; + + ManagedSettingsPosture { + source: selected_source, + hooks_locked, + mcp_locked, + error: None, + } +} + +fn read_managed_sources( + root: Option<&std::path::Path>, + hklm: impl FnOnce() -> Option>, +) -> ManagedSources { + use std::fs; + + let hklm_value = hklm(); + + let (file, drop_ins) = if let Some(root) = root { + // Check if managed-settings.d directory is readable + let drop_in_dir = root.join("managed-settings.d"); + if let Err(err) = fs::read_dir(&drop_in_dir) + && err.kind() != io::ErrorKind::NotFound + { + // Directory exists but is unreadable - report as a drop-in error + return ManagedSources { + hklm: hklm_value, + file: None, + drop_ins: vec![("managed-settings.d".to_owned(), Err(err))], + }; + } + + // Use discovery function to get file paths + let paths = super::discovery::managed_settings_files(root); + + let mut base_file = None; + let mut drop_ins = Vec::new(); + + for (index, path) in paths.iter().enumerate() { + if index == 0 { + // First entry is always the base file + base_file = match fs::read_to_string(path) { + Ok(content) => Some(Ok(content)), + Err(err) if err.kind() == io::ErrorKind::NotFound => None, + Err(err) => Some(Err(err)), + }; + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + let result = fs::read_to_string(path); + drop_ins.push((name.to_owned(), result)); + } + } + + (base_file, drop_ins) + } else { + (None, Vec::new()) + }; + + ManagedSources { + hklm: hklm_value, + file, + drop_ins, + } +} + +#[cfg(windows)] +fn read_hklm_settings( + hive: windows_sys::Win32::System::Registry::HKEY, + subkey: &str, +) -> Option> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_MORE_DATA}; + use windows_sys::Win32::System::Registry::{ + HKEY, KEY_READ, REG_EXPAND_SZ, REG_SZ, RRF_NOEXPAND, RRF_RT_ANY, RegCloseKey, RegGetValueW, + RegOpenKeyExW, + }; + + let subkey_wide: Vec = OsStr::new(subkey) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let value_name: Vec = OsStr::new("Settings") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut key: HKEY = std::ptr::null_mut(); + let open_result = unsafe { RegOpenKeyExW(hive, subkey_wide.as_ptr(), 0, KEY_READ, &mut key) }; + + if open_result != 0 { + return if open_result == ERROR_FILE_NOT_FOUND { + None + } else { + Some(Err(io::Error::from_raw_os_error(open_result as i32))) + }; + } + + // Query the size and type first + let mut value_type: u32 = 0; + let mut required_size: u32 = 0; + + let query_result = unsafe { + RegGetValueW( + key, + std::ptr::null(), + value_name.as_ptr(), + RRF_RT_ANY | RRF_NOEXPAND, + &mut value_type, + std::ptr::null_mut(), + &mut required_size, + ) + }; + + if query_result != 0 { + unsafe { RegCloseKey(key) }; + return if query_result == ERROR_FILE_NOT_FOUND { + None + } else { + Some(Err(io::Error::from_raw_os_error(query_result as i32))) + }; + } + + // Check if the type is REG_SZ or REG_EXPAND_SZ + if value_type != REG_SZ && value_type != REG_EXPAND_SZ { + unsafe { RegCloseKey(key) }; + return Some(Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("HKLM value has wrong type: {}", value_type), + ))); + } + + // Allocate buffer based on reported size, with room for possible growth + let buffer_len = (required_size as usize / 2) + 1; + let mut buffer = vec![0u16; buffer_len]; + let mut buffer_size = (buffer.len() * 2) as u32; + + let read_result = unsafe { + RegGetValueW( + key, + std::ptr::null(), + value_name.as_ptr(), + RRF_RT_ANY | RRF_NOEXPAND, + &mut value_type, + buffer.as_mut_ptr() as *mut _, + &mut buffer_size, + ) + }; + + unsafe { RegCloseKey(key) }; + + if read_result != 0 { + return if read_result == ERROR_MORE_DATA { + // Retry with a larger buffer + let mut large_buffer = vec![0u16; buffer_len * 2]; + let mut large_size = (large_buffer.len() * 2) as u32; + + let mut retry_key: HKEY = std::ptr::null_mut(); + let retry_open = + unsafe { RegOpenKeyExW(hive, subkey_wide.as_ptr(), 0, KEY_READ, &mut retry_key) }; + + if retry_open != 0 { + return Some(Err(io::Error::from_raw_os_error(retry_open as i32))); + } + + let retry_result = unsafe { + RegGetValueW( + retry_key, + std::ptr::null(), + value_name.as_ptr(), + RRF_RT_ANY | RRF_NOEXPAND, + &mut value_type, + large_buffer.as_mut_ptr() as *mut _, + &mut large_size, + ) + }; + + unsafe { RegCloseKey(retry_key) }; + + if retry_result != 0 { + Some(Err(io::Error::from_raw_os_error(retry_result as i32))) + } else { + let len = (large_size / 2) as usize; + if len > 0 && large_buffer[len - 1] == 0 { + large_buffer.truncate(len - 1); + } else { + large_buffer.truncate(len); + } + Some(Ok(String::from_utf16_lossy(&large_buffer))) + } + } else { + Some(Err(io::Error::from_raw_os_error(read_result as i32))) + }; + } + + // Trim null terminator + let len = (buffer_size / 2) as usize; + if len > 0 && buffer[len - 1] == 0 { + buffer.truncate(len - 1); + } else { + buffer.truncate(len); + } + + Some(Ok(String::from_utf16_lossy(&buffer))) +} + +/// Read managed sources from the real file system and registry. +pub fn managed_sources_now() -> ManagedSources { + read_managed_sources(super::discovery::managed_root().as_deref(), || { + #[cfg(windows)] + { + read_hklm_settings( + windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE, + r"SOFTWARE\Policies\ClaudeCode", + ) + } + #[cfg(not(windows))] + { + None + } + }) +} + +/// Read and compute the current managed-settings posture. +pub fn managed_posture_now() -> ManagedSettingsPosture { + managed_settings_state(&managed_sources_now()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Pure function tests for managed_settings_state (cases 1-15) + + #[test] + fn test_managed_settings_state_none() { + let sources = ManagedSources::default(); + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, None); + assert!(!posture.hooks_locked); + assert!(!posture.mcp_locked); + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_file_only_with_both_locks() { + let sources = ManagedSources { + hklm: None, + file: Some(Ok(r#"{"allowManagedHooksOnly":true,"allowManagedMcpServersOnly":true,"allowedMcpServers":[]}"#.to_owned())), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::File)); + assert!(posture.hooks_locked); + assert!(posture.mcp_locked); + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_hklm_only_with_both_locks() { + let sources = ManagedSources { + hklm: Some(Ok(r#"{"allowManagedHooksOnly":true,"allowManagedMcpServersOnly":true,"allowedMcpServers":[]}"#.to_owned())), + file: None, + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::Hklm)); + assert!(posture.hooks_locked); + assert!(posture.mcp_locked); + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_both_present_first_wins_selects_hklm() { + let sources = ManagedSources { + hklm: Some(Ok(r#"{"model":"claude-3-5-sonnet"}"#.to_owned())), + file: Some(Ok(r#"{"allowManagedHooksOnly":true}"#.to_owned())), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::Hklm)); + assert!(!posture.hooks_locked); // HKLM selected, doesn't have hooks lock + assert!(!posture.mcp_locked); + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_file_with_locks_hklm_without_locks() { + let sources = ManagedSources { + hklm: Some(Ok(r#"{"model":"claude-3-5-sonnet"}"#.to_owned())), + file: Some(Ok(r#"{"allowManagedHooksOnly":true,"allowManagedMcpServersOnly":true,"allowedMcpServers":[]}"#.to_owned())), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::Hklm)); + assert!(!posture.hooks_locked); // from selected source (HKLM), which doesn't have the lock + assert!(posture.mcp_locked); // cross-source: flag and list from file + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_merge_mode_with_locks_in_both() { + let sources = ManagedSources { + hklm: Some(Ok( + r#"{"managedSourcesBehavior":"merge","allowManagedHooksOnly":true}"#.to_owned(), + )), + file: Some(Ok( + r#"{"allowManagedMcpServersOnly":true,"allowedMcpServers":[]}"#.to_owned(), + )), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::Hklm)); + assert!(posture.hooks_locked); // strictest under merge + assert!(posture.mcp_locked); // cross-source + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_one_lock_only() { + let sources = ManagedSources { + hklm: None, + file: Some(Ok(r#"{"allowManagedMcpServersOnly":true}"#.to_owned())), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::File)); + assert!(!posture.hooks_locked); + assert!(!posture.mcp_locked); // flag present but no allowedMcpServers list + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_policy_helper_present() { + let sources = ManagedSources { + hklm: None, + file: Some(Ok(r#"{"policyHelper":"/usr/bin/helper","allowManagedHooksOnly":true,"allowManagedMcpServersOnly":true,"allowedMcpServers":[]}"#.to_owned())), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::File)); + assert!(!posture.hooks_locked); // policyHelper present, so both locks false + assert!(!posture.mcp_locked); + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_allowed_mcp_servers_empty_array() { + let sources = ManagedSources { + hklm: None, + file: Some(Ok( + r#"{"allowManagedMcpServersOnly":true,"allowedMcpServers":[]}"#.to_owned(), + )), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::File)); + assert!(!posture.hooks_locked); + assert!(posture.mcp_locked); // empty array still counts as having the list + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_hklm_control_keys_only() { + let sources = ManagedSources { + hklm: Some(Ok(r#"{"managedSourcesBehavior":"merge"}"#.to_owned())), + file: Some(Ok(r#"{"allowManagedHooksOnly":true}"#.to_owned())), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::File)); // HKLM has no policy keys + assert!(posture.hooks_locked); + assert!(!posture.mcp_locked); + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_null_policy_key_ignored() { + let sources = ManagedSources { + hklm: Some(Ok(r#"{"model":null}"#.to_owned())), + file: Some(Ok(r#"{"allowManagedHooksOnly":true}"#.to_owned())), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::File)); // HKLM model:null is not a policy key + assert!(posture.hooks_locked); + assert!(!posture.mcp_locked); + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_broken_hklm() { + let sources = ManagedSources { + hklm: Some(Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "access denied", + ))), + file: Some(Ok(r#"{"allowManagedHooksOnly":true}"#.to_owned())), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, None); // broken admin document + assert!(!posture.hooks_locked); + assert!(!posture.mcp_locked); + assert!(posture.error.is_some()); // error names the source + assert!( + posture.error.as_ref().unwrap().contains("HKLM") + || posture.error.as_ref().unwrap().contains("hklm") + ); + } + + #[test] + fn test_managed_settings_state_empty_file() { + let sources = ManagedSources { + hklm: None, + file: Some(Ok(" \n ".to_owned())), // whitespace-only + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, None); // empty file = {}, no policy keys + assert!(!posture.hooks_locked); + assert!(!posture.mcp_locked); + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_first_wins_with_flag_in_hklm_and_list_in_file() { + let sources = ManagedSources { + hklm: Some(Ok(r#"{"allowManagedMcpServersOnly":true}"#.to_owned())), + file: Some(Ok( + r#"{"allowedMcpServers":[{"serverUrl":"https://mcp.example.com/*"}]}"#.to_owned(), + )), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::Hklm)); + assert!(!posture.hooks_locked); + assert!(posture.mcp_locked); // cross-source: flag from HKLM, list from file + assert_eq!(posture.error, None); + } + + #[test] + fn test_managed_settings_state_later_dropin_setting_overrides_earlier() { + let sources = ManagedSources { + hklm: None, + file: None, + drop_ins: vec![ + ( + "50-first.json".to_owned(), + Ok(r#"{"allowManagedHooksOnly":true}"#.to_owned()), + ), + ( + "60-second.json".to_owned(), + Ok(r#"{"allowManagedHooksOnly":false}"#.to_owned()), + ), + ], + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::File)); // drop-ins are part of file tier + assert!(!posture.hooks_locked); // later drop-in (60-second) overrides earlier (50-first) + assert!(!posture.mcp_locked); + assert_eq!(posture.error, None); + } + + // Reader tests (R1, R2) + + #[test] + fn test_read_managed_sources_with_dropins() { + use std::fs; + let temp_dir = tempfile::tempdir().unwrap(); + let root = temp_dir.path(); + + // Create base file + fs::write(root.join("managed-settings.json"), r#"{"model":"base"}"#).unwrap(); + + // Create drop-ins directory + let dropins_dir = root.join("managed-settings.d"); + fs::create_dir(&dropins_dir).unwrap(); + + // Create two .json drop-ins + fs::write(dropins_dir.join("10-first.json"), r#"{"key1":"val1"}"#).unwrap(); + fs::write(dropins_dir.join("20-second.json"), r#"{"key2":"val2"}"#).unwrap(); + + // Create a hidden .json file (should be skipped) + fs::write( + dropins_dir.join(".50-agentdesktop.json"), + r#"{"hidden":"yes"}"#, + ) + .unwrap(); + + // Create a .txt file (should be skipped) + fs::write(dropins_dir.join("30-text.txt"), "not json").unwrap(); + + let sources = read_managed_sources(Some(root), || None); + + assert!(sources.hklm.is_none()); + assert!(sources.file.is_some()); + assert_eq!( + sources.file.as_ref().unwrap().as_ref().unwrap(), + r#"{"model":"base"}"# + ); + assert_eq!(sources.drop_ins.len(), 2); + assert_eq!(sources.drop_ins[0].0, "10-first.json"); + assert_eq!( + sources.drop_ins[0].1.as_ref().unwrap(), + r#"{"key1":"val1"}"# + ); + assert_eq!(sources.drop_ins[1].0, "20-second.json"); + assert_eq!( + sources.drop_ins[1].1.as_ref().unwrap(), + r#"{"key2":"val2"}"# + ); + } + + #[test] + fn test_read_managed_sources_missing_root() { + use std::path::Path; + let nonexistent = Path::new("/nonexistent/path/to/managed/settings"); + + let sources = read_managed_sources(Some(nonexistent), || None); + + assert!(sources.hklm.is_none()); + assert!(sources.file.is_none()); + assert!(sources.drop_ins.is_empty()); + } + + // Windows-only tests (W1, W2) + + #[cfg(windows)] + #[test] + fn test_read_hklm_settings_reg_sz_and_reg_expand_sz() { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::System::Registry::*; + + // Generate random test key name + let random_id: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + let test_key_path = format!(r"Software\agentdesktop-test\{}", random_id); + + // Create temp test key + let key_path_wide: Vec = OsStr::new(&test_key_path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut hkey: HKEY = std::ptr::null_mut(); + let result = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + key_path_wide.as_ptr(), + 0, + std::ptr::null(), + 0, + KEY_ALL_ACCESS, + std::ptr::null(), + &mut hkey, + std::ptr::null_mut(), + ) + }; + assert_eq!(result, 0, "Failed to create test registry key"); + + // Cleanup guard + struct KeyGuard(HKEY, String); + impl Drop for KeyGuard { + fn drop(&mut self) { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::System::Registry::*; + unsafe { + RegCloseKey(self.0); + // Delete the test-specific key + let path_wide: Vec = OsStr::new(&self.1) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + RegDeleteKeyW(HKEY_CURRENT_USER, path_wide.as_ptr()); + + // Delete the parent agentdesktop-test key if it's empty + let parent_path = r"Software\agentdesktop-test"; + let parent_wide: Vec = OsStr::new(parent_path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + RegDeleteKeyW(HKEY_CURRENT_USER, parent_wide.as_ptr()); + } + } + } + let _guard = KeyGuard(hkey, test_key_path.clone()); + + // Test REG_SZ + let sz_value = r#"{"test":"value"}"#; + let sz_wide: Vec = OsStr::new(sz_value) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let settings_wide: Vec = OsStr::new("Settings") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + unsafe { + RegSetValueExW( + hkey, + settings_wide.as_ptr(), + 0, + REG_SZ, + sz_wide.as_ptr() as *const u8, + (sz_wide.len() * 2) as u32, + ) + }; + + let result_sz = read_hklm_settings(HKEY_CURRENT_USER, &test_key_path); + assert!(result_sz.is_some()); + assert_eq!(result_sz.unwrap().unwrap(), sz_value); + + // Test REG_EXPAND_SZ + let expand_value = r#"%TEMP%\test"#; + let expand_wide: Vec = OsStr::new(expand_value) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + unsafe { + RegSetValueExW( + hkey, + settings_wide.as_ptr(), + 0, + REG_EXPAND_SZ, + expand_wide.as_ptr() as *const u8, + (expand_wide.len() * 2) as u32, + ) + }; + + let result_expand = read_hklm_settings(HKEY_CURRENT_USER, &test_key_path); + assert!(result_expand.is_some()); + // Should be read back unexpanded + assert_eq!(result_expand.unwrap().unwrap(), expand_value); + } + + #[cfg(windows)] + #[test] + fn test_read_hklm_settings_reg_dword_and_absent() { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::System::Registry::*; + + // Generate random test key name + let random_id: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + let test_key_path = format!(r"Software\agentdesktop-test\{}", random_id); + + // Create temp test key + let key_path_wide: Vec = OsStr::new(&test_key_path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut hkey: HKEY = std::ptr::null_mut(); + let result = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + key_path_wide.as_ptr(), + 0, + std::ptr::null(), + 0, + KEY_ALL_ACCESS, + std::ptr::null(), + &mut hkey, + std::ptr::null_mut(), + ) + }; + assert_eq!(result, 0, "Failed to create test registry key"); + + // Cleanup guard + struct KeyGuard(HKEY, String); + impl Drop for KeyGuard { + fn drop(&mut self) { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::System::Registry::*; + unsafe { + RegCloseKey(self.0); + // Delete the test-specific key + let path_wide: Vec = OsStr::new(&self.1) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + RegDeleteKeyW(HKEY_CURRENT_USER, path_wide.as_ptr()); + + // Delete the parent agentdesktop-test key if it's empty + let parent_path = r"Software\agentdesktop-test"; + let parent_wide: Vec = OsStr::new(parent_path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + RegDeleteKeyW(HKEY_CURRENT_USER, parent_wide.as_ptr()); + } + } + } + let _guard = KeyGuard(hkey, test_key_path.clone()); + + // Test REG_DWORD (should return Err(InvalidData)) + let dword_value: u32 = 1; + let settings_wide: Vec = OsStr::new("Settings") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + unsafe { + RegSetValueExW( + hkey, + settings_wide.as_ptr(), + 0, + REG_DWORD, + &dword_value as *const u32 as *const u8, + 4, + ) + }; + + let result_dword = read_hklm_settings(HKEY_CURRENT_USER, &test_key_path); + assert!(result_dword.is_some()); + let err = result_dword.unwrap().unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + + // Test absent value (should return None) + unsafe { + RegDeleteValueW(hkey, settings_wide.as_ptr()); + }; + + let result_absent = read_hklm_settings(HKEY_CURRENT_USER, &test_key_path); + assert!(result_absent.is_none()); + } + + // Writer tests for error handling via managed_settings_state (run on all platforms) + + #[test] + fn hklm_permission_denied_gives_error_with_cause() { + let sources = ManagedSources { + hklm: Some(Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "access denied", + ))), + file: None, + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, None); + assert!(!posture.hooks_locked); + assert!(!posture.mcp_locked); + assert!(posture.error.is_some()); + let err_msg = posture.error.unwrap(); + assert!( + err_msg.starts_with("HKLM: cannot read: "), + "error should start with 'HKLM: cannot read: ', got: {}", + err_msg + ); + assert!( + err_msg.contains("access denied"), + "error should contain the cause, got: {}", + err_msg + ); + } + + #[test] + fn hklm_invalid_data_gives_error_with_cause() { + let sources = ManagedSources { + hklm: Some(Err(io::Error::new( + io::ErrorKind::InvalidData, + "HKLM value has wrong type: 4", + ))), + file: None, + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, None); + assert!(!posture.hooks_locked); + assert!(!posture.mcp_locked); + assert!(posture.error.is_some()); + let err_msg = posture.error.unwrap(); + assert!( + err_msg.starts_with("HKLM: cannot read: "), + "error should start with 'HKLM: cannot read: ', got: {}", + err_msg + ); + assert!( + err_msg.contains("wrong type"), + "error should contain the cause, got: {}", + err_msg + ); + } + + #[test] + fn hklm_not_found_is_treated_as_absent() { + let sources = ManagedSources { + hklm: None, + file: Some(Ok(r#"{"model":"x"}"#.to_string())), + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::File)); + assert!(posture.error.is_none()); + } + + #[test] + fn hklm_empty_string_gives_broken_error() { + let sources = ManagedSources { + hklm: Some(Ok("".to_string())), + file: None, + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, None); + assert!(!posture.hooks_locked); + assert!(!posture.mcp_locked); + assert_eq!(posture.error, Some("HKLM: broken (empty)".to_string())); + } + + #[test] + fn large_hklm_value_processed_successfully() { + let large_json = format!(r#"{{"key":"{}"}}"#, "x".repeat(50000)); + let sources = ManagedSources { + hklm: Some(Ok(large_json)), + file: None, + drop_ins: Vec::new(), + }; + let posture = managed_settings_state(&sources); + assert_eq!(posture.source, Some(ManagedSettingsSource::Hklm)); + assert!(posture.error.is_none()); + } +} diff --git a/crates/agent/src/provider/claude_code/mod.rs b/crates/agent/src/provider/claude_code/mod.rs index 0c016487..8bf3d83e 100644 --- a/crates/agent/src/provider/claude_code/mod.rs +++ b/crates/agent/src/provider/claude_code/mod.rs @@ -8,6 +8,7 @@ use super::{Provider, ReconcileContext}; use crate::reconcile::ReconcilePlan; pub(super) mod discovery; +pub mod managed; mod reconcile; #[derive(Clone)] diff --git a/crates/agent/src/provider/claude_code/reconcile.rs b/crates/agent/src/provider/claude_code/reconcile.rs index a981b7b9..105420c9 100644 --- a/crates/agent/src/provider/claude_code/reconcile.rs +++ b/crates/agent/src/provider/claude_code/reconcile.rs @@ -8,7 +8,7 @@ use agentdesktop_core::config::{ ClaudeCodeConfig, LlmGatewayAuthentication, LlmGatewayConfig, SandboxConfig, }; use serde_json::{Value, json}; -use tracing::debug; +use tracing::{debug, warn}; use crate::reconcile::ReconcilePlan; @@ -68,6 +68,18 @@ pub(super) fn plan( let settings = managed_settings(config, gateway, credential_helper, hooks, sandbox)?; if merge_existing { + if config.allow_managed_hooks_only.is_some() + || config.allow_managed_mcp_servers_only.is_some() + { + use std::sync::Once; + static USER_MODE_WARNING: Once = Once::new(); + USER_MODE_WARNING.call_once(|| { + warn!( + "allowManagedHooksOnly/allowManagedMcpServersOnly have no effect in user settings; \ + deliver them through the system daemon or MDM" + ); + }); + } json_merge::plan_merge( path, &merge_state_path, @@ -163,6 +175,19 @@ fn managed_settings( ) -> anyhow::Result { let mut settings = serde_json::to_value(&config.settings) .context("serialize Claude Code pass-through settings")?; + + // Insert typed containment lock fields if set + if let Some(allow_managed_hooks_only) = config.allow_managed_hooks_only { + settings["allowManagedHooksOnly"] = json!(allow_managed_hooks_only); + } + if let Some(allow_managed_mcp_servers_only) = config.allow_managed_mcp_servers_only { + settings["allowManagedMcpServersOnly"] = json!(allow_managed_mcp_servers_only); + } + if let Some(ref allowed_mcp_servers) = config.allowed_mcp_servers { + settings["allowedMcpServers"] = + serde_json::to_value(allowed_mcp_servers).context("serialize allowedMcpServers")?; + } + if let Some(command) = hooks.tool_use { append_hook(&mut settings, "PreToolUse", command)?; } @@ -555,6 +580,99 @@ programs: ); } + #[test] + fn system_mode_plan_with_all_three_keys_writes_them_at_top_level() { + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com + authentication: + type: controllerJwt + audience: agentgateway + allowedClientIds: [claude-code] +programs: + claudeCode: + allowManagedHooksOnly: true + allowManagedMcpServersOnly: true + allowedMcpServers: + - serverUrl: https://mcp.example.com/* + env: + COMPANY_KEY: value + permissions: + defaultMode: plan +"#, + ) + .expect("valid daemon configuration"); + let claude = config.programs.claude_code.as_ref().unwrap(); + let gateway = config.llm_gateway.as_ref().unwrap(); + + let settings = managed_settings( + claude, + Some(gateway), + "agentdesktop credential", + Hooks::new(None, None), + None, + ) + .expect("merged settings"); + + assert_eq!(settings["allowManagedHooksOnly"], true); + assert_eq!(settings["allowManagedMcpServersOnly"], true); + assert_eq!( + settings["allowedMcpServers"], + json!([{"serverUrl": "https://mcp.example.com/*"}]) + ); + assert_eq!(settings["env"]["COMPANY_KEY"], "value"); + assert_eq!( + settings["env"]["ANTHROPIC_BASE_URL"], + "https://gateway.example.com/" + ); + assert_eq!(settings["permissions"], json!({"defaultMode": "plan"})); + assert!(!settings.as_object().unwrap().contains_key("settings")); + } + + #[test] + fn system_mode_plan_with_keys_unset_does_not_write_null() { + let config = parse_daemon( + r#" +programs: + claudeCode: + env: + COMPANY_KEY: value +"#, + ) + .expect("valid daemon configuration"); + let claude = config.programs.claude_code.as_ref().unwrap(); + + let settings = managed_settings( + claude, + None, + "agentdesktop credential", + Hooks::new(None, None), + None, + ) + .expect("merged settings"); + + assert!( + !settings + .as_object() + .unwrap() + .contains_key("allowManagedHooksOnly") + ); + assert!( + !settings + .as_object() + .unwrap() + .contains_key("allowManagedMcpServersOnly") + ); + assert!( + !settings + .as_object() + .unwrap() + .contains_key("allowedMcpServers") + ); + assert_eq!(settings["env"]["COMPANY_KEY"], "value"); + } + #[cfg(unix)] fn plan_with( path: &Path, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 9605ee78..49eee45d 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -792,6 +792,19 @@ impl ProgramsConfig { } } +/// MCP server allowlist entry for `allowedMcpServers`. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum McpServerRule { + /// User-assigned label; not a security control. + ServerName(String), + /// Command array, must be non-empty. + ServerCommand(Vec), + /// Server URL, must be non-empty. + ServerUrl(String), +} + /// Settings reconciled into Claude Code's managed configuration. /// /// Arbitrary keys are written directly to Agentdesktop's managed-settings @@ -806,11 +819,60 @@ pub struct ClaudeCodeConfig { /// Upstream authentication used by this agent. #[serde(default, skip_serializing_if = "Option::is_none")] pub auth: Option, + /// Allow only managed hooks. Claude Code reads this only from managed sources. + /// Unset: not written to Claude Code's settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_managed_hooks_only: Option, + /// Allow only managed MCP servers. Claude Code reads this only from managed sources. + /// Unset: not written to Claude Code's settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_managed_mcp_servers_only: Option, + /// Allowed MCP servers list. Unset: not written to Claude Code's settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_mcp_servers: Option>, /// Arbitrary Claude Code managed-settings values, flattened into this object. #[serde(default, flatten)] pub settings: BTreeMap, } +impl ClaudeCodeConfig { + /// Called from daemon config validation, so the controller refuses the config before any device sees it. + pub fn validate(&self) -> anyhow::Result<()> { + if let Some(servers) = &self.allowed_mcp_servers { + for (i, rule) in servers.iter().enumerate() { + match rule { + McpServerRule::ServerName(name) => { + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + anyhow::bail!( + "programs.claudeCode.allowedMcpServers[{i}]: serverName must match [A-Za-z0-9_-]+" + ); + } + } + McpServerRule::ServerCommand(cmd) => { + if cmd.is_empty() { + anyhow::bail!( + "programs.claudeCode.allowedMcpServers[{i}]: serverCommand must be a non-empty array" + ); + } + } + McpServerRule::ServerUrl(url) => { + if url.trim().is_empty() { + anyhow::bail!( + "programs.claudeCode.allowedMcpServers[{i}]: serverUrl must be non-empty" + ); + } + } + } + } + } + Ok(()) + } +} + /// Settings reconciled into Claude Desktop's managed configuration. /// /// Arbitrary keys are written directly to the managed settings file. Generated @@ -1693,6 +1755,9 @@ fn validate_daemon( anyhow::bail!("Pi model {model} is not declared in models"); } } + if let Some(claude_code) = &programs.claude_code { + claude_code.validate()?; + } Ok(()) } @@ -2528,4 +2593,219 @@ programs: ) .expect("absolute executor"); } + + // Managed-settings lock validation tests + + #[test] + fn claudecode_config_accepts_both_boolean_locks() { + let yaml = r#" +programs: + claudeCode: + allowManagedHooksOnly: true + allowManagedMcpServersOnly: false +"#; + let config = parse_daemon(yaml).expect("valid config"); + let claude = config.programs.claude_code.as_ref().unwrap(); + assert_eq!(claude.allow_managed_hooks_only, Some(true)); + assert_eq!(claude.allow_managed_mcp_servers_only, Some(false)); + } + + #[test] + fn claudecode_config_rejects_string_for_boolean_keys() { + let yaml_hooks = r#" +programs: + claudeCode: + allowManagedHooksOnly: "yes" +"#; + let error = parse_daemon(yaml_hooks).expect_err("string for boolean"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowManagedHooksOnly"), + "error should name the key path: {msg}" + ); + + let yaml_mcp = r#" +programs: + claudeCode: + allowManagedMcpServersOnly: "true" +"#; + let error = parse_daemon(yaml_mcp).expect_err("string for boolean"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowManagedMcpServersOnly"), + "error should name the key path: {msg}" + ); + } + + #[test] + fn claudecode_config_accepts_empty_allowed_mcp_servers() { + let yaml = r#" +programs: + claudeCode: + allowedMcpServers: [] +"#; + let config = parse_daemon(yaml).expect("valid config"); + let claude = config.programs.claude_code.as_ref().unwrap(); + assert_eq!( + claude.allowed_mcp_servers.as_ref().map(|v| v.len()), + Some(0) + ); + } + + #[test] + fn claudecode_config_accepts_one_entry_of_each_kind() { + let yaml = r#" +programs: + claudeCode: + allowedMcpServers: + - serverName: "my-server" + - serverCommand: ["npx", "-y", "server"] + - serverUrl: "https://mcp.example.com/*" +"#; + let config = parse_daemon(yaml).expect("valid config"); + let claude = config.programs.claude_code.as_ref().unwrap(); + let servers = claude.allowed_mcp_servers.as_ref().unwrap(); + assert_eq!(servers.len(), 3); + } + + #[test] + fn claudecode_config_rejects_invalid_allowed_mcp_servers() { + // serverCommand as string not array + let yaml_cmd_string = r#" +programs: + claudeCode: + allowedMcpServers: + - serverCommand: "npx" +"#; + let error = parse_daemon(yaml_cmd_string).expect_err("string command"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowedMcpServers[0]"), + "error should name the path: {msg}" + ); + + // Two keys in one entry + let yaml_two_keys = r#" +programs: + claudeCode: + allowedMcpServers: + - serverName: "test" + serverUrl: "https://example.com" +"#; + let error = parse_daemon(yaml_two_keys).expect_err("two keys"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowedMcpServers"), + "error should name the path: {msg}" + ); + + // serverName with invalid characters + let yaml_invalid_name = r#" +programs: + claudeCode: + allowedMcpServers: + - serverName: "a b" +"#; + let error = parse_daemon(yaml_invalid_name).expect_err("invalid server name"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowedMcpServers[0]"), + "error should name the path: {msg}" + ); + + // serverName empty + let yaml_empty_name = r#" +programs: + claudeCode: + allowedMcpServers: + - serverName: "" +"#; + let error = parse_daemon(yaml_empty_name).expect_err("empty server name"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowedMcpServers[0]"), + "error should name the path: {msg}" + ); + assert!( + msg.contains("serverName must match"), + "error should explain the rule: {msg}" + ); + + // serverUrl empty + let yaml_empty_url = r#" +programs: + claudeCode: + allowedMcpServers: + - serverUrl: "" +"#; + let error = parse_daemon(yaml_empty_url).expect_err("empty url"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowedMcpServers[0]"), + "error should name the path: {msg}" + ); + assert!( + msg.contains("serverUrl must be non-empty"), + "error should explain the rule: {msg}" + ); + + // serverCommand empty array + let yaml_empty_cmd = r#" +programs: + claudeCode: + allowedMcpServers: + - serverCommand: [] +"#; + let error = parse_daemon(yaml_empty_cmd).expect_err("empty command"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowedMcpServers[0]"), + "error should name the path: {msg}" + ); + assert!( + msg.contains("serverCommand must be a non-empty array"), + "error should explain the rule: {msg}" + ); + + // Non-object entry + let yaml_non_object = r#" +programs: + claudeCode: + allowedMcpServers: + - "just a string" +"#; + let error = parse_daemon(yaml_non_object).expect_err("non-object entry"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowedMcpServers"), + "error should name the path: {msg}" + ); + + // allowedMcpServers as object not array + let yaml_not_array = r#" +programs: + claudeCode: + allowedMcpServers: {} +"#; + let error = parse_daemon(yaml_not_array).expect_err("object not array"); + let msg = format!("{error:#}"); + assert!( + msg.contains("programs.claudeCode.allowedMcpServers"), + "error should name the path: {msg}" + ); + } + + #[test] + fn claudecode_config_passes_through_other_keys() { + let yaml = r#" +programs: + claudeCode: + companyAnnouncements: "hello" + allowManagedHooksOnly: true +"#; + let config = parse_daemon(yaml).expect("valid config"); + let claude = config.programs.claude_code.as_ref().unwrap(); + assert_eq!(claude.allow_managed_hooks_only, Some(true)); + assert!(claude.settings.contains_key("companyAnnouncements")); + } } diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index d4267f1b..1b4abb6e 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -147,6 +147,16 @@ pub struct DaemonInfo { pub containment: Option, } +/// The admin source Claude Code selects for managed settings. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ManagedSettingsSource { + Hklm, + File, + #[serde(other)] + Unknown, +} + /// Whether `agentdesktop run` can launch a sandboxed program on this daemon. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -158,6 +168,18 @@ pub struct ContainmentInfo { /// Why launches are impossible, when `available` is `false`. #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, + /// The admin source Claude Code selects (hklm, file, or null when no admin source delivers a policy). + #[serde(default)] + pub managed_settings_source: Option, + /// Whether `allowManagedHooksOnly` is enforced. + #[serde(default)] + pub managed_hooks_locked: bool, + /// Whether `allowManagedMcpServersOnly` with `allowedMcpServers` is enforced. + #[serde(default)] + pub managed_mcp_locked: bool, + /// Error reading managed settings, when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub managed_settings_error: Option, } /// Body of `POST /v1/launch`. @@ -198,6 +220,15 @@ pub struct Launch { /// (set by the periodic check or the exit check; never cleared). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub integrity_drift: bool, + /// Managed-settings source when this launch started. + #[serde(default)] + pub managed_settings_source: Option, + /// Whether `allowManagedHooksOnly` was enforced when this launch started. + #[serde(default)] + pub managed_hooks_locked: bool, + /// Whether `allowManagedMcpServersOnly` was enforced when this launch started. + #[serde(default)] + pub managed_mcp_locked: bool, } /// State of the daemon's receiver for gateway usage. @@ -543,10 +574,20 @@ mod tests { executor: r"C:\mxc\x64\wxc-exec.exe".into(), available: true, error: None, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + managed_settings_error: None, }; assert_eq!( serde_json::to_value(&available).unwrap(), - serde_json::json!({"executor": r"C:\mxc\x64\wxc-exec.exe", "available": true}) + serde_json::json!({ + "executor": r"C:\mxc\x64\wxc-exec.exe", + "available": true, + "managedSettingsSource": null, + "managedHooksLocked": false, + "managedMcpLocked": false + }) ); let unavailable: ContainmentInfo = serde_json::from_str( @@ -595,6 +636,9 @@ mod tests { ended_at_unix_seconds: None, credential_expires_at_unix_seconds: Some(1_700_003_600), integrity_drift: false, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, }; let json = serde_json::to_value(&running).unwrap(); assert_eq!(json["launchId"], "0123456789abcdef"); @@ -603,6 +647,9 @@ mod tests { assert_eq!(json["startedAtUnixSeconds"], 1_700_000_000u64); assert_eq!(json["credentialExpiresAtUnixSeconds"], 1_700_003_600u64); assert!(json.get("integrityDrift").is_none()); + assert_eq!(json["managedSettingsSource"], serde_json::Value::Null); + assert_eq!(json["managedHooksLocked"], false); + assert_eq!(json["managedMcpLocked"], false); let again: Launch = serde_json::from_value(json).unwrap(); assert_eq!(again, running); @@ -742,6 +789,9 @@ mod tests { ended_at_unix_seconds: None, credential_expires_at_unix_seconds: None, integrity_drift: false, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, }; let json = serde_json::to_value(&without_drift).unwrap(); assert!(json.get("integrityDrift").is_none()); @@ -759,4 +809,96 @@ mod tests { assert_eq!(parsed, with_drift); assert!(parsed.integrity_drift); } + + #[test] + fn launch_and_containment_info_round_trip_with_managed_settings_fields() { + use super::ManagedSettingsSource; + + // Launch with managed-settings fields + let launch_with_fields = 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, + credential_expires_at_unix_seconds: Some(1_700_003_600), + managed_settings_source: Some(ManagedSettingsSource::File), + managed_hooks_locked: true, + managed_mcp_locked: false, + integrity_drift: false, + }; + let json = serde_json::to_value(&launch_with_fields).unwrap(); + assert_eq!(json["managedSettingsSource"], "file"); + assert_eq!(json["managedHooksLocked"], true); + assert_eq!(json["managedMcpLocked"], false); + let again: Launch = serde_json::from_value(json).unwrap(); + assert_eq!(again, launch_with_fields); + + // Launch without managed-settings fields (defaults) + let launch_without_fields = Launch { + launch_id: "fedcba9876543210".into(), + program: "claude-code".into(), + cwd: "/work/app".into(), + container_id: "agentdesktop-fedcba9876543210".into(), + status: LaunchStatus::Running, + exit_code: None, + started_at_unix_seconds: 1_700_000_000, + ended_at_unix_seconds: None, + credential_expires_at_unix_seconds: None, + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + integrity_drift: false, + }; + let json = serde_json::to_value(&launch_without_fields).unwrap(); + assert_eq!(json["managedSettingsSource"], serde_json::Value::Null); + assert_eq!(json["managedHooksLocked"], false); + assert_eq!(json["managedMcpLocked"], false); + let again: Launch = serde_json::from_value(json).unwrap(); + assert_eq!(again, launch_without_fields); + + // ContainmentInfo with managed-settings fields + let info_with_fields = ContainmentInfo { + executor: r"C:\mxc\x64\wxc-exec.exe".into(), + available: true, + error: None, + managed_settings_source: Some(ManagedSettingsSource::Hklm), + managed_hooks_locked: false, + managed_mcp_locked: true, + managed_settings_error: Some("cannot read file".into()), + }; + let json = serde_json::to_value(&info_with_fields).unwrap(); + assert_eq!(json["managedSettingsSource"], "hklm"); + assert_eq!(json["managedHooksLocked"], false); + assert_eq!(json["managedMcpLocked"], true); + assert_eq!(json["managedSettingsError"], "cannot read file"); + let again: ContainmentInfo = serde_json::from_value(json).unwrap(); + assert_eq!(again, info_with_fields); + + // ContainmentInfo without managed-settings fields + let info_without_fields = ContainmentInfo { + executor: "x".into(), + available: false, + error: Some("requires user mode".into()), + managed_settings_source: None, + managed_hooks_locked: false, + managed_mcp_locked: false, + managed_settings_error: None, + }; + let json = serde_json::to_value(&info_without_fields).unwrap(); + assert_eq!(json["managedSettingsSource"], serde_json::Value::Null); + assert_eq!(json["managedHooksLocked"], false); + assert_eq!(json["managedMcpLocked"], false); + assert!( + !json + .as_object() + .unwrap() + .contains_key("managedSettingsError") + ); + let again: ContainmentInfo = serde_json::from_value(json).unwrap(); + assert_eq!(again, info_without_fields); + } } diff --git a/frontend/desktop/src/stories/fixtures.ts b/frontend/desktop/src/stories/fixtures.ts index 593ceebf..cb9552f5 100644 --- a/frontend/desktop/src/stories/fixtures.ts +++ b/frontend/desktop/src/stories/fixtures.ts @@ -47,6 +47,9 @@ export const containmentDaemonInfo: DaemonInfo = { executor: "C:\\Program Files\\mxc\\wxc-exec.exe", available: false, error: "executor not found: C:\\Program Files\\mxc\\wxc-exec.exe", + managedSettingsSource: null, + managedHooksLocked: false, + managedMcpLocked: false, }, }; diff --git a/frontend/desktop/src/types.ts b/frontend/desktop/src/types.ts index 7b5ece3a..6e8db079 100644 --- a/frontend/desktop/src/types.ts +++ b/frontend/desktop/src/types.ts @@ -73,6 +73,10 @@ export interface DaemonInfo { executor: string; available: boolean; error?: string; + managedSettingsSource?: "hklm" | "file" | "unknown" | null; + managedHooksLocked?: boolean; + managedMcpLocked?: boolean; + managedSettingsError?: string; }; } diff --git a/schema/daemon-config.json b/schema/daemon-config.json index bfd645d3..3dfb4299 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -72,6 +72,30 @@ "description": "Settings reconciled into Claude Code's managed configuration.\n\nArbitrary keys are written directly to Agentdesktop's managed-settings\ndrop-in. Generated gateway settings take precedence when values overlap.", "type": "object", "properties": { + "allowManagedHooksOnly": { + "description": "Allow only managed hooks. Claude Code reads this only from managed sources.\nUnset: not written to Claude Code's settings.", + "type": [ + "boolean", + "null" + ] + }, + "allowManagedMcpServersOnly": { + "description": "Allow only managed MCP servers. Claude Code reads this only from managed sources.\nUnset: not written to Claude Code's settings.", + "type": [ + "boolean", + "null" + ] + }, + "allowedMcpServers": { + "description": "Allowed MCP servers list. Unset: not written to Claude Code's settings.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/McpServerRule" + } + }, "auth": { "description": "Upstream authentication used by this agent.", "anyOf": [ @@ -568,6 +592,53 @@ }, "additionalProperties": false }, + "McpServerRule": { + "description": "MCP server allowlist entry for `allowedMcpServers`.", + "oneOf": [ + { + "description": "User-assigned label; not a security control.", + "type": "object", + "properties": { + "serverName": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "serverName" + ] + }, + { + "description": "Command array, must be non-empty.", + "type": "object", + "properties": { + "serverCommand": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "serverCommand" + ] + }, + { + "description": "Server URL, must be non-empty.", + "type": "object", + "properties": { + "serverUrl": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "serverUrl" + ] + } + ] + }, "OpenCodeConfig": { "description": "Settings reconciled into OpenCode's system-managed configuration.\n\nValues under `managedConfig` are written to OpenCode's managed JSONC file.\nWhen generated LLM gateway settings overlap with those values,\nAgentdesktop's generated values take precedence.", "type": "object", diff --git a/schema/daemon-config.md b/schema/daemon-config.md index d3e6b0d3..cd8b6d5c 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -64,6 +64,12 @@ |`programSelection.allowUserChoice`|boolean|Let the device user opt out of all configured programs, or choose the
ones to manage, with `agentdesktop programs set`.
Agentdesktop removes the settings it wrote for a program the user opts
out of, and reports the program as opted out.

Defaults to `false`: every configured program is managed and a choice
stored on the device is ignored until this is enabled again.| |`programs`|object|Per-program settings reconciled on this device.| |`programs.claudeCode`|object|Claude Code managed-settings configuration. Arbitrary keys are passed through directly.| +|`programs.claudeCode.allowManagedHooksOnly`|boolean|Allow only managed hooks. Claude Code reads this only from managed sources.
Unset: not written to Claude Code's settings.| +|`programs.claudeCode.allowManagedMcpServersOnly`|boolean|Allow only managed MCP servers. Claude Code reads this only from managed sources.
Unset: not written to Claude Code's settings.| +|`programs.claudeCode.allowedMcpServers`|[]object|Allowed MCP servers list. Unset: not written to Claude Code's settings.| +|`programs.claudeCode.allowedMcpServers[].serverName`|string|User-assigned label; not a security control.| +|`programs.claudeCode.allowedMcpServers[].serverCommand`|[]string|Command array, must be non-empty.| +|`programs.claudeCode.allowedMcpServers[].serverUrl`|string|Server URL, must be non-empty.| |`programs.claudeCode.auth`|enum|Upstream authentication used by this agent.
Possible values: `subscription`.| |`programs.claudeCode.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| |`programs.claudeDesktop`|object|Claude Desktop system-managed configuration. User daemons skip it.
Arbitrary keys are passed through directly.| From 129cf03b382849ebc64318f118f766596ff9a603 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Tue, 20 Oct 2026 18:46:37 +0200 Subject: [PATCH 06/19] launch: run sandboxed sessions through the relay; no credential in the box Until now a sandboxed launch received a short-lived gateway credential in a file inside the box. Remove that credential from the box entirely: the launch registers a relay session on the daemon's local LLM proxy, and the box talks to `http://127.0.0.1:/sandbox` with a per-session pairing header. The daemon attaches the real gateway credential host-side; the box never sees it. Revocation is immediate: a revoked or exited session gets `403 pairing_invalid` on the next request, even mid-conversation. - Box env: `ANTHROPIC_BASE_URL` points at the relay, `ANTHROPIC_AUTH_TOKEN` is a placeholder, the pairing travels in `ANTHROPIC_CUSTOM_HEADERS`, and `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1` keeps a settings.json base URL from overriding the relay. No credential file, no `AGENTDESKTOP_CREDENTIAL_FILE`, and the launch-time credential refresh task is gone. - `daemon.containment.egress` (`allow` | `deny`): with `deny`, the MXC request removes outbound network from the box except host loopback, so the relay is the only way out. Opt-in here; flipping the default is a follow-up. - Session lifecycle: registered in `start_launch` after a capacity pre-check, revoked when the launch fails, when the box exits, and by the poller when it loses the box. `Launch` gains `sessionId`; `GET /v1/launches` carries it. - The sandbox route never starts an interactive sign-in; its 401 tells the user to sign in on the host. The session id is logged once at creation; the pairing value is never logged. Co-Authored-By: Claude Fable 5 --- README.md | 80 +-- crates/agent/src/api.rs | 86 +++ crates/agent/src/cli.rs | 119 ++-- crates/agent/src/daemon.rs | 53 +- crates/agent/src/launch/host.rs | 23 +- crates/agent/src/launch/mod.rs | 279 ++++----- .../src/launch/testdata/request-claude.json | 9 +- crates/agent/src/launch/tests.rs | 547 ++++++++++++------ crates/agent/src/launch/writer_tests.rs | 59 +- crates/agent/src/llm_proxy/mod.rs | 98 +++- crates/agent/src/llm_proxy/sessions.rs | 10 +- crates/core/src/config.rs | 100 ++++ crates/core/src/model.rs | 277 ++++----- schema/daemon-config.json | 19 + schema/daemon-config.md | 1 + 15 files changed, 1083 insertions(+), 677 deletions(-) diff --git a/README.md b/README.md index 706e08be..c5fbae3e 100644 --- a/README.md +++ b/README.md @@ -695,10 +695,16 @@ 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: deny # opt-in: the box loses direct outbound network ``` +`run` needs the local LLM proxy: without `daemon.llmProxy.listen` it answers +`409`. + The daemon reports the setting as `containment` in daemon-info (`available` with the reason when not). Then, from the workspace directory: @@ -748,45 +754,48 @@ 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`, the daemon's state directory or a denied path (`%ProgramFiles(x86)%` -counts as a system directory). Outbound network is open in this preview; whether the box can reach -loopback services on the host, and whether it accepts inbound loopback -connections, follows the request's network block (`egress` open, host loopback -ingress denied) and is not yet verified in the lab; 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 and no sign-in: the daemon writes the -gateway credential to a private file named by `AGENTDESKTOP_CREDENTIAL_FILE`, -which `agentdesktop credential --client-id claude-code` reads inside the box. -That token is the box's single secret. Inside the box the agent can read a -valid gateway token and send it anywhere, because outbound traffic is open in -this preview; the local relay route removes the token from the box. The daemon -rewrites the file shortly before the credential expires (about 50 s before), -without ever opening a browser; if the gateway refuses the refresh the file -stays until the credential expires, and if the configuration no longer allows -the launch the file is removed at that next refresh. After logout or a policy -change a running box therefore keeps model access for at most the remaining -credential lifetime. Restarting the daemon ends model access for running -sandboxed sessions within the credential lifetime; start a new `run` with -`--resume`. The box's own `TEMP` lives under the launch directory. Telemetry +counts as a system directory). Outbound network is open by default (`daemon.containment.egress: +deny` 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. 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, workspace or argument, `403` when another user calls a user-mode daemon on Windows, `409` for the configuration -rules above, `401`/`424`/`502` straight from the credential request, and `500` -when preparing the launch fails (creating `~\.claude`, the credential directory, -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 -entry carries `integrityDrift: true` once a change was detected. A daemon restart keeps running -boxes alive but stops refreshing their credential: at startup the daemon removes -the credential and 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 +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, @@ -977,7 +986,10 @@ 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 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 diff --git a/crates/agent/src/api.rs b/crates/agent/src/api.rs index 1e939ed7..6afd6c8a 100644 --- a/crates/agent/src/api.rs +++ b/crates/agent/src/api.rs @@ -484,6 +484,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, @@ -1177,4 +1191,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/cli.rs b/crates/agent/src/cli.rs index 23eb2646..661e3706 100644 --- a/crates/agent/src/cli.rs +++ b/crates/agent/src/cli.rs @@ -1,5 +1,4 @@ use std::{ - ffi::OsStr, io::Read, path::{Path, PathBuf}, }; @@ -106,10 +105,9 @@ fn session_error_message(error: &anyhow::Error) -> String { Some(status) if status.status == 404 => { "this daemon has no session API (upgrade agentdesktop)".to_owned() } - Some(status) if status.status == 429 => format!( - "too many sessions ({}); revoke unused ones", - crate::llm_proxy::sessions::MAX_SESSIONS - ), + Some(status) if status.status == 429 => { + crate::llm_proxy::sessions::too_many_sessions_message() + } Some(status) => status.to_string(), None => format!("{error:#}"), } @@ -270,15 +268,6 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()> ); } ClientCommand::Credential { client_id } => { - // Inside a sandboxed launch the daemon's pipe is out of reach; the - // launch directory carries the credential instead. - if let Some(credential) = file_credential( - &client_id, - std::env::var_os("AGENTDESKTOP_CREDENTIAL_FILE").as_deref(), - ) { - println!("{credential}"); - return Ok(()); - } let client_id: String = url::form_urlencoded::byte_serialize(client_id.as_bytes()).collect(); let response: LlmGatewayCredential = client::get( @@ -293,19 +282,7 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()> let request = run_request(&program, cwd, args); match client::post_json_response::<_, Launch>(&socket, "/v1/launch", &request).await { Ok(launch) => { - let valid_until = launch - .credential_expires_at_unix_seconds - .map(|seconds| { - humantime::format_rfc3339_seconds( - std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds), - ) - .to_string() - }) - .unwrap_or_else(|| "unknown".to_owned()); - println!( - "launched {} (container {}, credential valid until {valid_until})", - launch.launch_id, launch.container_id - ); + println!("{}", run_success_line(&launch)); } Err(error) => { eprintln!("{}", run_error_message(&error)); @@ -388,20 +365,6 @@ fn print_programs(management: &ProgramManagement) { } } -/// The credential a sandboxed launch reads from `AGENTDESKTOP_CREDENTIAL_FILE` -/// (the value of that variable is `var`). `None` unless `client_id` is -/// `claude-code`, and `None` when the variable is unset or the file is -/// unreadable or empty after trimming. -pub fn file_credential(client_id: &str, var: Option<&OsStr>) -> Option { - if client_id != crate::provider::claude_code::ClaudeCode::ID { - return None; - } - let path = var.filter(|value| !value.is_empty())?; - let contents = std::fs::read_to_string(path).ok()?; - let credential = contents.trim(); - (!credential.is_empty()).then(|| credential.to_owned()) -} - /// The launch request for `agentdesktop run `; the alias `claude` /// maps to the provider id `claude-code`. pub fn run_request(program: &str, cwd: PathBuf, args: Vec) -> LaunchRequest { @@ -417,6 +380,14 @@ pub fn run_request(program: &str, cwd: PathBuf, args: Vec) -> LaunchRequ } } +/// 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::() { @@ -510,14 +481,14 @@ fn claude_client_id_for_entrypoint(entrypoint: Option<&str>) -> &'static str { #[cfg(test)] mod tests { - use std::{ffi::OsStr, path::PathBuf}; + use std::path::PathBuf; use agentdesktop_client::StatusError; use agentdesktop_core::model::{LaunchRequest, ProgramChoice, TelemetryEventKind}; use clap::Parser; use super::{ - ClientCommand, LlmProxyCommand, SessionCommand, file_credential, parse_claude_pre_tool_use, + ClientCommand, LlmProxyCommand, SessionCommand, parse_claude_pre_tool_use, parse_program_choice, run_error_message, run_request, session_error_message, }; @@ -641,50 +612,30 @@ mod tests { assert!(run_error_message(&error).contains("connect to /tmp/x.sock")); } - fn credential_file(contents: &str) -> (tempfile::TempDir, PathBuf) { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("token"); - std::fs::write(&path, contents).unwrap(); - (dir, path) - } - + // AC 4: run_success_line test #[test] - fn file_credential_reads_and_trims_the_file_named_by_the_variable() { - let (_dir, path) = credential_file("gateway-token\r\n"); - assert_eq!( - file_credential("claude-code", Some(path.as_os_str())), - Some("gateway-token".to_owned()) - ); - } - - #[test] - fn file_credential_is_none_for_an_empty_file() { - let (_dir, path) = credential_file(" \n"); - assert_eq!(file_credential("claude-code", Some(path.as_os_str())), None); - } - - #[test] - fn file_credential_is_none_when_the_variable_is_unset_or_the_file_is_unreadable() { - assert_eq!(file_credential("claude-code", None), None); - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing"); + 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(), + }; + let line = super::run_success_line(&launch); assert_eq!( - file_credential("claude-code", Some(missing.as_os_str())), - None + line, + "launched 0123456789abcdef (container agentdesktop-0123456789abcdef)" ); - assert_eq!(file_credential("claude-code", Some(OsStr::new(""))), None); - } - - #[test] - fn file_credential_is_only_for_claude_code() { - let (_dir, path) = credential_file("gateway-token"); - for client_id in ["codex", "vscode", "claude-desktop", "agentdesktop", ""] { - assert_eq!( - file_credential(client_id, Some(path.as_os_str())), - None, - "{client_id}" - ); - } } fn session_command(args: &[&str]) -> Result { diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index 49d1566c..c62d2c4a 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -101,6 +101,8 @@ struct ResolvedDaemonArgs { 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, } struct ResolvedToolConfigPath { @@ -254,6 +256,7 @@ impl DaemonArgs { dry_run: self.dry_run, containment, claude_settings_overridden, + containment_egress: config::ContainmentEgress::default(), }); } @@ -352,6 +355,11 @@ impl DaemonArgs { dry_run: self.dry_run, containment, claude_settings_overridden, + containment_egress: startup + .containment + .as_ref() + .map(|c| c.egress) + .unwrap_or_default(), }) } } @@ -707,15 +715,16 @@ where // 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: std::sync::Arc::new(llm_proxy::sessions::ConfigPolicy { - config: state.config.clone(), - state_dir: state.state_dir.clone(), - }), + policy: session_policy.clone(), }; let launch_state = launch::LaunchState { app: state.clone(), @@ -732,6 +741,12 @@ where 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, }; let app = api::router(state.clone()) .merge(llm_proxy::sessions::session_router(session_api)) @@ -2451,6 +2466,7 @@ programs: config::DaemonStartupConfig { containment: Some(config::ContainmentStartupConfig { executor: PathBuf::from(r"C:\mxc\x64\wxc-exec.exe"), + egress: config::ContainmentEgress::default(), }), ..Default::default() } @@ -2520,6 +2536,35 @@ programs: } } + #[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_allow_when_absent() { + 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::Allow + ); + } + fn startup_with_llm_proxy(listen: &str) -> config::DaemonStartupConfig { let mut startup = config::DaemonStartupConfig::default(); startup.llm_proxy.listen = Some(listen.parse().unwrap()); diff --git a/crates/agent/src/launch/host.rs b/crates/agent/src/launch/host.rs index 5e36f4a3..8086cc80 100644 --- a/crates/agent/src/launch/host.rs +++ b/crates/agent/src/launch/host.rs @@ -10,10 +10,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use agentdesktop_core::{ - config::{DaemonConfig, LlmGatewayAuthentication}, - model::LlmGatewayCredential, -}; +use agentdesktop_core::model::LlmGatewayCredential; use async_trait::async_trait; use axum::http::StatusCode; @@ -292,7 +289,7 @@ fn process_start_time(_child: &Child) -> anyhow::Result { } /// The gateway credential for a launch: the local API's own path, with the -/// subscription never included and no browser sign-in for a refresh. +/// subscription never included. The pre-flight is interactive by design. pub struct GatewayCredentialSource { pub app: api::AppState, } @@ -315,7 +312,7 @@ impl CredentialSource for GatewayCredentialSource { let effective = if interactive { effective } else { - non_interactive(effective) + api::non_interactive(effective) }; // `include_subscription` stays false whatever the configuration says: // no subscription token enters the box. @@ -334,20 +331,6 @@ impl CredentialSource for GatewayCredentialSource { } } -/// 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. -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 -} - /// Whether the process `pid` with creation time `start_time` is still alive: /// the pid alone is not an identity, Windows reuses it. #[cfg(windows)] diff --git a/crates/agent/src/launch/mod.rs b/crates/agent/src/launch/mod.rs index 57436b07..1cd0ae29 100644 --- a/crates/agent/src/launch/mod.rs +++ b/crates/agent/src/launch/mod.rs @@ -1,11 +1,11 @@ //! Sandboxed launch of a managed program in a Windows AppContainer (MXC). //! -//! `POST /v1/launch` writes an MXC request and a short-lived gateway credential -//! into a private launch directory, starts `wxc-exec.exe` on it and keeps the -//! credential fresh while the box runs. Everything that touches the host -//! (environment, filesystem, process creation, credentials) goes through the -//! traits below, so the behaviour is testable on every platform; the -//! production implementations live in [`host`]. +//! `POST /v1/launch` writes an MXC request into a private launch directory, +//! registers a relay session and starts `wxc-exec.exe` on it. The box reaches +//! the gateway only through the local LLM proxy's relay. Everything that +//! touches the host (environment, filesystem, process creation, credentials) +//! goes through the traits below, so the behaviour is testable on every +//! platform; the production implementations live in [`host`]. use std::{ collections::BTreeMap, @@ -50,8 +50,6 @@ const KEPT_EXITED: usize = 32; const EARLY_EXIT_WATCH: Duration = Duration::from_secs(3); /// Poll cadence for the exit of a running launch. const POLL_INTERVAL: Duration = Duration::from_secs(1); -/// How long one credential refresh may take. -const REFRESH_TIMEOUT: Duration = Duration::from_secs(30); /// Bytes of `wxc-exec.log` returned with an early-exit error. const LOG_TAIL: usize = 4096; @@ -335,7 +333,8 @@ pub fn check_args(args: &[String]) -> Result, String> { .collect() } -#[derive(Clone, Debug, PartialEq, Serialize)] +// No Debug: the request's environment carries the session pairing. +#[derive(Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct MxcRequest { pub version: String, @@ -355,7 +354,8 @@ pub struct MxcUi { pub injection: bool, } -#[derive(Clone, Debug, PartialEq, Serialize)] +// No Debug: the env list carries the session pairing. +#[derive(Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct MxcProcess { pub command_line: String, @@ -407,7 +407,9 @@ pub struct LaunchInput<'a> { pub launch_dir: PathBuf, pub git_dir: Option, pub program_files_claude: Option, - pub gateway_url: String, + pub egress: agentdesktop_core::config::ContainmentEgress, + pub relay_base_url: String, + pub pairing: String, pub base_env: BTreeMap, pub exists: &'a dyn Fn(&Path) -> bool, pub is_dir: &'a dyn Fn(&Path) -> bool, @@ -440,7 +442,6 @@ pub fn build_request(input: &LaunchInput) -> MxcRequest { let program_files_claude = input.program_files_claude.as_deref().map(win); let claude_dir = join(&home, ".claude"); let tmp = join(&launch_dir, "tmp"); - let credential_dir = join(&launch_dir, "credential"); let system_root = input .base_env @@ -470,11 +471,13 @@ pub fn build_request(input: &LaunchInput) -> MxcRequest { "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".into(), "1".into(), ); - env.insert("ANTHROPIC_BASE_URL".into(), input.gateway_url.clone()); + env.insert("ANTHROPIC_BASE_URL".into(), input.relay_base_url.clone()); + env.insert("ANTHROPIC_AUTH_TOKEN".into(), "placeholder".into()); env.insert( - "AGENTDESKTOP_CREDENTIAL_FILE".into(), - join(&credential_dir, "token"), + "ANTHROPIC_CUSTOM_HEADERS".into(), + format!("{}: {}", crate::llm_proxy::PAIRING_HEADER, input.pairing), ); + env.insert("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST".into(), "1".into()); env.insert("HOME".into(), home.clone()); let mut command_line = format!( @@ -495,7 +498,6 @@ pub fn build_request(input: &LaunchInput) -> MxcRequest { git_dir, Some(client_exe_dir), program_files_claude, - Some(credential_dir), // git reads the user's configuration before anything else; unreadable // means every git command fails in the box (lab L4e). Some(join(&home, ".gitconfig")), @@ -593,11 +595,14 @@ pub fn build_request(input: &LaunchInput) -> MxcRequest { }, network: MxcNetwork { egress: MxcEgress { - default: "allow".into(), + default: match input.egress { + agentdesktop_core::config::ContainmentEgress::Allow => "allow".into(), + agentdesktop_core::config::ContainmentEgress::Deny => "deny".into(), + }, }, ingress: MxcIngress { default: "allow".into(), - host_loopback: "deny".into(), + host_loopback: "allow".into(), }, }, } @@ -613,45 +618,6 @@ pub fn write_request(launch_dir: &Path, request: &MxcRequest) -> anyhow::Result< Ok(path) } -/// When the credential refresh next runs: `max(expires_at - 50, now + 10)`. -pub fn refresh_at(expires_at: u64, now: u64) -> u64 { - expires_at.saturating_sub(50).max(now + 10) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum RefreshInput { - /// A credential with this (new) `expires_at`. - Ok(u64), - Status(StatusCode), - Timeout, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RefreshAction { - Keep, - Rewrite, - Delete, -} - -pub fn next_action(input: &RefreshInput, expires_at: u64, now: u64) -> RefreshAction { - match input { - RefreshInput::Ok(new_expiry) if *new_expiry > expires_at => RefreshAction::Rewrite, - RefreshInput::Ok(_) => RefreshAction::Keep, - // A policy or precondition failure: the box must lose the token now. - RefreshInput::Status(StatusCode::CONFLICT | StatusCode::FAILED_DEPENDENCY) => { - RefreshAction::Delete - } - // A transient failure: the still-valid token stays until it expires. - RefreshInput::Status(_) | RefreshInput::Timeout => { - if now > expires_at { - RefreshAction::Delete - } else { - RefreshAction::Keep - } - } - } -} - #[async_trait] pub trait CredentialSource: Send + Sync { async fn credential( @@ -850,16 +816,6 @@ impl LaunchRegistry { } } - pub fn set_credential_expiry(&self, launch_id: &str, expires_at: u64) { - if let Some(launch) = self - .lock() - .iter_mut() - .find(|launch| launch.launch_id == launch_id) - { - launch.credential_expires_at_unix_seconds = Some(expires_at); - } - } - pub fn set_integrity_drift(&self, launch_id: &str) { if let Some(launch) = self .lock() @@ -894,6 +850,10 @@ pub struct LaunchState { pub availability: Option, /// The daemon's own SID; `Some` only for a Windows user-mode daemon. pub daemon_sid: Option, + pub(crate) sessions: crate::llm_proxy::sessions::SessionRegistry, + pub(crate) proxy_address: Option, + pub(crate) session_policy: Arc, + pub(crate) egress: agentdesktop_core::config::ContainmentEgress, } /// `POST /v1/launch` and `GET /v1/launches`, behind the peer check. @@ -980,16 +940,23 @@ async fn launch( ) })?; preconditions(&effective).map_err(|message| (StatusCode::CONFLICT, message))?; - let gateway_url = effective - .llm_gateway - .as_ref() - .map(|gateway| gateway.url.as_str().to_owned()) - .unwrap_or_default(); - let credential = state.credentials.credential(PROGRAM, true).await?; + let Some(proxy_address) = state.proxy_address else { + return Err(( + StatusCode::CONFLICT, + "the local LLM proxy is not running (daemon.llmProxy.listen)".to_owned(), + )); + }; + // Refuse a full registry before any interactive sign-in starts; the + // register call below stays the authoritative check. + if state.sessions.len(state.host.now()) >= crate::llm_proxy::sessions::MAX_SESSIONS { + return Err(too_many_sessions()); + } + state.session_policy.check(PROGRAM).await?; + let _credential = state.credentials.credential(PROGRAM, true).await?; // From here on the work runs in its own task: a client that disconnects // during the early-exit watch must not leave a running box unregistered, - // unpolled and with a token nobody refreshes or removes. + // unpolled and without its session revoked. let started = tokio::spawn(start_launch( state, StartInput { @@ -997,8 +964,7 @@ async fn launch( executable, cwd, args, - gateway_url, - credential, + proxy_address, }, )); match started.await { @@ -1010,18 +976,24 @@ async fn launch( } } +fn too_many_sessions() -> LaunchError { + ( + StatusCode::TOO_MANY_REQUESTS, + crate::llm_proxy::sessions::too_many_sessions_message(), + ) +} + /// What `start_launch` needs beyond the state: the checked request. struct StartInput { executor: PathBuf, executable: PathBuf, cwd: PathBuf, args: Vec, - gateway_url: String, - credential: LlmGatewayCredential, + proxy_address: std::net::SocketAddr, } /// Creates the launch directory, starts the executor, watches it for an early -/// exit and registers the launch with its poller and refresh task. +/// exit and registers the launch with its poller. async fn start_launch( state: LaunchState, input: StartInput, @@ -1034,10 +1006,42 @@ async fn start_launch( format!("resolve the daemon state directory: {error}"), ) })?; + + let session = match state.sessions.register( + PROGRAM, + &format!("run {}", launch_id), + crate::llm_proxy::sessions::DEFAULT_IDLE_TTL, + host.now(), + ) { + Ok(session) => session, + Err(crate::llm_proxy::sessions::SessionError::Full) => { + return Err(too_many_sessions()); + } + Err(crate::llm_proxy::sessions::SessionError::InvalidClientId) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "invalid session request".to_owned(), + )); + } + Err(crate::llm_proxy::sessions::SessionError::InvalidLabel) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "invalid session request".to_owned(), + )); + } + }; + let session_id = session.id.clone(); + let launch_dir = state_dir.join("launches").join(&launch_id); let log_path = launch_dir.join("wxc-exec.log"); let fail = |launch_dir: &Path, message: String| { - tracing::warn!(launch_id = %launch_id, %message, "sandboxed launch failed"); + state.sessions.revoke(&session_id); + tracing::warn!( + launch_id = %launch_id, + session_id = %session_id, + %message, + "sandboxed launch failed; its session is revoked" + ); let _ = std::fs::remove_dir_all(launch_dir); Err((StatusCode::INTERNAL_SERVER_ERROR, message)) }; @@ -1049,9 +1053,7 @@ async fn start_launch( format!("prepare launch: create ~\\.claude: {error}"), ); } - // The credential directory exists before the request is built: a - // read-only grant is only honoured for a path that exists. - if let Err(error) = create_launch_dir(&launch_dir, &input.credential.credential) { + if let Err(error) = create_launch_dir(&launch_dir) { return fail(&launch_dir, format!("prepare launch: {error:#}")); } let exists = |path: &Path| host.exists(path); @@ -1067,7 +1069,9 @@ async fn start_launch( launch_dir: launch_dir.clone(), git_dir: host.git_dir(), program_files_claude: host.program_files_claude(), - gateway_url: input.gateway_url, + egress: state.egress, + relay_base_url: crate::llm_proxy::sessions::sandbox_base_url(input.proxy_address), + pairing: session.pairing.to_string(), base_env: host.base_env(), exists: &exists, is_dir: &is_dir, @@ -1194,14 +1198,15 @@ async fn start_launch( exit_code: None, started_at_unix_seconds: started_at, ended_at_unix_seconds: None, - credential_expires_at_unix_seconds: Some(input.credential.expires_at_unix_seconds), integrity_drift: false, managed_settings_source: managed_posture.source, managed_hooks_locked: managed_posture.hooks_locked, managed_mcp_locked: managed_posture.mcp_locked, + session_id: session_id.clone(), }; state.registry.insert(launch.clone()); tracing::info!(launch_id, cwd = %launch.cwd.display(), "sandboxed launch started"); + tracing::info!(launch_id, session_id, "sandboxed launch session created"); // Start periodic integrity check let periodic = { @@ -1229,35 +1234,23 @@ async fn start_launch( )) }; - let refresh = tokio::spawn(refresh_task( - state.credentials.clone(), - host.clone(), - state.registry.clone(), - launch_id.clone(), - launch_dir.join("credential").join("token"), - input.credential.expires_at_unix_seconds, - )); tokio::spawn(poll_task( child, - refresh.abort_handle(), + state.sessions.clone(), + session_id, periodic.abort_handle(), state.registry.clone(), host.clone(), launch_id, launch_dir, - log_path, integrity_check, )); Ok((StatusCode::CREATED, Json(launch))) } -/// Creates the private launch directory with `credential\token` and the box's -/// `tmp`, before the request that grants them is built. -pub(crate) fn create_launch_dir(launch_dir: &Path, credential: &str) -> anyhow::Result<()> { +/// Creates the private launch directory with the box's `tmp`. +pub(crate) fn create_launch_dir(launch_dir: &Path) -> anyhow::Result<()> { secure_fs::ensure_private_dir(launch_dir)?; - let credential_dir = launch_dir.join("credential"); - secure_fs::ensure_private_dir(&credential_dir)?; - secure_fs::atomic_write(&credential_dir.join("token"), credential.as_bytes(), 0o600)?; std::fs::create_dir_all(launch_dir.join("tmp")).context("create launch tmp")?; Ok(()) } @@ -1270,20 +1263,21 @@ fn log_tail(log: &Path) -> String { } /// Watches the executor: copies new log lines to the daemon log. When it exits, -/// or when it can no longer be watched (then it is stopped first), records the -/// exit, stops the refresh and removes the launch directory. +/// or when it can no longer be watched (then it is stopped first), revokes the +/// session, records the exit and removes the launch directory. #[allow(clippy::too_many_arguments)] async fn poll_task( mut child: Box, - refresh: tokio::task::AbortHandle, + sessions: crate::llm_proxy::sessions::SessionRegistry, + session_id: String, periodic: tokio::task::AbortHandle, registry: LaunchRegistry, host: Arc, launch_id: String, launch_dir: PathBuf, - log_path: PathBuf, integrity_check: IntegrityCheck, ) { + let log_path = launch_dir.join("wxc-exec.log"); let mut log_offset = 0u64; loop { tokio::time::sleep(POLL_INTERVAL).await; @@ -1301,11 +1295,11 @@ async fn poll_task( // Stop the background tasks, then verify once against the launch // snapshot before the launch is recorded as exited. periodic.abort(); - refresh.abort(); if integrity_check.verify_and_log(&launch_id).await { registry.set_integrity_drift(&launch_id); } + sessions.revoke(&session_id); registry.mark_exited(&launch_id, exit, host.now()); if let Err(error) = std::fs::remove_dir_all(&launch_dir) { @@ -1343,77 +1337,6 @@ fn copy_log_lines(launch_id: &str, log_path: &Path, offset: u64) -> u64 { offset + complete as u64 + 1 } -/// Re-fetches the credential shortly before it expires and rewrites the token -/// file; a policy refusal removes the token, a transient failure keeps it -/// until it expires. -async fn refresh_task( - credentials: Arc, - host: Arc, - registry: LaunchRegistry, - launch_id: String, - token_path: PathBuf, - mut expires_at: u64, -) { - let mut next_at = refresh_at(expires_at, host.now()); - loop { - let now = host.now(); - if next_at > now { - tokio::time::sleep(Duration::from_secs(next_at - now)).await; - } - let now = host.now(); - let (input, fresh) = - match tokio::time::timeout(REFRESH_TIMEOUT, credentials.credential(PROGRAM, false)) - .await - { - Ok(Ok(credential)) => ( - RefreshInput::Ok(credential.expires_at_unix_seconds), - Some(credential), - ), - Ok(Err((status, body))) => { - tracing::warn!(launch_id, %status, body, "credential refresh refused"); - (RefreshInput::Status(status), None) - } - Err(_) => { - tracing::warn!(launch_id, "credential refresh timed out"); - (RefreshInput::Timeout, None) - } - }; - match next_action(&input, expires_at, now) { - RefreshAction::Rewrite => { - let credential = fresh.expect("a rewrite follows a fetched credential"); - match secure_fs::atomic_write(&token_path, credential.credential.as_bytes(), 0o600) - { - Ok(()) => { - expires_at = credential.expires_at_unix_seconds; - registry.set_credential_expiry(&launch_id, expires_at); - next_at = refresh_at(expires_at, host.now()); - } - Err(error) => { - tracing::warn!(launch_id, error = %format!("{error:#}"), "failed to rewrite the credential"); - next_at = host.now() + 30; - } - } - } - RefreshAction::Keep => { - next_at = host.now() - + if matches!(input, RefreshInput::Ok(_)) { - 10 - } else { - 30 - }; - } - RefreshAction::Delete => { - let _ = std::fs::remove_file(&token_path); - tracing::warn!( - launch_id, - "credential removed; the box can no longer reach the gateway" - ); - return; - } - } - } -} - /// Removes what a previous daemon left behind in `\launches`: /// `credential\` and `request.json` of every launch, and the whole launch /// directory when `\pid` (`" "`) no longer names a @@ -1461,7 +1384,7 @@ pub fn sweep(state_dir: &Path, alive: &dyn Fn(u32, u64) -> bool) -> anyhow::Resu if live { tracing::info!( launch_id, - "launch {launch_id} still running; its credential is no longer refreshed" + "launch {launch_id} still running; the restart revoked its relay session" ); } else if let Err(error) = std::fs::remove_dir_all(&dir) { tracing::warn!(launch_id, %error, "failed to remove a stale launch directory"); diff --git a/crates/agent/src/launch/testdata/request-claude.json b/crates/agent/src/launch/testdata/request-claude.json index 323d5aa2..20e12848 100644 --- a/crates/agent/src/launch/testdata/request-claude.json +++ b/crates/agent/src/launch/testdata/request-claude.json @@ -12,10 +12,12 @@ "cwd": "C:\\lab\\ws pr1", "inheritDefaultEnv": false, "env": [ - "AGENTDESKTOP_CREDENTIAL_FILE=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\credential\\token", - "ANTHROPIC_BASE_URL=https://alpha-dev.aks.solo.sademo.de", + "ANTHROPIC_AUTH_TOKEN=placeholder", + "ANTHROPIC_BASE_URL=http://127.0.0.1:4000/sandbox", + "ANTHROPIC_CUSTOM_HEADERS=x-agentdesktop-pairing: test-pairing-value", "APPDATA=C:\\Users\\dev\\AppData\\Roaming", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1", "CLAUDE_CODE_USE_POWERSHELL_TOOL=1", "CLAUDE_CONFIG_DIR=C:\\Users\\dev\\.claude", "ComSpec=C:\\Windows\\system32\\cmd.exe", @@ -50,7 +52,6 @@ "C:\\Program Files\\Git", "C:\\lab\\agentdesktop", "C:\\Program Files\\ClaudeCode", - "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\credential", "C:\\Users\\dev\\.claude\\settings.json", "C:\\Users\\dev\\.claude\\plugins", "C:\\lab\\ws pr1\\.claude\\settings.local.json", @@ -79,7 +80,7 @@ }, "ingress": { "default": "allow", - "hostLoopback": "deny" + "hostLoopback": "allow" } } } diff --git a/crates/agent/src/launch/tests.rs b/crates/agent/src/launch/tests.rs index 8e537f1d..8eebfcab 100644 --- a/crates/agent/src/launch/tests.rs +++ b/crates/agent/src/launch/tests.rs @@ -28,6 +28,60 @@ use axum::http::StatusCode; use super::*; +// --- Log capture helper for session logging test (AC 2) + +#[derive(Clone, Default)] +struct LogCapture(Arc>>); + +struct LogWriter(Arc>>); + +impl std::io::Write for LogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture { + type Writer = LogWriter; + + fn make_writer(&'a self) -> LogWriter { + LogWriter(self.0.clone()) + } +} + +impl LogCapture { + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } + + async fn wait_for_text(&self, needle: &str) -> String { + for _ in 0..100 { + let text = self.text(); + if text.contains(needle) { + return text; + } + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(10)).await; + } + self.text() + } +} + +fn capture_logs() -> (LogCapture, tracing::subscriber::DefaultGuard) { + let capture = LogCapture::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(capture.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::INFO) + .finish(); + (capture, tracing::subscriber::set_default(subscriber)) +} + /// A platform-appropriate absolute root: `/` or `C:\`. fn root() -> PathBuf { if cfg!(windows) { @@ -50,6 +104,7 @@ fn p(relative: &str) -> PathBuf { fn executor_cfg(path: &str) -> ContainmentStartupConfig { ContainmentStartupConfig { executor: PathBuf::from(path), + egress: agentdesktop_core::config::ContainmentEgress::default(), } } @@ -857,7 +912,9 @@ fn launch_input<'a>( ), git_dir: Some(PathBuf::from(r"C:\Program Files\Git")), program_files_claude: Some(PathBuf::from(r"C:\Program Files\ClaudeCode")), - gateway_url: "https://alpha-dev.aks.solo.sademo.de".into(), + egress: agentdesktop_core::config::ContainmentEgress::Allow, + relay_base_url: "http://127.0.0.1:4000/sandbox".into(), + pairing: "test-pairing-value".into(), base_env: base_env(), exists, is_dir, @@ -986,13 +1043,30 @@ fn the_request_has_the_fixed_shape_and_no_process_timeout() { request["network"], serde_json::json!({ "egress": {"default": "allow"}, - "ingress": {"default": "allow", "hostLoopback": "deny"}, + "ingress": {"default": "allow", "hostLoopback": "allow"}, }) ); assert_eq!(request["process"]["inheritDefaultEnv"], false); assert!(request["process"].get("timeout").is_none()); } +// AC 1: egress deny test +#[test] +fn egress_deny_sets_the_network_default_to_deny() { + use agentdesktop_core::config::ContainmentEgress; + let request = request_json(&snapshot_existing(), &snapshot_directories(), |input| { + input.egress = ContainmentEgress::Deny; + }); + assert_eq!( + request["network"], + serde_json::json!({ + "egress": {"default": "deny"}, + "ingress": {"default": "allow", "hostLoopback": "allow"}, + }), + "egress deny sets network.egress.default to deny and hostLoopback to allow" + ); +} + #[test] fn the_command_line_starts_the_program_through_cmd_without_autorun_or_delayed_expansion() { let request = request_json(&snapshot_existing(), &snapshot_directories(), |_| {}); @@ -1031,14 +1105,13 @@ fn the_environment_is_closed_and_expands_the_path_from_system_root() { assert_eq!(env["CLAUDE_CODE_USE_POWERSHELL_TOOL"], "1"); assert_eq!(env["DISABLE_AUTOUPDATER"], "1"); assert_eq!(env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], "1"); + assert_eq!(env["ANTHROPIC_BASE_URL"], "http://127.0.0.1:4000/sandbox"); + assert_eq!(env["ANTHROPIC_AUTH_TOKEN"], "placeholder"); assert_eq!( - env["ANTHROPIC_BASE_URL"], - "https://alpha-dev.aks.solo.sademo.de" - ); - assert_eq!( - env["AGENTDESKTOP_CREDENTIAL_FILE"], - format!(r"{LAUNCH_DIR}\credential\token") + env["ANTHROPIC_CUSTOM_HEADERS"], + "x-agentdesktop-pairing: test-pairing-value" ); + assert_eq!(env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"], "1"); for (key, value) in base_env() { assert_eq!( env[&key], value, @@ -1074,13 +1147,7 @@ fn the_request_contains_no_percent_sign_and_no_forward_slash_in_any_path() { paths.extend(readonly(&request)); paths.extend(denied(&request)); paths.push(request["process"]["cwd"].as_str().unwrap().to_owned()); - for key in [ - "PATH", - "TEMP", - "TMP", - "CLAUDE_CONFIG_DIR", - "AGENTDESKTOP_CREDENTIAL_FILE", - ] { + for key in ["PATH", "TEMP", "TMP", "CLAUDE_CONFIG_DIR"] { paths.push(env_map(&request)[key].clone()); } for path in paths { @@ -1092,11 +1159,11 @@ fn the_request_contains_no_percent_sign_and_no_forward_slash_in_any_path() { #[test] fn a_url_in_the_request_keeps_its_slashes() { let request = request_json(&snapshot_existing(), &snapshot_directories(), |input| { - input.gateway_url = "https://gateway.example.com/anthropic".into(); + input.relay_base_url = "http://127.0.0.1:4000/sandbox".into(); }); assert_eq!( env_map(&request)["ANTHROPIC_BASE_URL"], - "https://gateway.example.com/anthropic" + "http://127.0.0.1:4000/sandbox" ); } @@ -1123,7 +1190,6 @@ fn read_only_runtime_directories_are_listed_only_when_they_exist() { r"C:\Program Files\Git", r"C:\lab\agentdesktop", r"C:\Program Files\ClaudeCode", - &format!(r"{LAUNCH_DIR}\credential"), ]; for candidate in candidates { assert!( @@ -1323,72 +1389,6 @@ fn write_request_uses_a_private_directory_and_a_0600_file() { assert_eq!(mode(&path), 0o600); } -// ============================================================ refresh (AC 8) - -#[test] -fn refresh_runs_fifty_seconds_before_expiry() { - assert_eq!(refresh_at(1_000, 100), 950); -} - -#[test] -fn refresh_never_runs_sooner_than_ten_seconds_from_now() { - assert_eq!(refresh_at(1_000, 990), 1_000); - assert_eq!(refresh_at(1_000, 995), 1_005); - assert_eq!(refresh_at(1_000, 2_000), 2_010); -} - -#[test] -fn refresh_does_not_underflow_for_an_early_expiry() { - assert_eq!(refresh_at(30, 0), 10); -} - -#[test] -fn a_later_expiry_is_rewritten() { - assert_eq!( - next_action(&RefreshInput::Ok(5_000), 1_000, 950), - RefreshAction::Rewrite - ); -} - -#[test] -fn the_same_expiry_keeps_the_file_and_retries() { - assert_eq!( - next_action(&RefreshInput::Ok(1_000), 1_000, 950), - RefreshAction::Keep - ); -} - -#[test] -fn a_gateway_error_or_timeout_keeps_the_file_until_it_expires() { - for input in [ - RefreshInput::Status(StatusCode::UNAUTHORIZED), - RefreshInput::Status(StatusCode::BAD_GATEWAY), - RefreshInput::Timeout, - ] { - assert_eq!( - next_action(&input, 1_000, 950), - RefreshAction::Keep, - "{input:?} before expiry" - ); - assert_eq!( - next_action(&input, 1_000, 1_001), - RefreshAction::Delete, - "{input:?} after expiry" - ); - } -} - -#[test] -fn a_policy_or_precondition_failure_deletes_the_file_at_once() { - for status in [StatusCode::CONFLICT, StatusCode::FAILED_DEPENDENCY] { - assert_eq!( - next_action(&RefreshInput::Status(status), 1_000, 100), - RefreshAction::Delete, - "{status}" - ); - } -} - // =========================================================== LaunchRegistry (AC 9) fn launch(id: &str, started_at: u64) -> Launch { @@ -1401,7 +1401,7 @@ fn launch(id: &str, started_at: u64) -> Launch { exit_code: None, started_at_unix_seconds: started_at, ended_at_unix_seconds: None, - credential_expires_at_unix_seconds: None, + session_id: format!("session-{id}"), integrity_drift: false, managed_settings_source: None, managed_hooks_locked: false, @@ -1470,31 +1470,6 @@ fn an_exit_without_a_code_is_recorded_as_exited() { assert_eq!(listed[0].exit_code, None); } -#[test] -fn the_credential_expiry_of_a_running_launch_can_be_updated() { - let registry = LaunchRegistry::new(); - registry.insert(launch("a", 10)); - registry.set_credential_expiry("a", 3_600); - assert_eq!( - registry.list()[0].credential_expires_at_unix_seconds, - Some(3_600) - ); - registry.set_credential_expiry("a", 7_200); - assert_eq!( - registry.list()[0].credential_expires_at_unix_seconds, - Some(7_200) - ); -} - -#[test] -fn updating_an_unknown_launch_changes_nothing() { - let registry = LaunchRegistry::new(); - registry.insert(launch("a", 10)); - registry.mark_exited("nope", Some(1), 5); - registry.set_credential_expiry("nope", 5); - assert_eq!(registry.list(), vec![launch("a", 10)]); -} - #[test] fn the_registry_keeps_every_running_launch_and_the_last_32_exited_ones() { let registry = LaunchRegistry::new(); @@ -1979,6 +1954,10 @@ struct Setup { claude_installed: bool, credentials_must_not_be_reached: bool, managed_file: Option<&'static str>, + proxy_address: Option, + session_policy: Arc, + sessions: crate::llm_proxy::sessions::SessionRegistry, + egress: agentdesktop_core::config::ContainmentEgress, managed_file_error: Option<&'static str>, } @@ -1994,6 +1973,13 @@ impl Default for Setup { claude_installed: true, credentials_must_not_be_reached: false, managed_file: None, + proxy_address: Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4000))), + session_policy: Arc::new(crate::llm_proxy::sessions::ConfigPolicy { + config: serde_yaml::from_str(GOOD_CONFIG).unwrap(), + state_dir: std::env::temp_dir(), + }), + sessions: crate::llm_proxy::sessions::SessionRegistry::default(), + egress: agentdesktop_core::config::ContainmentEgress::default(), managed_file_error: None, } } @@ -2009,6 +1995,8 @@ struct Harness { executor: Arc, credentials: Arc, integrity_reads: IntegrityReads, + #[allow(dead_code)] + sessions: crate::llm_proxy::sessions::SessionRegistry, } fn strip_verbatim(path: PathBuf) -> PathBuf { @@ -2128,6 +2116,10 @@ impl Harness { registry: LaunchRegistry::new(), availability, daemon_sid: setup.daemon_sid.map(str::to_owned), + sessions: setup.sessions.clone(), + proxy_address: setup.proxy_address, + session_policy: setup.session_policy.clone(), + egress: setup.egress, }; let mut router = router(state); @@ -2148,6 +2140,7 @@ impl Harness { executor, credentials, integrity_reads, + sessions: setup.sessions, } } @@ -2619,12 +2612,7 @@ async fn a_launch_answers_201_with_the_launch_and_lists_it() { assert_eq!(launched.exit_code, None); assert_eq!(launched.ended_at_unix_seconds, None); assert!((BASE_TIME..BASE_TIME + 10).contains(&launched.started_at_unix_seconds)); - let expires = launched - .credential_expires_at_unix_seconds - .expect("credential expiry"); - assert!( - (BASE_TIME + CREDENTIAL_LIFETIME..BASE_TIME + CREDENTIAL_LIFETIME + 10).contains(&expires) - ); + assert!(!launched.session_id.is_empty(), "session_id present"); assert_eq!(h.listed().await, vec![launched]); } @@ -2651,7 +2639,7 @@ async fn the_credential_and_request_files_exist_before_the_executor_starts() { assert_eq!(spawn.launch_id, launched.launch_id); assert_eq!(spawn.request, dir.join("request.json")); assert_eq!(spawn.log, dir.join("wxc-exec.log")); - assert_eq!(spawn.token.as_deref(), Some("gateway-token-0")); + assert_eq!(spawn.token, None, "nothing is stored for the box"); let request: serde_json::Value = serde_json::from_str(spawn.request_json.as_deref().unwrap()).unwrap(); assert_eq!( @@ -2660,13 +2648,14 @@ async fn the_credential_and_request_files_exist_before_the_executor_starts() { ); let env = env_map(&request); assert!( - env["ANTHROPIC_BASE_URL"].starts_with("https://gateway.example.com"), - "{env:?}" + env["ANTHROPIC_BASE_URL"].ends_with("/sandbox"), + "the box talks to the relay: {env:?}" ); assert!( - env["AGENTDESKTOP_CREDENTIAL_FILE"].ends_with(r"credential\token"), + env["ANTHROPIC_CUSTOM_HEADERS"].starts_with("x-agentdesktop-pairing: "), "{env:?}" ); + assert!(!env.contains_key("AGENTDESKTOP_CREDENTIAL_FILE"), "{env:?}"); assert!( !request.to_string().contains("gateway-token"), "the request holds no secret" @@ -2698,9 +2687,11 @@ async fn the_pid_file_names_the_process_and_its_start_time() { std::fs::read_to_string(h.launch_dir(&launched.launch_id).join("pid")).unwrap(), "4242 987654321" ); + assert_eq!(h.token(&launched.launch_id), None, "nothing is stored"); assert_eq!( - h.token(&launched.launch_id).as_deref(), - Some("gateway-token-0") + h.credentials.calls(), + vec![("claude-code".to_owned(), true)], + "the sign-in pre-flight ran" ); } @@ -2775,75 +2766,14 @@ async fn the_poller_marks_an_exit_stops_the_refresh_and_removes_the_launch_direc let ended = listed[0].ended_at_unix_seconds.expect("an end time"); assert!(ended >= listed[0].started_at_unix_seconds); assert!(!h.launch_dir(&launched.launch_id).exists()); + let now = h.credentials.clock.now(); + assert_eq!(h.sessions.len(now), 0, "session revoked on exit"); tokio::time::sleep(Duration::from_secs(2 * CREDENTIAL_LIFETIME)).await; - assert_eq!(h.credentials.calls().len(), 1, "no refresh after the exit"); -} - -#[tokio::test(start_paused = true)] -async fn the_refresh_task_rewrites_the_token_before_it_expires_without_prompting() { - let h = Harness::start(Setup::default()).await; - let launched = h.launch_ok().await; - let first_expiry = launched.credential_expires_at_unix_seconds.unwrap(); - assert_eq!( - h.token(&launched.launch_id).as_deref(), - Some("gateway-token-0") - ); - - tokio::time::sleep(Duration::from_secs(CREDENTIAL_LIFETIME - 40)).await; - assert_eq!( - h.token(&launched.launch_id).as_deref(), - Some("gateway-token-1") - ); - let calls = h.credentials.calls(); - assert_eq!(calls[0], ("claude-code".to_owned(), true)); - assert_eq!( - calls[1], - ("claude-code".to_owned(), false), - "the refresh never prompts" - ); - let listed = h.listed().await; - assert!( - listed[0].credential_expires_at_unix_seconds.unwrap() > first_expiry, - "the launch reports the new expiry" - ); -} - -#[tokio::test(start_paused = true)] -async fn a_refresh_refused_by_policy_deletes_the_token_but_not_the_launch() { - let h = Harness::start(Setup::default()).await; - let launched = h.launch_ok().await; - h.credentials.fail_with(Some(( - StatusCode::CONFLICT, - "programs.claudeCode must use the gateway", - ))); - - tokio::time::sleep(Duration::from_secs(CREDENTIAL_LIFETIME - 40)).await; - - assert_eq!(h.token(&launched.launch_id), None); - assert_eq!(h.listed().await[0].status, LaunchStatus::Running); -} - -#[tokio::test(start_paused = true)] -async fn a_gateway_error_keeps_the_token_until_it_expires_then_deletes_it() { - let h = Harness::start(Setup::default()).await; - let launched = h.launch_ok().await; - h.credentials - .fail_with(Some((StatusCode::BAD_GATEWAY, "upstream down"))); - - tokio::time::sleep(Duration::from_secs(CREDENTIAL_LIFETIME - 40)).await; - assert_eq!( - h.token(&launched.launch_id).as_deref(), - Some("gateway-token-0"), - "a transient error keeps the still-valid token" - ); - - tokio::time::sleep(Duration::from_secs(120)).await; - assert_eq!( - h.token(&launched.launch_id), - None, - "an expired token is removed" + h.credentials.calls().len(), + 1, + "only the sign-in pre-flight reached the credentials" ); } @@ -3246,7 +3176,7 @@ async fn launch_logs_posture_error_as_warning() { let subscriber = tracing_subscriber::registry().with( fmt::layer() - .with_writer(move || LogWriter(logs_writer.clone())) + .with_writer(move || PostureLogWriter(logs_writer.clone())) .with_ansi(false), ); @@ -3283,9 +3213,9 @@ async fn launch_logs_posture_error_as_warning() { } /// Writer adapter for capturing tracing output -struct LogWriter(Arc>>); +struct PostureLogWriter(Arc>>); -impl std::io::Write for LogWriter { +impl std::io::Write for PostureLogWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.0.lock().unwrap().write(buf) } @@ -3295,6 +3225,241 @@ impl std::io::Write for LogWriter { } } +// ----------------------------------------------------------------- relay sessions + +/// Stub policy for testing: returns a fixed result. +struct RefusingPolicy { + status: StatusCode, + message: String, +} + +#[async_trait::async_trait] +impl crate::llm_proxy::sessions::SessionPolicy for RefusingPolicy { + async fn check(&self, _client_id: &str) -> Result<(), (StatusCode, String)> { + Err((self.status, self.message.clone())) + } +} + +#[tokio::test(start_paused = true)] +async fn a_launch_without_a_proxy_address_answers_409() { + let h = Harness::start(Setup { + proxy_address: None, + credentials_must_not_be_reached: true, + ..Setup::default() + }) + .await; + + let (status, body) = h.launch(&h.body()).await; + + assert_eq!(status, 409, "{body}"); + assert!( + body.contains("the local LLM proxy is not running (daemon.llmProxy.listen)"), + "{body}" + ); + h.assert_untouched(); + assert!(h.credentials.calls().is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn the_proxy_check_comes_after_an_invalid_cwd() { + let h = Harness::start(Setup { + proxy_address: None, + ..Setup::default() + }) + .await; + + let (status, body) = h + .launch(&h.body_with(|body| { + body["cwd"] = h.home.display().to_string().into(); + })) + .await; + + assert_eq!(status, 400, "{body}"); + assert_ne!( + body, "the local LLM proxy is not running (daemon.llmProxy.listen)", + "cwd check runs before proxy check" + ); +} + +#[tokio::test(start_paused = true)] +async fn a_refusing_session_policy_passes_its_status_and_text_through() { + let h = Harness::start(Setup { + session_policy: Arc::new(RefusingPolicy { + status: StatusCode::CONFLICT, + message: "policy says no".to_owned(), + }), + credentials_must_not_be_reached: true, + ..Setup::default() + }) + .await; + + let (status, body) = h.launch(&h.body()).await; + + assert_eq!(status, 409, "{body}"); + assert_eq!(body, "policy says no"); + h.assert_untouched(); + assert!(h.executor.spawns().is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn controller_jwt_without_claude_code_in_allowed_client_ids_is_refused() { + let config: agentdesktop_core::config::DaemonConfig = + serde_yaml::from_str( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: controllerJwt\n audience: agentgateway\n allowedClientIds: [copilot-cli]\nprograms:\n claudeCode: {}\n" + ) + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + let h = Harness::start(Setup { + session_policy: Arc::new(crate::llm_proxy::sessions::ConfigPolicy { + config, + state_dir: temp.path().to_owned(), + }), + credentials_must_not_be_reached: true, + ..Setup::default() + }) + .await; + + let (status, body) = h.launch(&h.body()).await; + + assert_eq!(status, 409, "{body}"); + assert!( + body.contains("claude-code") && body.contains("allowedClientIds"), + "{body}" + ); + h.assert_untouched(); +} + +#[tokio::test(start_paused = true)] +async fn a_successful_launch_registers_a_session_with_the_pairing_in_env() { + let h = Harness::start(Setup::default()).await; + + let launched = h.launch_ok().await; + + let now = h.credentials.clock.now(); + let sessions = h.sessions.list(now); + assert_eq!(sessions.len(), 1, "exactly one session registered"); + let session = &sessions[0]; + assert_eq!(session.id, launched.session_id); + assert_eq!(session.client_id, "claude-code"); + assert!( + session.label.starts_with("run "), + "label is 'run ': {}", + session.label + ); + + let spawn = &h.executor.spawns()[0]; + let request: serde_json::Value = + serde_json::from_str(spawn.request_json.as_deref().unwrap()).unwrap(); + let env = env_map(&request); + let pairing_header = env + .get("ANTHROPIC_CUSTOM_HEADERS") + .expect("ANTHROPIC_CUSTOM_HEADERS in env"); + assert!( + pairing_header.starts_with("x-agentdesktop-pairing: "), + "{pairing_header}" + ); + let pairing = pairing_header + .strip_prefix("x-agentdesktop-pairing: ") + .unwrap(); + + let body = serde_json::to_string(&launched).unwrap(); + assert!( + !body.contains(pairing), + "pairing is not in the 201 body: {body}" + ); + assert!( + !body.contains("credentialExpiresAtUnixSeconds"), + "no credential expiry in body: {body}" + ); +} + +#[tokio::test(start_paused = true)] +async fn an_early_exit_revokes_the_session_before_answering() { + let h = Harness::start(Setup::default()).await; + h.executor.exit_with(1); + + let (status, _) = h.launch(&h.body()).await; + + assert_eq!(status, 500); + let now = h.credentials.clock.now(); + assert_eq!(h.sessions.len(now), 0, "session revoked on early exit"); +} + +#[tokio::test(start_paused = true)] +async fn a_spawn_error_revokes_the_session() { + let h = Harness::start(Setup::default()).await; + *h.executor.error.lock().unwrap() = + Some(io::Error::new(io::ErrorKind::NotFound, "no such file")); + + let (status, _) = h.launch(&h.body()).await; + + assert_eq!(status, 500); + let now = h.credentials.clock.now(); + assert_eq!(h.sessions.len(now), 0, "session revoked on spawn error"); +} + +#[tokio::test(start_paused = true)] +async fn session_capacity_exhaustion_answers_429_before_the_credential_check() { + let h = Harness::start(Setup { + credentials_must_not_be_reached: true, + ..Setup::default() + }) + .await; + let now = h.credentials.clock.now(); + for n in 0..64 { + h.sessions + .register( + "claude-code", + &format!("s{n}"), + Duration::from_secs(3600), + now, + ) + .unwrap(); + } + + let (status, body) = h.launch(&h.body()).await; + + assert_eq!(status, 429, "{body}"); + assert!( + body.contains("too many sessions (64)") && body.contains("revoke unused ones"), + "{body}" + ); + h.assert_untouched(); + assert_eq!(h.sessions.len(now), 64, "registry count unchanged"); + assert!(h.credentials.calls().is_empty()); +} + +#[tokio::test] +async fn the_launch_log_names_the_session_once_and_never_its_pairing() { + let (logs, _guard) = capture_logs(); + let h = Harness::start(Setup::default()).await; + + let launched = h.launch_ok().await; + + let spawn = &h.executor.spawns()[0]; + let request: serde_json::Value = + serde_json::from_str(spawn.request_json.as_deref().unwrap()).unwrap(); + let env = env_map(&request); + let pairing_header = env + .get("ANTHROPIC_CUSTOM_HEADERS") + .expect("ANTHROPIC_CUSTOM_HEADERS in env"); + let pairing = pairing_header + .strip_prefix("x-agentdesktop-pairing: ") + .expect("header starts with x-agentdesktop-pairing: "); + + let log_text = logs.wait_for_text(&launched.session_id).await; + let session_count = log_text.matches(&launched.session_id).count(); + assert_eq!( + session_count, 1, + "session_id appears exactly once in the log: {}", + launched.session_id + ); + assert!( + !log_text.contains(pairing), + "pairing value never appears in the log" + ); +} + // ----------------------------------------------------------------- Windows only #[cfg(windows)] diff --git a/crates/agent/src/launch/writer_tests.rs b/crates/agent/src/launch/writer_tests.rs index b13199e6..4c8acbf8 100644 --- a/crates/agent/src/launch/writer_tests.rs +++ b/crates/agent/src/launch/writer_tests.rs @@ -13,21 +13,21 @@ fn the_user_mode_pipe_admits_system_and_the_daemon_user_only() { ); } -/// The read-only grant for `credential\` is only honoured when the directory -/// exists, so it is created before the request is built. +/// The grants are only honoured when the directories exist, so the launch +/// directory and the box's `tmp` are created before the request is built; +/// no credential directory exists any more. #[test] -fn the_credential_directory_exists_before_the_request_is_written() { +fn the_launch_directories_exist_before_the_request_is_written() { let temp = tempfile::tempdir().unwrap(); let launch_dir = temp.path().join("launches").join("0123456789abcdef"); - create_launch_dir(&launch_dir, "gateway-token").unwrap(); + create_launch_dir(&launch_dir).unwrap(); - assert!(launch_dir.join("credential").is_dir()); - assert_eq!( - std::fs::read_to_string(launch_dir.join("credential").join("token")).unwrap(), - "gateway-token" - ); assert!(launch_dir.join("tmp").is_dir()); + assert!( + !launch_dir.join("credential").exists(), + "no credential directory is created" + ); assert!( !launch_dir.join("request.json").exists(), "the request is built after the directories it grants exist" @@ -90,7 +90,9 @@ fn the_executable_configuration_rule_covers_git_commondir_and_the_ide_directory_ ), git_dir: None, program_files_claude: None, - gateway_url: "https://gateway.example.com/".into(), + egress: agentdesktop_core::config::ContainmentEgress::Allow, + relay_base_url: "http://127.0.0.1:4000/sandbox".into(), + pairing: "test-pairing".into(), base_env: BTreeMap::new(), exists: &exists, is_dir: &is_dir, @@ -331,11 +333,21 @@ mod handler { managed_settings_error: None, }), daemon_sid: None, + sessions: crate::llm_proxy::sessions::SessionRegistry::default(), + proxy_address: Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4000))), + session_policy: Arc::new(crate::llm_proxy::sessions::ConfigPolicy { + config: serde_yaml::from_str( + "llmGateway: {url: 'https://gw', authentication: {type: oidc, issuer: 'https://kc', clientId: 'agentdesktop'}}", + ) + .unwrap(), + state_dir: std::env::temp_dir(), + }), + egress: agentdesktop_core::config::ContainmentEgress::default(), } } #[tokio::test(start_paused = true)] - async fn the_credential_directory_is_granted_read_only_to_the_box() { + async fn no_credential_directory_is_granted_to_the_box() { let temp = tempfile::tempdir().unwrap(); let root = std::fs::canonicalize(temp.path()).unwrap(); let home = root.join("home"); @@ -368,10 +380,6 @@ mod handler { serde_json::from_str(recorder.request_json.lock().unwrap().as_deref().unwrap()) .unwrap(); let launch_dir = state_dir.join("launches").join(&launch.launch_id); - let credential_dir = launch_dir - .join("credential") - .to_string_lossy() - .replace('/', "\\"); let readonly: Vec = request["filesystem"]["readonlyPaths"] .as_array() .unwrap() @@ -379,10 +387,13 @@ mod handler { .map(|v| v.as_str().unwrap().to_owned()) .collect(); assert!( - readonly.contains(&credential_dir), - "credential directory {credential_dir} must be granted; got {readonly:?}" + !readonly.iter().any(|path| path.contains("credential")), + "no credential path is granted; got {readonly:?}" + ); + assert!( + !launch_dir.join("credential").exists(), + "no credential directory is created" ); - assert!(launch_dir.join("credential").join("token").is_file()); // `~\.claude` did not exist before the launch; its entries need it. assert!( *recorder.home_claude_existed.lock().unwrap(), @@ -418,7 +429,9 @@ mod handler { ), git_dir: None, program_files_claude: None, - gateway_url: "https://gateway.example.com/".into(), + egress: agentdesktop_core::config::ContainmentEgress::Allow, + relay_base_url: "http://127.0.0.1:4000/sandbox".into(), + pairing: "test-pairing".into(), base_env: BTreeMap::new(), exists: &exists, is_dir: &is_dir, @@ -540,7 +553,9 @@ fn the_environment_is_a_list_of_key_value_entries_and_a_missing_workspace_claude ), git_dir: None, program_files_claude: None, - gateway_url: "https://gateway.example.com/".into(), + egress: agentdesktop_core::config::ContainmentEgress::Allow, + relay_base_url: "http://127.0.0.1:4000/sandbox".into(), + pairing: "test-pairing".into(), base_env: BTreeMap::from([("SystemRoot".to_owned(), r"C:\Windows".to_owned())]), exists: &exists, is_dir: &is_dir, @@ -587,7 +602,9 @@ fn the_box_environment_has_home_set_to_the_daemon_home_when_daemon_env_has_no_ho ), git_dir: None, program_files_claude: None, - gateway_url: "https://gateway.example.com/".into(), + egress: agentdesktop_core::config::ContainmentEgress::Allow, + relay_base_url: "http://127.0.0.1:4000/sandbox".into(), + pairing: "test-pairing".into(), base_env: BTreeMap::new(), exists: &exists, is_dir: &is_dir, diff --git a/crates/agent/src/llm_proxy/mod.rs b/crates/agent/src/llm_proxy/mod.rs index a2067006..2af8e7b2 100644 --- a/crates/agent/src/llm_proxy/mod.rs +++ b/crates/agent/src/llm_proxy/mod.rs @@ -1410,7 +1410,15 @@ async fn forward( // fetches and the others find its credential in the cache. let fetch = { let state = state.clone(); - let effective = effective.clone(); + // On the sandbox route, the daemon never starts an interactive + // sign-in on behalf of a box: if the stored session is + // unusable, the box's request fails and the user signs in on + // the host. + let effective = if session.is_some() { + api::non_interactive(effective.clone()) + } else { + effective.clone() + }; let client_id = client_id.clone(); let cache = cache.clone(); let cache_key = cache_key.clone(); @@ -1448,7 +1456,19 @@ async fn forward( format!("credential fetch failed: {error}"), )) })? - .map_err(ProxyError::credential)?; + .map_err(|(status, message)| { + // On the sandbox route, a 401 means deviceAuthorization is + // false or the token is missing/expired. The endpoint for + // device flow (agentdesktop-headless login) refuses with 424 + // in that configuration, so the honest guidance is to sign in + // on the host session (the daemon's own environment). + let message = if session.is_some() && status == StatusCode::UNAUTHORIZED { + "LLM gateway sign-in required. Sign in on the host (the machine running the daemon), not inside the sandbox.".to_owned() + } else { + message + }; + ProxyError::credential((status, message)) + })?; let (credential, deadline, cached) = fetched; from_cache = cached; fetched_now = !cached; @@ -4954,4 +4974,78 @@ mod tests { .await .unwrap(); } + + // When deviceAuthorization: false and no stored token exists, a sandbox + // request with a valid pairing returns 401 (not 502) because the sandbox + // route transforms the config to non-interactive before credential + // acquisition. Without the transform, discovery would attempt to contact + // the unreachable issuer and return 502. This test verifies mod.rs:1386. + #[tokio::test] + async fn sandbox_route_returns_401_when_oidc_device_authorization_is_false_and_no_stored_token() + { + tokio::time::timeout(Duration::from_secs(15), async { + let dir = tempfile::tempdir().unwrap(); + // No stored token (unlike request_source_state which stores one). + // Use an unreachable loopback HTTPS address. + let unreachable_issuer = "https://127.0.0.1:1/"; + let config = parse_daemon(&format!( + "llmGateway:\n url: http://127.0.0.1:9999\n authentication:\n type: oidc\n issuer: {unreachable_issuer}\n clientId: test-client\n deviceAuthorization: false\n allowInsecure: true\n" + )).unwrap(); + let (_, discovery) = watch::channel(Arc::new(Discovery { + agents: vec![], + model_runtimes: vec![], + })); + let state = AppState { + controller_status: None, + config, + daemon_info: DaemonInfo { + version: "test".into(), + scope: DaemonScope::User, + config_path: String::new(), + state_directory: String::new(), + inventory_interval: Duration::from_secs(60), + controller: None, + llm_proxy: None, + usage_receiver: None, + intercept: None, + containment: None, + }, + discovery, + enrollment: EnrollmentState::new(false), + state_dir: dir.path().to_owned(), + oidc_callback_listen: None, + telemetry: None, + logout: None, + usage: None, + programs: None, + intercept: None, + }; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + + let request = post_with( + proxy_address, + "/sandbox/v1/messages", + Some(&session.pairing), + b"{}", + ); + let response = client.request(request).await.unwrap(); + let (status, code, _) = refusal(response).await; + // Must be 401 (sign-in required), not 502 (bad gateway/discovery error). + assert_eq!(status, StatusCode::UNAUTHORIZED, "non-interactive acquisition returns 401"); + assert_eq!(code, "agentdesktop_credential", "the error is credential-related"); + + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } } diff --git a/crates/agent/src/llm_proxy/sessions.rs b/crates/agent/src/llm_proxy/sessions.rs index ecd261ce..8fb8e2f6 100644 --- a/crates/agent/src/llm_proxy/sessions.rs +++ b/crates/agent/src/llm_proxy/sessions.rs @@ -33,6 +33,11 @@ const MAX_TTL_SECONDS: u64 = 86_400; /// Unexpired sessions a daemon holds at most: a launcher that leaks sessions /// fails loudly instead of growing the registry. pub(crate) const MAX_SESSIONS: usize = 64; + +/// The one message for a full registry, shared with the launch routes. +pub(crate) fn too_many_sessions_message() -> String { + format!("too many sessions ({MAX_SESSIONS}); revoke unused ones") +} /// Longest a label may be, in Unicode scalar values. const MAX_LABEL_CHARS: usize = 128; @@ -386,10 +391,7 @@ async fn create_session( super::now_unix(), ) .map_err(|error| match error { - SessionError::Full => ( - StatusCode::TOO_MANY_REQUESTS, - format!("too many sessions ({MAX_SESSIONS}); revoke unused ones"), - ), + SessionError::Full => (StatusCode::TOO_MANY_REQUESTS, too_many_sessions_message()), // Both were checked above; a race with the registry's own // validation would be a bug, reported as such. SessionError::InvalidClientId | SessionError::InvalidLabel => ( diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 49eee45d..80f308e0 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -142,6 +142,9 @@ pub struct ContainmentStartupConfig { /// startup only (restart the daemon after changing it). Requires user /// mode; ignored with `--once` and `--dry-run`. pub executor: PathBuf, + /// Outbound network for the sandboxed program: `allow` (default) or `deny`. With `deny` the box reaches only loopback: model requests go through the local LLM proxy, and git over HTTPS and package downloads fail. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`. + #[serde(default, skip_serializing_if = "ContainmentEgress::is_default")] + pub egress: ContainmentEgress, } /// Checks `daemon.containment`. With `windows == true` the executor must be an @@ -371,6 +374,24 @@ impl ProxyUnavailable { } } +/// Outbound network posture for sandboxed programs. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ContainmentEgress { + /// Plain outbound access; model requests still go through the local LLM proxy. + #[default] + Allow, + /// Loopback only. + Deny, +} + +impl ContainmentEgress { + fn is_default(&self) -> bool { + *self == Self::default() + } +} + /// GitHub App user authorization for Copilot requests through the local proxy. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -2469,6 +2490,7 @@ programs: fn containment(executor: &str) -> super::ContainmentStartupConfig { super::ContainmentStartupConfig { executor: executor.into(), + egress: super::ContainmentEgress::default(), } } @@ -2808,4 +2830,82 @@ programs: assert_eq!(claude.allow_managed_hooks_only, Some(true)); assert!(claude.settings.contains_key("companyAnnouncements")); } + + // AC 1: ContainmentEgress tests + + #[test] + fn containment_egress_allow_parses() { + let config = super::parse_local_daemon( + "daemon:\n containment:\n executor: 'C:\\mxc\\wxc-exec.exe'\n egress: allow\n", + ) + .expect("allow parses"); + let egress = config.daemon.unwrap().containment.unwrap().egress; + assert_eq!(egress, super::ContainmentEgress::Allow); + } + + #[test] + fn containment_egress_deny_parses() { + let config = super::parse_local_daemon( + "daemon:\n containment:\n executor: 'C:\\mxc\\wxc-exec.exe'\n egress: deny\n", + ) + .expect("deny parses"); + let egress = config.daemon.unwrap().containment.unwrap().egress; + assert_eq!(egress, super::ContainmentEgress::Deny); + } + + #[test] + fn containment_egress_absent_defaults_to_allow() { + let config = super::parse_local_daemon( + "daemon:\n containment:\n executor: 'C:\\mxc\\wxc-exec.exe'\n", + ) + .expect("absent defaults"); + let egress = config.daemon.unwrap().containment.unwrap().egress; + assert_eq!(egress, super::ContainmentEgress::Allow); + } + + #[test] + fn containment_egress_rejects_deny_capitalized() { + assert!( + super::parse_local_daemon( + "daemon:\n containment:\n executor: /x/wxc-exec\n egress: Deny\n" + ) + .is_err(), + "Deny capitalized is rejected" + ); + } + + #[test] + fn containment_egress_rejects_block() { + assert!( + super::parse_local_daemon( + "daemon:\n containment:\n executor: /x/wxc-exec\n egress: block\n" + ) + .is_err(), + "block is rejected" + ); + } + + #[test] + fn containment_egress_unset_not_serialized() { + let cfg = super::ContainmentStartupConfig { + executor: std::path::PathBuf::from("/x/wxc-exec"), + egress: super::ContainmentEgress::Allow, + }; + let json = serde_json::to_value(&cfg).unwrap(); + assert!( + json.get("egress").is_none(), + "default egress is not serialized" + ); + + let cfg_deny = super::ContainmentStartupConfig { + executor: std::path::PathBuf::from("/x/wxc-exec"), + egress: super::ContainmentEgress::Deny, + }; + let json_deny = serde_json::to_value(&cfg_deny).unwrap(); + assert_eq!( + json_deny.get("egress").and_then(|v| v.as_str()), + Some("deny"), + "deny egress is serialized" + ); + } } diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 1b4abb6e..a9029d9b 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -215,7 +215,6 @@ pub struct Launch { pub exit_code: Option, pub started_at_unix_seconds: u64, pub ended_at_unix_seconds: Option, - pub credential_expires_at_unix_seconds: Option, /// True once the executable configuration changed while this launch ran /// (set by the periodic check or the exit check; never cleared). #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -229,6 +228,10 @@ pub struct Launch { /// Whether `allowManagedMcpServersOnly` was enforced when this launch started. #[serde(default)] pub managed_mcp_locked: bool, + /// The relay session this launch runs on; revoked when the box exits. + /// Empty when an older daemon answers without the field. + #[serde(default)] + pub session_id: String, } /// State of the daemon's receiver for gateway usage. @@ -549,25 +552,6 @@ mod tests { assert_eq!(newer.last_error, Some(ControllerConnectionError::Unknown)); } - #[test] - fn program_choice_is_tagged_by_mode() { - let selected = ProgramChoice::Selected { - programs: ["codex".to_owned()].into(), - }; - assert_eq!( - serde_json::to_value(&selected).unwrap(), - serde_json::json!({ "mode": "selected", "programs": ["codex"] }) - ); - assert_eq!( - serde_json::from_str::(r#"{"mode":"none"}"#).unwrap(), - ProgramChoice::None - ); - assert!(ProgramChoice::default().includes("claude-code")); - assert!(!ProgramChoice::None.includes("claude-code")); - assert!(selected.includes("codex")); - assert!(!selected.includes("claude-code")); - } - #[test] fn containment_info_is_camel_case_and_omits_an_absent_error() { let available = ContainmentInfo { @@ -634,7 +618,7 @@ mod tests { exit_code: None, started_at_unix_seconds: 1_700_000_000, ended_at_unix_seconds: None, - credential_expires_at_unix_seconds: Some(1_700_003_600), + session_id: "test-session-id".into(), integrity_drift: false, managed_settings_source: None, managed_hooks_locked: false, @@ -645,7 +629,11 @@ mod tests { assert_eq!(json["containerId"], "agentdesktop-0123456789abcdef"); assert_eq!(json["status"], "running"); assert_eq!(json["startedAtUnixSeconds"], 1_700_000_000u64); - assert_eq!(json["credentialExpiresAtUnixSeconds"], 1_700_003_600u64); + assert_eq!(json["sessionId"], "test-session-id"); + assert!( + json.get("credentialExpiresAtUnixSeconds").is_none(), + "credentialExpiresAtUnixSeconds is removed" + ); assert!(json.get("integrityDrift").is_none()); assert_eq!(json["managedSettingsSource"], serde_json::Value::Null); assert_eq!(json["managedHooksLocked"], false); @@ -666,116 +654,6 @@ mod tests { assert_eq!(serde_json::from_value::(json).unwrap(), exited); } - #[test] - fn session_request_uses_camel_case_and_ttl_seconds_is_optional() { - let request = SessionRequest { - client_id: "claude-code".into(), - label: "lab".into(), - ttl_seconds: None, - }; - assert_eq!( - serde_json::to_value(&request).unwrap(), - serde_json::json!({"clientId": "claude-code", "label": "lab"}) - ); - let with_ttl = SessionRequest { - ttl_seconds: Some(120), - ..request.clone() - }; - assert_eq!( - serde_json::to_value(&with_ttl).unwrap(), - serde_json::json!({"clientId": "claude-code", "label": "lab", "ttlSeconds": 120}) - ); - - let parsed: SessionRequest = - serde_json::from_str(r#"{"clientId":"claude-code","label":"lab"}"#).unwrap(); - assert_eq!(parsed, request); - let parsed: SessionRequest = - serde_json::from_str(r#"{"clientId":"claude-code","label":"lab","ttlSeconds":120}"#) - .unwrap(); - assert_eq!(parsed, with_ttl); - // A newer client may send fields this build does not know. - let parsed: SessionRequest = serde_json::from_str( - r#"{"clientId":"claude-code","label":"lab","somethingNew":{"a":1}}"#, - ) - .unwrap(); - assert_eq!(parsed, request); - // The wire names are camelCase only. - assert!( - serde_json::from_str::(r#"{"client_id":"claude-code","label":"lab"}"#) - .is_err() - ); - } - - #[test] - fn session_created_round_trips_with_camel_case_field_names() { - let created = SessionCreated { - id: "0123456789abcdef".into(), - pairing: "p".repeat(43), - client_id: "claude-code".into(), - label: "lab".into(), - created_at_unix_seconds: 1_000, - expires_at_unix_seconds: 29_800, - base_url: "http://127.0.0.1:4000/sandbox".into(), - }; - let json = serde_json::to_value(&created).unwrap(); - assert_eq!( - json, - serde_json::json!({ - "id": "0123456789abcdef", - "pairing": "p".repeat(43), - "clientId": "claude-code", - "label": "lab", - "createdAtUnixSeconds": 1_000, - "expiresAtUnixSeconds": 29_800, - "baseUrl": "http://127.0.0.1:4000/sandbox", - }) - ); - assert_eq!( - serde_json::from_value::(json).unwrap(), - created - ); - } - - #[test] - fn session_created_debug_redacts_the_pairing() { - let created = SessionCreated { - id: "0123456789abcdef".into(), - pairing: "secret-pairing-value".into(), - client_id: "claude-code".into(), - label: "lab".into(), - created_at_unix_seconds: 1_000, - expires_at_unix_seconds: 29_800, - base_url: "http://127.0.0.1:4000/sandbox".into(), - }; - let debug = format!("{created:?}"); - assert!(debug.contains(""), "{debug}"); - assert!(!debug.contains("secret-pairing-value"), "{debug}"); - } - - #[test] - fn session_info_has_no_pairing_and_uses_camel_case() { - let info = SessionInfo { - id: "0123456789abcdef".into(), - client_id: "claude-code".into(), - label: "lab".into(), - created_at_unix_seconds: 1_000, - expires_at_unix_seconds: 29_800, - }; - let json = serde_json::to_value(&info).unwrap(); - assert_eq!( - json, - serde_json::json!({ - "id": "0123456789abcdef", - "clientId": "claude-code", - "label": "lab", - "createdAtUnixSeconds": 1_000, - "expiresAtUnixSeconds": 29_800, - }) - ); - assert_eq!(serde_json::from_value::(json).unwrap(), info); - assert_eq!(info.clone(), info); - } - #[test] fn launch_integrity_drift_round_trip() { let without_drift = Launch { @@ -787,7 +665,7 @@ mod tests { exit_code: None, started_at_unix_seconds: 1_700_000_000, ended_at_unix_seconds: None, - credential_expires_at_unix_seconds: None, + session_id: "test-session-id".into(), integrity_drift: false, managed_settings_source: None, managed_hooks_locked: false, @@ -824,7 +702,7 @@ mod tests { exit_code: None, started_at_unix_seconds: 1_700_000_000, ended_at_unix_seconds: None, - credential_expires_at_unix_seconds: Some(1_700_003_600), + session_id: "test-session-id".into(), managed_settings_source: Some(ManagedSettingsSource::File), managed_hooks_locked: true, managed_mcp_locked: false, @@ -847,7 +725,7 @@ mod tests { exit_code: None, started_at_unix_seconds: 1_700_000_000, ended_at_unix_seconds: None, - credential_expires_at_unix_seconds: None, + session_id: "test-session-id".into(), managed_settings_source: None, managed_hooks_locked: false, managed_mcp_locked: false, @@ -901,4 +779,133 @@ mod tests { let again: ContainmentInfo = serde_json::from_value(json).unwrap(); assert_eq!(again, info_without_fields); } + + #[test] + fn session_request_uses_camel_case_and_ttl_seconds_is_optional() { + let request = SessionRequest { + client_id: "claude-code".into(), + label: "lab".into(), + ttl_seconds: None, + }; + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({"clientId": "claude-code", "label": "lab"}) + ); + let with_ttl = SessionRequest { + ttl_seconds: Some(120), + ..request.clone() + }; + assert_eq!( + serde_json::to_value(&with_ttl).unwrap(), + serde_json::json!({"clientId": "claude-code", "label": "lab", "ttlSeconds": 120}) + ); + + let parsed: SessionRequest = + serde_json::from_str(r#"{"clientId":"claude-code","label":"lab"}"#).unwrap(); + assert_eq!(parsed, request); + let parsed: SessionRequest = + serde_json::from_str(r#"{"clientId":"claude-code","label":"lab","ttlSeconds":120}"#) + .unwrap(); + assert_eq!(parsed, with_ttl); + // A newer client may send fields this build does not know. + let parsed: SessionRequest = serde_json::from_str( + r#"{"clientId":"claude-code","label":"lab","somethingNew":{"a":1}}"#, + ) + .unwrap(); + assert_eq!(parsed, request); + // The wire names are camelCase only. + assert!( + serde_json::from_str::(r#"{"client_id":"claude-code","label":"lab"}"#) + .is_err() + ); + } + + #[test] + fn session_created_round_trips_with_camel_case_field_names() { + let created = SessionCreated { + id: "0123456789abcdef".into(), + pairing: "p".repeat(43), + client_id: "claude-code".into(), + label: "lab".into(), + created_at_unix_seconds: 1_000, + expires_at_unix_seconds: 29_800, + base_url: "http://127.0.0.1:4000/sandbox".into(), + }; + let json = serde_json::to_value(&created).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "id": "0123456789abcdef", + "pairing": "p".repeat(43), + "clientId": "claude-code", + "label": "lab", + "createdAtUnixSeconds": 1_000, + "expiresAtUnixSeconds": 29_800, + "baseUrl": "http://127.0.0.1:4000/sandbox", + }) + ); + assert_eq!( + serde_json::from_value::(json).unwrap(), + created + ); + } + + #[test] + fn session_created_debug_redacts_the_pairing() { + let created = SessionCreated { + id: "0123456789abcdef".into(), + pairing: "secret-pairing-value".into(), + client_id: "claude-code".into(), + label: "lab".into(), + created_at_unix_seconds: 1_000, + expires_at_unix_seconds: 29_800, + base_url: "http://127.0.0.1:4000/sandbox".into(), + }; + let debug = format!("{created:?}"); + assert!(debug.contains(""), "{debug}"); + assert!(!debug.contains("secret-pairing-value"), "{debug}"); + } + + #[test] + fn session_info_has_no_pairing_and_uses_camel_case() { + let info = SessionInfo { + id: "0123456789abcdef".into(), + client_id: "claude-code".into(), + label: "lab".into(), + created_at_unix_seconds: 1_000, + expires_at_unix_seconds: 29_800, + }; + let json = serde_json::to_value(&info).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "id": "0123456789abcdef", + "clientId": "claude-code", + "label": "lab", + "createdAtUnixSeconds": 1_000, + "expiresAtUnixSeconds": 29_800, + }) + ); + assert_eq!(serde_json::from_value::(json).unwrap(), info); + assert_eq!(info.clone(), info); + } + + #[test] + fn program_choice_is_tagged_by_mode() { + let selected = ProgramChoice::Selected { + programs: ["codex".to_owned()].into(), + }; + assert_eq!( + serde_json::to_value(&selected).unwrap(), + serde_json::json!({ "mode": "selected", "programs": ["codex"] }) + ); + assert_eq!( + serde_json::from_str::(r#"{"mode":"none"}"#).unwrap(), + ProgramChoice::None + ); + assert!(ProgramChoice::default().includes("claude-code")); + assert!(!ProgramChoice::None.includes("claude-code")); + assert!(selected.includes("codex")); + assert!(!selected.includes("claude-code")); + } } diff --git a/schema/daemon-config.json b/schema/daemon-config.json index 3dfb4299..5c6bf35a 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -173,10 +173,29 @@ }, "additionalProperties": false }, + "ContainmentEgress": { + "description": "Outbound network posture for sandboxed programs.", + "oneOf": [ + { + "description": "Plain outbound access; model requests still go through the local LLM proxy.", + "type": "string", + "const": "allow" + }, + { + "description": "Loopback only.", + "type": "string", + "const": "deny" + } + ] + }, "ContainmentStartupConfig": { "description": "Sandboxed launch settings.", "type": "object", "properties": { + "egress": { + "description": "Outbound network for the sandboxed program: `allow` (default) or `deny`. With `deny` the box reaches only loopback: model requests go through the local LLM proxy, and git over HTTPS and package downloads fail. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`.", + "$ref": "#/$defs/ContainmentEgress" + }, "executor": { "description": "Windows only. Absolute path to MXC `wxc-exec.exe`; no default. Read at\nstartup only (restart the daemon after changing it). Requires user\nmode; ignored with `--once` and `--dry-run`.", "type": "string" diff --git a/schema/daemon-config.md b/schema/daemon-config.md index cd8b6d5c..84ddb9b5 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -16,6 +16,7 @@ |`daemon.codex`|object|Codex paths.| |`daemon.codex.config`|string|Configuration file.| |`daemon.containment`|object|Sandboxed launch (`agentdesktop run`).| +|`daemon.containment.egress`|enum|Outbound network for the sandboxed program: `allow` (default) or `deny`. With `deny` the box reaches only loopback: model requests go through the local LLM proxy, and git over HTTPS and package downloads fail. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`.
Possible values: `allow`, `deny`.| |`daemon.containment.executor`|string|Windows only. Absolute path to MXC `wxc-exec.exe`; no default. Read at
startup only (restart the daemon after changing it). Requires user
mode; ignored with `--once` and `--dry-run`.| |`daemon.copilot`|object|GitHub Copilot CLI paths (`config` = the `providers.json` to manage;
defaults to `COPILOT_PROVIDERS_CONFIG`, `$COPILOT_HOME/providers.json`,
then `~/.copilot/providers.json`).| |`daemon.copilot.config`|string|Configuration file.| From a945817bd2f9328dcd68be7b88687f3812359309 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Tue, 20 Oct 2026 18:49:25 +0200 Subject: [PATCH 07/19] launch: build requests from a neutral plan through an engine The agent crate now builds sandboxed launches from a neutral `LaunchPlan` through an `Engine` trait, with `mxc` as the only and default engine. Nothing observable changes: every config file accepted today stays accepted (the new `engine` key is optional), the request bytes are identical (pinned byte-for-byte by a snapshot test against the pre-move pipeline), and the API is unchanged except that daemon-info's containment block now names the engine. - `launch/plan.rs`: `LaunchPlan` holds the resolved launch (program, args, window title, cwd, env, path lists, egress); `plan()` carries all request building except the MXC envelope. - `launch/engine/`: `trait Engine` (`name`, `container_id`, `request`); `MxcEngine` owns the MXC request types and the envelope. - `launch/profile.rs`: the Claude Code launch profile as data; the hard-coded lists leave `launch/mod.rs`, pinned by profile-equivalence tests. - `daemon.containment.engine: mxc` (optional; absent means `mxc`, an unknown value refuses startup, and an older daemon refuses a config that sets it). The single-variant key lands now so configs written today stay valid when a second engine arrives. Schema regenerated. Co-Authored-By: Claude Fable 5 --- README.md | 5 + crates/agent/src/daemon.rs | 15 + crates/agent/src/launch/engine/mod.rs | 19 + crates/agent/src/launch/engine/mxc.rs | 148 ++++++ crates/agent/src/launch/host.rs | 32 +- crates/agent/src/launch/integrity.rs | 104 ++--- crates/agent/src/launch/mod.rs | 265 ++++------- crates/agent/src/launch/plan.rs | 27 ++ crates/agent/src/launch/profile.rs | 100 ++++ crates/agent/src/launch/tests.rs | 563 ++++++++++++++++++++++- crates/agent/src/launch/writer_tests.rs | 4 + crates/core/src/config.rs | 59 +++ crates/core/src/model.rs | 8 +- frontend/desktop/src/stories/fixtures.ts | 1 + frontend/desktop/src/types.ts | 2 + schema/daemon-config.json | 14 + schema/daemon-config.md | 1 + 17 files changed, 1100 insertions(+), 267 deletions(-) create mode 100644 crates/agent/src/launch/engine/mod.rs create mode 100644 crates/agent/src/launch/engine/mxc.rs create mode 100644 crates/agent/src/launch/plan.rs create mode 100644 crates/agent/src/launch/profile.rs diff --git a/README.md b/README.md index c5fbae3e..c7e33be4 100644 --- a/README.md +++ b/README.md @@ -700,11 +700,16 @@ daemon: containment: executor: C:\Program Files\mxc\x64\wxc-exec.exe # egress: deny # opt-in: the box loses direct outbound network + # engine: mxc # the sandbox engine; `mxc` is the only value ``` `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: diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index c62d2c4a..ec5df781 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -103,6 +103,8 @@ struct ResolvedDaemonArgs { 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 { @@ -257,6 +259,7 @@ impl DaemonArgs { containment, claude_settings_overridden, containment_egress: config::ContainmentEgress::default(), + containment_engine: config::ContainmentEngine::default(), }); } @@ -360,6 +363,11 @@ impl DaemonArgs { .as_ref() .map(|c| c.egress) .unwrap_or_default(), + containment_engine: startup + .containment + .as_ref() + .map(|c| c.engine) + .unwrap_or_default(), }) } } @@ -556,12 +564,17 @@ where } 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) => { @@ -747,6 +760,7 @@ where .and_then(|listener| listener.local_addr().ok()), session_policy, egress: args.containment_egress, + engine, }; let app = api::router(state.clone()) .merge(llm_proxy::sessions::session_router(session_api)) @@ -2467,6 +2481,7 @@ programs: containment: Some(config::ContainmentStartupConfig { executor: PathBuf::from(r"C:\mxc\x64\wxc-exec.exe"), egress: config::ContainmentEgress::default(), + engine: config::ContainmentEngine::default(), }), ..Default::default() } 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..e275fedb --- /dev/null +++ b/crates/agent/src/launch/engine/mxc.rs @@ -0,0 +1,148 @@ +//! MXC engine: Windows AppContainer request generation. + +use anyhow::{Context, Result}; +use serde::Serialize; + +use super::Engine; +use crate::launch::{plan::LaunchPlan, 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 = format!( + "cmd.exe /d /v:off /c start \"{}\" /WAIT /D \"{cwd}\" \"{}\"", + plan.window_title, + win(&plan.program) + ); + for arg in &plan.args { + command_line.push(' '); + command_line.push_str(arg); + } + 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/host.rs b/crates/agent/src/launch/host.rs index 8086cc80..62fd5ae9 100644 --- a/crates/agent/src/launch/host.rs +++ b/crates/agent/src/launch/host.rs @@ -20,23 +20,6 @@ use super::{ }; use crate::api; -/// Environment variables copied from the daemon into the box. -const BASE_ENV: [&str; 13] = [ - "SystemRoot", - "windir", - "SystemDrive", - "ComSpec", - "PATHEXT", - "USERPROFILE", - "HOMEDRIVE", - "HOMEPATH", - "APPDATA", - "LOCALAPPDATA", - "ProgramFiles", - "ProgramData", - "USERNAME", -]; - /// Directories a workspace may never be under, by environment variable. const SYSTEM_DIRS: [&str; 4] = [ "SystemRoot", @@ -45,10 +28,6 @@ const SYSTEM_DIRS: [&str; 4] = [ "ProgramData", ]; -/// Directories under the home the box must never see; the workspace may not -/// be under them either. -const DENIED_HOME_DIRS: [&str; 4] = [".ssh", ".aws", ".azure", ".kube"]; - /// The daemon's user, as the process environment describes it. pub struct HostEnvironment { home: PathBuf, @@ -104,7 +83,8 @@ impl LaunchHost for HostEnvironment { } fn base_env(&self) -> BTreeMap { - BASE_ENV + super::profile::CLAUDE_CODE + .base_env_keys .iter() .filter_map(|name| { std::env::var(name) @@ -122,8 +102,14 @@ impl LaunchHost for HostEnvironment { .iter() .filter_map(|name| std::env::var_os(name).map(PathBuf::from)) .collect(), - denied: DENIED_HOME_DIRS + // Directories under the home the box must never see; the + // workspace may not be under them either. These are the profile's + // top-level denied secrets (the `.claude` entries are guarded by + // the workspace rules separately). + denied: super::profile::CLAUDE_CODE + .denied_secrets .iter() + .filter(|entry| !entry.contains('\\')) .map(|name| self.home.join(name)) .collect(), app_data: ["APPDATA", "LOCALAPPDATA"] diff --git a/crates/agent/src/launch/integrity.rs b/crates/agent/src/launch/integrity.rs index 2883d7cd..7a26fccf 100644 --- a/crates/agent/src/launch/integrity.rs +++ b/crates/agent/src/launch/integrity.rs @@ -701,62 +701,58 @@ pub fn verify( } /// Return the list of executable-config paths to check. -/// Every executable-config entry of `build_request`, plus workspace `.claude` and `.git` (identity only). +/// Every executable-config entry of the launch profile, plus workspace +/// `.claude` and `.git` (identity only). pub fn integrity_paths(home: &Path, cwd: &Path) -> Vec { - vec![ - IntegrityRoot { - path: home.join(".claude").join("settings.json"), + let profile = &super::profile::CLAUDE_CODE; + let claude_dir = home.join(".claude"); + let workspace_claude = cwd.join(".claude"); + let git = cwd.join(".git"); + // The workspace entries interleave with the two identity-only folders in + // the order the watcher has always reported them. + let mut roots: Vec = profile + .home_executable_config + .iter() + .map(|entry| IntegrityRoot { + path: claude_dir.join(entry), mode: RootMode::Recursive, - }, - IntegrityRoot { - path: home.join(".claude").join("hooks"), - mode: RootMode::Recursive, - }, - IntegrityRoot { - path: home.join(".claude").join("plugins"), - mode: RootMode::Recursive, - }, - IntegrityRoot { - path: cwd.join(".claude"), - mode: RootMode::IdentityOnly, - }, - IntegrityRoot { - path: cwd.join(".claude").join("settings.json"), - mode: RootMode::Recursive, - }, - IntegrityRoot { - path: cwd.join(".claude").join("settings.local.json"), - mode: RootMode::Recursive, - }, - IntegrityRoot { - path: cwd.join(".claude").join("hooks"), - mode: RootMode::Recursive, - }, - IntegrityRoot { - path: cwd.join(".mcp.json"), - mode: RootMode::Recursive, - }, - IntegrityRoot { - path: cwd.join(".git"), - mode: RootMode::IdentityOnly, - }, - IntegrityRoot { - path: cwd.join("HEAD"), - mode: RootMode::Recursive, - }, - IntegrityRoot { - path: cwd.join(".git").join("config"), - mode: RootMode::Recursive, - }, - IntegrityRoot { - path: cwd.join(".git").join("hooks"), - mode: RootMode::Recursive, - }, - IntegrityRoot { - path: cwd.join(".git").join("commondir"), - mode: RootMode::Recursive, - }, - ] + }) + .collect(); + roots.push(IntegrityRoot { + path: workspace_claude.clone(), + mode: RootMode::IdentityOnly, + }); + roots.extend( + profile + .workspace_claude_entries + .iter() + .map(|entry| IntegrityRoot { + path: workspace_claude.join(entry), + mode: RootMode::Recursive, + }), + ); + roots.push(IntegrityRoot { + path: cwd.join(profile.mcp_json), + mode: RootMode::Recursive, + }); + roots.push(IntegrityRoot { + path: git.clone(), + mode: RootMode::IdentityOnly, + }); + roots.push(IntegrityRoot { + path: cwd.join(profile.workspace_head), + mode: RootMode::Recursive, + }); + roots.extend( + profile + .git_executable_config + .iter() + .map(|entry| IntegrityRoot { + path: git.join(entry), + mode: RootMode::Recursive, + }), + ); + roots } /// Verifies the roots every `period` while the launch runs. Each pass is diff --git a/crates/agent/src/launch/mod.rs b/crates/agent/src/launch/mod.rs index 1cd0ae29..9b88834b 100644 --- a/crates/agent/src/launch/mod.rs +++ b/crates/agent/src/launch/mod.rs @@ -15,6 +15,7 @@ use std::{ time::Duration, }; +use crate::{api, secure_fs}; use agentdesktop_core::{ config::{ContainmentStartupConfig, DaemonConfig}, model::{ @@ -31,12 +32,12 @@ use axum::{ response::{IntoResponse, Response}, routing::{get, post}, }; -use serde::Serialize; - -use crate::{api, secure_fs}; +pub(crate) mod engine; pub mod host; pub mod integrity; +pub(crate) mod plan; +pub(crate) mod profile; #[cfg(test)] mod tests; #[cfg(test)] @@ -63,6 +64,7 @@ pub fn containment_info( windows: bool, claude_settings_overridden: bool, exists: &dyn Fn(&Path) -> bool, + engine_name: &'static str, ) -> Option { let cfg = cfg?; let executor = cfg.executor.display().to_string(); @@ -82,6 +84,7 @@ pub fn containment_info( }; Some(ContainmentInfo { executor, + engine: engine_name.to_owned(), available: error.is_none(), error, managed_settings_source: None, @@ -333,67 +336,7 @@ pub fn check_args(args: &[String]) -> Result, String> { .collect() } -// No Debug: the request's environment carries the session pairing. -#[derive(Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub 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 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 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 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 struct MxcNetwork { - pub egress: MxcEgress, - pub ingress: MxcIngress, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct MxcEgress { - pub default: String, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MxcIngress { - pub default: String, - pub host_loopback: String, -} - -/// Everything `build_request` may use. Paths are Windows-style strings in a +/// Everything `plan` may use. Paths are Windows-style strings in a /// `PathBuf`; `exists` is called with `\`-joined paths. pub struct LaunchInput<'a> { pub launch_id: String, @@ -416,7 +359,7 @@ pub struct LaunchInput<'a> { } /// A path as the request carries it: `\` separators only. -fn win(path: &Path) -> String { +pub(crate) fn win(path: &Path) -> String { path.to_string_lossy().replace('/', "\\") } @@ -424,9 +367,11 @@ fn join(base: &str, rest: &str) -> String { format!("{}\\{rest}", base.trim_end_matches('\\')) } -/// Builds the MXC request. Pure: no host environment or filesystem reads -/// beyond `input.exists` and `input.is_dir`. -pub fn build_request(input: &LaunchInput) -> MxcRequest { +/// Resolves a launch input into a plan: the canonical executable, validated args, +/// final environment, and existence-filtered paths. Existence filtering reflects +/// MXC's grant rules. Pure: no host environment or filesystem reads beyond +/// `input.exists` and `input.is_dir`. +pub(crate) fn plan(input: &LaunchInput, profile: &profile::ClaudeCodeProfile) -> plan::LaunchPlan { let exists = |path: &str| (input.exists)(Path::new(path)); let is_dir = |path: &str| (input.is_dir)(Path::new(path)); let cwd = win(&input.cwd); @@ -460,48 +405,34 @@ pub fn build_request(input: &LaunchInput) -> MxcRequest { path.push(client_exe_dir.clone()); let mut env = input.base_env.clone(); - env.insert("TERM".into(), "xterm-256color".into()); env.insert("PATH".into(), path.join(";")); env.insert("TEMP".into(), tmp.clone()); env.insert("TMP".into(), tmp.clone()); env.insert("CLAUDE_CONFIG_DIR".into(), claude_dir.clone()); - env.insert("CLAUDE_CODE_USE_POWERSHELL_TOOL".into(), "1".into()); - env.insert("DISABLE_AUTOUPDATER".into(), "1".into()); - env.insert( - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".into(), - "1".into(), - ); + for (key, value) in profile.fixed_env { + env.insert((*key).to_owned(), (*value).to_owned()); + } env.insert("ANTHROPIC_BASE_URL".into(), input.relay_base_url.clone()); - env.insert("ANTHROPIC_AUTH_TOKEN".into(), "placeholder".into()); env.insert( "ANTHROPIC_CUSTOM_HEADERS".into(), format!("{}: {}", crate::llm_proxy::PAIRING_HEADER, input.pairing), ); - env.insert("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST".into(), "1".into()); env.insert("HOME".into(), home.clone()); - let mut command_line = format!( - "cmd.exe /d /v:off /c start \"agentdesktop: claude\" /WAIT /D \"{cwd}\" \"{executable}\"" - ); - for arg in &input.args { - command_line.push(' '); - command_line.push_str(arg); - } - let mut readonly: Vec = Vec::new(); let mut denied: Vec = Vec::new(); // Runtime directories: granted read-only when they exist, simply absent - // otherwise (nothing the box could create there matters). + // otherwise (nothing the box could create there matters). The profile's + // home-relative entries keep their positions among the discovered + // directories; the request bytes pin the order. let runtime = [ Some(exe_dir), - Some(join(&home, r".local\share\claude")), + Some(join(&home, profile.runtime_share)), git_dir, Some(client_exe_dir), program_files_claude, - // git reads the user's configuration before anything else; unreadable - // means every git command fails in the box (lab L4e). - Some(join(&home, ".gitconfig")), - Some(join(&home, r".config\git\config")), + Some(join(&home, profile.git_user_config)), + Some(join(&home, profile.git_xdg_config)), ]; for candidate in runtime.into_iter().flatten() { if exists(&candidate) && !readonly.contains(&candidate) { @@ -510,24 +441,22 @@ pub fn build_request(input: &LaunchInput) -> MxcRequest { } // Executable configuration: read-only when present, denied when absent, so // the box can neither change nor create it. - let mut executable_config = vec![ - join(&claude_dir, "settings.json"), - join(&claude_dir, "hooks"), - join(&claude_dir, "plugins"), - join(&cwd, ".mcp.json"), - // A `HEAD` file at the workspace root would let git treat the - // workspace itself as a bare repository with box-written config. - join(&cwd, "HEAD"), - ]; + let mut executable_config: Vec = profile + .home_executable_config + .iter() + .map(|entry| join(&claude_dir, entry)) + .collect(); + executable_config.push(join(&cwd, profile.mcp_json)); + executable_config.push(join(&cwd, profile.workspace_head)); // MXC refuses a denied path whose parent folder does not exist // (lab-verified, 0x80070003), so a workspace without `.claude` gets the - // folder itself denied instead of its three executable-config entries. + // folder itself denied instead of its executable-config entries. let workspace_claude = join(&cwd, ".claude"); - let workspace_claude_entries = [ - join(&workspace_claude, "settings.json"), - join(&workspace_claude, "settings.local.json"), - join(&workspace_claude, "hooks"), - ]; + let workspace_claude_entries: Vec = profile + .workspace_claude_entries + .iter() + .map(|entry| join(&workspace_claude, entry)) + .collect(); if exists(&workspace_claude) || workspace_claude_entries.iter().any(|path| exists(path)) { executable_config.extend(workspace_claude_entries); } else { @@ -545,9 +474,12 @@ pub fn build_request(input: &LaunchInput) -> MxcRequest { // folder). `.git\HEAD` stays writable: git replaces it by rename on // every branch switch, and the root `HEAD` rule already blocks a // bare-repo fall-through. - executable_config.push(join(&git, "config")); - executable_config.push(join(&git, "hooks")); - executable_config.push(join(&git, "commondir")); + executable_config.extend( + profile + .git_executable_config + .iter() + .map(|entry| join(&git, entry)), + ); } // A `.git` file (worktree, submodule) is left as it is. for path in executable_config { @@ -557,64 +489,33 @@ pub fn build_request(input: &LaunchInput) -> MxcRequest { denied.push(path); } } - for secret in [ - join(&claude_dir, ".credentials.json"), - join(&claude_dir, "history.jsonl"), - // IDE lock files carry auth tokens for the editor bridge. - join(&claude_dir, "ide"), - join(&home, ".ssh"), - join(&home, ".aws"), - join(&home, ".azure"), - join(&home, ".kube"), - ] { - denied.push(secret); + for secret in profile.denied_secrets { + denied.push(join(&home, secret)); } - MxcRequest { - version: "1.0.0".into(), - containment: "processcontainer".into(), - container_id: format!("agentdesktop-{}", input.launch_id), - ui: MxcUi { - disable: false, - clipboard: "none".into(), - injection: false, - }, - process: MxcProcess { - command_line, - cwd: cwd.clone(), - inherit_default_env: false, - env: env - .into_iter() - .map(|(key, value)| format!("{key}={value}")) - .collect(), - }, - filesystem: MxcFilesystem { - readwrite_paths: vec![cwd, claude_dir, tmp], - readonly_paths: readonly, - denied_paths: denied, - }, - network: MxcNetwork { - egress: MxcEgress { - default: match input.egress { - agentdesktop_core::config::ContainmentEgress::Allow => "allow".into(), - agentdesktop_core::config::ContainmentEgress::Deny => "deny".into(), - }, - }, - ingress: MxcIngress { - default: "allow".into(), - host_loopback: "allow".into(), - }, - }, + plan::LaunchPlan { + program: input.executable.clone(), + args: input.args.clone(), + window_title: profile.window_title, + cwd: input.cwd.clone(), + env, + readwrite: vec![ + PathBuf::from(cwd), + PathBuf::from(claude_dir), + PathBuf::from(tmp), + ], + readonly: readonly.into_iter().map(PathBuf::from).collect(), + denied: denied.into_iter().map(PathBuf::from).collect(), + egress: input.egress, } } /// Creates `launch_dir` (private) and writes `request.json` (UTF-8, no BOM, /// mode 0600) into it; returns the file's path. -pub fn write_request(launch_dir: &Path, request: &MxcRequest) -> anyhow::Result { +pub fn write_request(launch_dir: &Path, contents: &[u8]) -> anyhow::Result { secure_fs::ensure_private_dir(launch_dir)?; let path = launch_dir.join("request.json"); - let contents = serde_json::to_vec_pretty(request).context("encode MXC request")?; - secure_fs::atomic_write(&path, &contents, 0o600)?; + secure_fs::atomic_write(&path, contents, 0o600)?; Ok(path) } @@ -854,6 +755,7 @@ pub struct LaunchState { pub(crate) proxy_address: Option, pub(crate) session_policy: Arc, pub(crate) egress: agentdesktop_core::config::ContainmentEgress, + pub(crate) engine: Arc, } /// `POST /v1/launch` and `GET /v1/launches`, behind the peer check. @@ -1058,25 +960,32 @@ async fn start_launch( } let exists = |path: &Path| host.exists(path); let is_dir = |path: &Path| host.is_dir(path); - let mxc = build_request(&LaunchInput { - launch_id: launch_id.clone(), - executable: input.executable, - client_exe_dir: host.client_exe_dir(), - cwd: input.cwd.clone(), - args: input.args, - home: host.home(), - state_dir, - launch_dir: launch_dir.clone(), - git_dir: host.git_dir(), - program_files_claude: host.program_files_claude(), - egress: state.egress, - relay_base_url: crate::llm_proxy::sessions::sandbox_base_url(input.proxy_address), - pairing: session.pairing.to_string(), - base_env: host.base_env(), - exists: &exists, - is_dir: &is_dir, - }); - let request_path = match write_request(&launch_dir, &mxc) { + let launch_plan = plan( + &LaunchInput { + launch_id: launch_id.clone(), + executable: input.executable, + client_exe_dir: host.client_exe_dir(), + cwd: input.cwd.clone(), + args: input.args, + home: host.home(), + state_dir, + launch_dir: launch_dir.clone(), + git_dir: host.git_dir(), + program_files_claude: host.program_files_claude(), + egress: state.egress, + relay_base_url: crate::llm_proxy::sessions::sandbox_base_url(input.proxy_address), + pairing: session.pairing.to_string(), + base_env: host.base_env(), + exists: &exists, + is_dir: &is_dir, + }, + &profile::CLAUDE_CODE, + ); + let request_bytes = match state.engine.request(&launch_plan, &launch_id) { + Ok(bytes) => bytes, + Err(error) => return fail(&launch_dir, format!("encode launch request: {error:#}")), + }; + let request_path = match write_request(&launch_dir, &request_bytes) { Ok(path) => path, Err(error) => return fail(&launch_dir, format!("prepare launch: {error:#}")), }; @@ -1193,7 +1102,7 @@ async fn start_launch( launch_id: launch_id.clone(), program: PROGRAM.to_owned(), cwd: input.cwd, - container_id: format!("agentdesktop-{launch_id}"), + container_id: state.engine.container_id(&launch_id), status: LaunchStatus::Running, exit_code: None, started_at_unix_seconds: started_at, diff --git a/crates/agent/src/launch/plan.rs b/crates/agent/src/launch/plan.rs new file mode 100644 index 00000000..f7867e66 --- /dev/null +++ b/crates/agent/src/launch/plan.rs @@ -0,0 +1,27 @@ +//! Launch plan: the resolved launch inputs before engine-specific envelope. + +use std::{collections::BTreeMap, path::PathBuf}; + +use agentdesktop_core::config::ContainmentEgress; + +/// A resolved launch: canonical paths, validated args, and final environment. +pub(crate) struct LaunchPlan { + /// The canonical executable path. + pub program: PathBuf, + /// Already validated and quoted arguments. + pub args: Vec, + /// The window title from the profile. + pub window_title: &'static str, + /// The working directory. + pub cwd: PathBuf, + /// Final environment including relay URL and pairing. + pub env: BTreeMap, + /// Readwrite paths (existence-filtered). + pub readwrite: Vec, + /// Readonly paths (existence-filtered). + pub readonly: Vec, + /// Denied paths (existence-filtered and ordered). + pub denied: Vec, + /// Egress policy. + pub egress: ContainmentEgress, +} diff --git a/crates/agent/src/launch/profile.rs b/crates/agent/src/launch/profile.rs new file mode 100644 index 00000000..bce51244 --- /dev/null +++ b/crates/agent/src/launch/profile.rs @@ -0,0 +1,100 @@ +//! Profile data: lists and constants for the Claude Code profile. +//! +//! The profile carries what the launch grants and denies as data; `plan` in +//! the parent module turns it into a [`super::plan::LaunchPlan`], and +//! `integrity_paths` watches the same executable-configuration entries. +//! Request paths are joined with the string `join` (`\`), integrity paths +//! with `Path::join`, so every entry here is a single relative segment unless +//! noted as a `\`-joined home-relative path. + +/// The Claude Code profile. +pub(crate) struct ClaudeCodeProfile { + /// Environment variable names copied from the daemon into the box. + pub base_env_keys: &'static [&'static str], + /// Fixed environment variable key-value pairs set for every launch. + pub fixed_env: &'static [(&'static str, &'static str)], + /// Home-relative runtime path (`\`-joined) of the client's shared data, + /// granted read-only when it exists and simply absent otherwise. Order is + /// part of the request: this entry sits between the executable's + /// directory and the git installation. + pub runtime_share: &'static str, + /// The git user configuration (home-relative), granted read-only when it + /// exists; comes after the discovered directories (git reads the user's + /// configuration before anything else; unreadable means every git + /// command fails in the box, lab L4e). + pub git_user_config: &'static str, + /// The XDG fallback of the git user configuration (`\`-joined), granted + /// read-only directly after `git_user_config`. + pub git_xdg_config: &'static str, + /// Executable configuration under `\.claude`: read-only when + /// present, denied when absent, so the box can neither change nor + /// create it. + pub home_executable_config: &'static [&'static str], + /// Executable configuration at the workspace root, same read-only-or- + /// denied rule as the home entries. + pub mcp_json: &'static str, + /// Executable configuration at the workspace root, same rule: a `HEAD` + /// file at the workspace root would let git treat the workspace itself + /// as a bare repository with box-written config. + pub workspace_head: &'static str, + /// Executable configuration under `\.claude`, same rule; emitted + /// only when the folder (or one of the entries) exists, because MXC + /// refuses a denied path whose parent folder does not exist + /// (lab-verified, 0x80070003). A workspace without `.claude` gets the + /// folder itself denied instead. + pub workspace_claude_entries: &'static [&'static str], + /// Executable configuration under `\.git`, emitted whether or not + /// the entries exist once `.git` is a directory. + pub git_executable_config: &'static [&'static str], + /// Home-relative secrets (`\`-joined) denied for every launch, in request + /// order. The single-segment entries (the top-level home directories) + /// are also the workspace-validation denied directories. + pub denied_secrets: &'static [&'static str], + /// Title of the `cmd.exe start` window. + pub window_title: &'static str, +} + +pub(crate) const CLAUDE_CODE: ClaudeCodeProfile = ClaudeCodeProfile { + base_env_keys: &[ + "SystemRoot", + "windir", + "SystemDrive", + "ComSpec", + "PATHEXT", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "ProgramFiles", + "ProgramData", + "USERNAME", + ], + fixed_env: &[ + ("TERM", "xterm-256color"), + ("CLAUDE_CODE_USE_POWERSHELL_TOOL", "1"), + ("DISABLE_AUTOUPDATER", "1"), + ("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"), + ("ANTHROPIC_AUTH_TOKEN", "placeholder"), + ("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1"), + ], + runtime_share: r".local\share\claude", + git_user_config: ".gitconfig", + git_xdg_config: r".config\git\config", + home_executable_config: &["settings.json", "hooks", "plugins"], + mcp_json: ".mcp.json", + workspace_head: "HEAD", + workspace_claude_entries: &["settings.json", "settings.local.json", "hooks"], + git_executable_config: &["config", "hooks", "commondir"], + denied_secrets: &[ + r".claude\.credentials.json", + r".claude\history.jsonl", + // IDE lock files carry auth tokens for the editor bridge. + r".claude\ide", + ".ssh", + ".aws", + ".azure", + ".kube", + ], + window_title: "agentdesktop: claude", +}; diff --git a/crates/agent/src/launch/tests.rs b/crates/agent/src/launch/tests.rs index 8eebfcab..243f3053 100644 --- a/crates/agent/src/launch/tests.rs +++ b/crates/agent/src/launch/tests.rs @@ -26,6 +26,7 @@ use agentdesktop_core::{ use async_trait::async_trait; use axum::http::StatusCode; +use super::engine::mxc::build_request; use super::*; // --- Log capture helper for session logging test (AC 2) @@ -105,22 +106,27 @@ fn executor_cfg(path: &str) -> ContainmentStartupConfig { ContainmentStartupConfig { executor: PathBuf::from(path), egress: agentdesktop_core::config::ContainmentEgress::default(), + engine: agentdesktop_core::config::ContainmentEngine::default(), } } #[test] fn containment_info_is_absent_when_unconfigured() { - assert_eq!(containment_info(None, true, true, false, &|_| true), None); + assert_eq!( + containment_info(None, true, true, false, &|_| true, "mxc"), + None + ); } #[test] fn containment_info_is_available_for_a_user_mode_windows_daemon_with_the_executor() { let cfg = executor_cfg(r"C:\mxc\x64\wxc-exec.exe"); - let info = containment_info(Some(&cfg), true, true, false, &|_| true).unwrap(); + let info = containment_info(Some(&cfg), true, true, false, &|_| true, "mxc").unwrap(); assert_eq!( info, ContainmentInfo { executor: r"C:\mxc\x64\wxc-exec.exe".into(), + engine: "mxc".to_owned(), available: true, error: None, managed_settings_source: None, @@ -134,7 +140,7 @@ fn containment_info_is_available_for_a_user_mode_windows_daemon_with_the_executo #[test] fn containment_info_needs_user_mode() { let cfg = executor_cfg(r"C:\mxc\wxc-exec.exe"); - let info = containment_info(Some(&cfg), false, true, false, &|_| true).unwrap(); + let info = containment_info(Some(&cfg), false, true, false, &|_| true, "mxc").unwrap(); assert!(!info.available); assert_eq!(info.error.as_deref(), Some("requires user mode")); assert_eq!(info.executor, r"C:\mxc\wxc-exec.exe"); @@ -143,7 +149,7 @@ fn containment_info_needs_user_mode() { #[test] fn containment_info_is_windows_only() { let cfg = executor_cfg("/opt/mxc/wxc-exec.exe"); - let info = containment_info(Some(&cfg), true, false, false, &|_| true).unwrap(); + let info = containment_info(Some(&cfg), true, false, false, &|_| true, "mxc").unwrap(); assert!(!info.available); assert_eq!(info.error.as_deref(), Some("Windows only")); } @@ -152,10 +158,17 @@ fn containment_info_is_windows_only() { fn containment_info_names_a_missing_executor() { let cfg = executor_cfg(r"C:\missing\wxc-exec.exe"); let seen = RefCell::new(Vec::new()); - let info = containment_info(Some(&cfg), true, true, false, &|path| { - seen.borrow_mut().push(path.to_owned()); - false - }) + let info = containment_info( + Some(&cfg), + true, + true, + false, + &|path| { + seen.borrow_mut().push(path.to_owned()); + false + }, + "mxc", + ) .unwrap(); assert!(!info.available); assert_eq!( @@ -171,7 +184,7 @@ fn containment_info_names_a_missing_executor() { #[test] fn containment_info_is_unavailable_when_the_claude_settings_path_is_overridden() { let cfg = executor_cfg(r"C:\mxc\wxc-exec.exe"); - let info = containment_info(Some(&cfg), true, true, true, &|_| true).unwrap(); + let info = containment_info(Some(&cfg), true, true, true, &|_| true, "mxc").unwrap(); assert!(!info.available); let error = info.error.expect("a reason"); assert!( @@ -1360,7 +1373,7 @@ fn write_request_creates_the_private_launch_dir_and_writes_json_without_a_bom() let is_dir = |path: &Path| directories.contains(path.to_str().unwrap()); let request = build_request(&launch_input(&exists, &is_dir)); - let path = write_request(&launch_dir, &request).unwrap(); + let path = write_request(&launch_dir, &serde_json::to_vec_pretty(&request).unwrap()).unwrap(); assert_eq!(path, launch_dir.join("request.json")); let bytes = std::fs::read(&path).unwrap(); @@ -1382,7 +1395,7 @@ fn write_request_uses_a_private_directory_and_a_0600_file() { let is_dir = |_: &Path| false; let request = build_request(&launch_input(&exists, &is_dir)); - let path = write_request(&launch_dir, &request).unwrap(); + let path = write_request(&launch_dir, &serde_json::to_vec_pretty(&request).unwrap()).unwrap(); let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; assert_eq!(mode(&launch_dir), 0o700); @@ -2050,6 +2063,7 @@ impl Harness { let availability = match setup.availability { Availability::Available => Some(ContainmentInfo { executor: executor_path.display().to_string(), + engine: "mxc".to_owned(), available: true, error: None, managed_settings_source: None, @@ -2059,6 +2073,7 @@ impl Harness { }), Availability::Unavailable(error) => Some(ContainmentInfo { executor: executor_path.display().to_string(), + engine: "mxc".to_owned(), available: false, error: Some(error.to_owned()), managed_settings_source: None, @@ -2120,6 +2135,7 @@ impl Harness { proxy_address: setup.proxy_address, session_policy: setup.session_policy.clone(), egress: setup.egress, + engine: Arc::new(engine::mxc::MxcEngine), }; let mut router = router(state); @@ -3504,3 +3520,528 @@ fn copy_log_lines_uses_raw_byte_offset_not_lossy_string_offset() { let final_offset = super::copy_log_lines("test-launch", &log, new_offset); assert_eq!(final_offset, 12, "second read from correct offset"); } + +// --- PR 2d spec-derived tests (AC 7, engine interface) + +#[test] +fn the_engine_reproduces_the_baseline_request_bytes() { + use engine::Engine; + use engine::mxc::MxcEngine; + use profile::CLAUDE_CODE; + + const LAUNCH_ID: &str = "0123456789abcdef"; + + // Oracle (a): launch_input + snapshot_existing extended with both git + // user-configuration files, egress: Allow. The extension pins the order + // of the profile's two git user-configuration entries in `readonlyPaths`; + // re-captured at the baseline with the same fixture (ledger ## v2). + const ORACLE_A: &str = r#"{ + "version": "1.0.0", + "containment": "processcontainer", + "containerId": "agentdesktop-0123456789abcdef", + "ui": { + "disable": false, + "clipboard": "none", + "injection": false + }, + "process": { + "commandLine": "cmd.exe /d /v:off /c start \"agentdesktop: claude\" /WAIT /D \"C:\\lab\\ws pr1\" \"C:\\Users\\dev\\.local\\bin\\claude.exe\" --debug-file \"C:\\lab\\ws pr1\\pr1-l2.log\" --allowedTools PowerShell", + "cwd": "C:\\lab\\ws pr1", + "inheritDefaultEnv": false, + "env": [ + "ANTHROPIC_AUTH_TOKEN=placeholder", + "ANTHROPIC_BASE_URL=http://127.0.0.1:4000/sandbox", + "ANTHROPIC_CUSTOM_HEADERS=x-agentdesktop-pairing: test-pairing-value", + "APPDATA=C:\\Users\\dev\\AppData\\Roaming", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1", + "CLAUDE_CODE_USE_POWERSHELL_TOOL=1", + "CLAUDE_CONFIG_DIR=C:\\Users\\dev\\.claude", + "ComSpec=C:\\Windows\\system32\\cmd.exe", + "DISABLE_AUTOUPDATER=1", + "HOME=C:\\Users\\dev", + "HOMEDRIVE=C:", + "HOMEPATH=\\Users\\dev", + "LOCALAPPDATA=C:\\Users\\dev\\AppData\\Local", + "PATH=C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\WindowsPowerShell\\v1.0;C:\\Program Files\\Git\\cmd;C:\\Users\\dev\\.local\\bin;C:\\lab\\agentdesktop", + "PATHEXT=.COM;.EXE;.BAT;.CMD", + "ProgramData=C:\\ProgramData", + "ProgramFiles=C:\\Program Files", + "SystemDrive=C:", + "SystemRoot=C:\\Windows", + "TEMP=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp", + "TERM=xterm-256color", + "TMP=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp", + "USERNAME=dev", + "USERPROFILE=C:\\Users\\dev", + "windir=C:\\Windows" + ] + }, + "filesystem": { + "readwritePaths": [ + "C:\\lab\\ws pr1", + "C:\\Users\\dev\\.claude", + "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp" + ], + "readonlyPaths": [ + "C:\\Users\\dev\\.local\\bin", + "C:\\Users\\dev\\.local\\share\\claude", + "C:\\Program Files\\Git", + "C:\\lab\\agentdesktop", + "C:\\Program Files\\ClaudeCode", + "C:\\Users\\dev\\.gitconfig", + "C:\\Users\\dev\\.config\\git\\config", + "C:\\Users\\dev\\.claude\\settings.json", + "C:\\Users\\dev\\.claude\\plugins", + "C:\\lab\\ws pr1\\.mcp.json", + "C:\\lab\\ws pr1\\.claude\\settings.local.json", + "C:\\lab\\ws pr1\\.git\\config", + "C:\\lab\\ws pr1\\.git\\hooks" + ], + "deniedPaths": [ + "C:\\Users\\dev\\.claude\\hooks", + "C:\\lab\\ws pr1\\HEAD", + "C:\\lab\\ws pr1\\.claude\\settings.json", + "C:\\lab\\ws pr1\\.claude\\hooks", + "C:\\lab\\ws pr1\\.git\\commondir", + "C:\\Users\\dev\\.claude\\.credentials.json", + "C:\\Users\\dev\\.claude\\history.jsonl", + "C:\\Users\\dev\\.claude\\ide", + "C:\\Users\\dev\\.ssh", + "C:\\Users\\dev\\.aws", + "C:\\Users\\dev\\.azure", + "C:\\Users\\dev\\.kube" + ] + }, + "network": { + "egress": { + "default": "allow" + }, + "ingress": { + "default": "allow", + "hostLoopback": "allow" + } + } +}"#; + + // Oracle (b): same with egress: Deny + const ORACLE_B: &str = r#"{ + "version": "1.0.0", + "containment": "processcontainer", + "containerId": "agentdesktop-0123456789abcdef", + "ui": { + "disable": false, + "clipboard": "none", + "injection": false + }, + "process": { + "commandLine": "cmd.exe /d /v:off /c start \"agentdesktop: claude\" /WAIT /D \"C:\\lab\\ws pr1\" \"C:\\Users\\dev\\.local\\bin\\claude.exe\" --debug-file \"C:\\lab\\ws pr1\\pr1-l2.log\" --allowedTools PowerShell", + "cwd": "C:\\lab\\ws pr1", + "inheritDefaultEnv": false, + "env": [ + "ANTHROPIC_AUTH_TOKEN=placeholder", + "ANTHROPIC_BASE_URL=http://127.0.0.1:4000/sandbox", + "ANTHROPIC_CUSTOM_HEADERS=x-agentdesktop-pairing: test-pairing-value", + "APPDATA=C:\\Users\\dev\\AppData\\Roaming", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1", + "CLAUDE_CODE_USE_POWERSHELL_TOOL=1", + "CLAUDE_CONFIG_DIR=C:\\Users\\dev\\.claude", + "ComSpec=C:\\Windows\\system32\\cmd.exe", + "DISABLE_AUTOUPDATER=1", + "HOME=C:\\Users\\dev", + "HOMEDRIVE=C:", + "HOMEPATH=\\Users\\dev", + "LOCALAPPDATA=C:\\Users\\dev\\AppData\\Local", + "PATH=C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\WindowsPowerShell\\v1.0;C:\\Program Files\\Git\\cmd;C:\\Users\\dev\\.local\\bin;C:\\lab\\agentdesktop", + "PATHEXT=.COM;.EXE;.BAT;.CMD", + "ProgramData=C:\\ProgramData", + "ProgramFiles=C:\\Program Files", + "SystemDrive=C:", + "SystemRoot=C:\\Windows", + "TEMP=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp", + "TERM=xterm-256color", + "TMP=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp", + "USERNAME=dev", + "USERPROFILE=C:\\Users\\dev", + "windir=C:\\Windows" + ] + }, + "filesystem": { + "readwritePaths": [ + "C:\\lab\\ws pr1", + "C:\\Users\\dev\\.claude", + "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp" + ], + "readonlyPaths": [ + "C:\\Users\\dev\\.local\\bin", + "C:\\Users\\dev\\.local\\share\\claude", + "C:\\Program Files\\Git", + "C:\\lab\\agentdesktop", + "C:\\Program Files\\ClaudeCode", + "C:\\Users\\dev\\.claude\\settings.json", + "C:\\Users\\dev\\.claude\\plugins", + "C:\\lab\\ws pr1\\.mcp.json", + "C:\\lab\\ws pr1\\.claude\\settings.local.json", + "C:\\lab\\ws pr1\\.git\\config", + "C:\\lab\\ws pr1\\.git\\hooks" + ], + "deniedPaths": [ + "C:\\Users\\dev\\.claude\\hooks", + "C:\\lab\\ws pr1\\HEAD", + "C:\\lab\\ws pr1\\.claude\\settings.json", + "C:\\lab\\ws pr1\\.claude\\hooks", + "C:\\lab\\ws pr1\\.git\\commondir", + "C:\\Users\\dev\\.claude\\.credentials.json", + "C:\\Users\\dev\\.claude\\history.jsonl", + "C:\\Users\\dev\\.claude\\ide", + "C:\\Users\\dev\\.ssh", + "C:\\Users\\dev\\.aws", + "C:\\Users\\dev\\.azure", + "C:\\Users\\dev\\.kube" + ] + }, + "network": { + "egress": { + "default": "deny" + }, + "ingress": { + "default": "allow", + "hostLoopback": "allow" + } + } +}"#; + + // Oracle (c): minimal input + const ORACLE_C: &str = r#"{ + "version": "1.0.0", + "containment": "processcontainer", + "containerId": "agentdesktop-0123456789abcdef", + "ui": { + "disable": false, + "clipboard": "none", + "injection": false + }, + "process": { + "commandLine": "cmd.exe /d /v:off /c start \"agentdesktop: claude\" /WAIT /D \"C:\\ws\" \"C:\\Users\\dev\\.local\\bin\\claude.exe\"", + "cwd": "C:\\ws", + "inheritDefaultEnv": false, + "env": [ + "ANTHROPIC_AUTH_TOKEN=placeholder", + "ANTHROPIC_BASE_URL=http://127.0.0.1:4000/sandbox", + "ANTHROPIC_CUSTOM_HEADERS=x-agentdesktop-pairing: test-pairing-value", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1", + "CLAUDE_CODE_USE_POWERSHELL_TOOL=1", + "CLAUDE_CONFIG_DIR=C:\\Users\\dev\\.claude", + "DISABLE_AUTOUPDATER=1", + "HOME=C:\\Users\\dev", + "PATH=C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\WindowsPowerShell\\v1.0;C:\\Users\\dev\\.local\\bin;C:\\lab\\agentdesktop", + "TEMP=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp", + "TERM=xterm-256color", + "TMP=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp" + ] + }, + "filesystem": { + "readwritePaths": [ + "C:\\ws", + "C:\\Users\\dev\\.claude", + "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp" + ], + "readonlyPaths": [], + "deniedPaths": [ + "C:\\ws\\.claude", + "C:\\ws\\.git", + "C:\\Users\\dev\\.claude\\settings.json", + "C:\\Users\\dev\\.claude\\hooks", + "C:\\Users\\dev\\.claude\\plugins", + "C:\\ws\\.mcp.json", + "C:\\ws\\HEAD", + "C:\\Users\\dev\\.claude\\.credentials.json", + "C:\\Users\\dev\\.claude\\history.jsonl", + "C:\\Users\\dev\\.claude\\ide", + "C:\\Users\\dev\\.ssh", + "C:\\Users\\dev\\.aws", + "C:\\Users\\dev\\.azure", + "C:\\Users\\dev\\.kube" + ] + }, + "network": { + "egress": { + "default": "allow" + }, + "ingress": { + "default": "allow", + "hostLoopback": "allow" + } + } +}"#; + + // Fixture (a): both git user-configuration files exist, so the oracle + // covers their positions among the read-only grants. + let existing_a = { + let mut existing = snapshot_existing(); + existing.insert(r"C:\Users\dev\.gitconfig".to_owned()); + existing.insert(r"C:\Users\dev\.config\git\config".to_owned()); + existing + }; + let exists_a = |path: &Path| existing_a.contains(path.to_str().expect("UTF-8 path")); + let is_dir_a = + |path: &Path| snapshot_directories().contains(path.to_str().expect("UTF-8 path")); + let input_a = launch_input(&exists_a, &is_dir_a); + let plan_a = plan(&input_a, &CLAUDE_CODE); + let bytes_a = MxcEngine.request(&plan_a, LAUNCH_ID).unwrap(); + assert_eq!(String::from_utf8_lossy(&bytes_a), ORACLE_A); + + // Fixture (b) + let exists_b = |path: &Path| snapshot_existing().contains(path.to_str().expect("UTF-8 path")); + let is_dir_b = + |path: &Path| snapshot_directories().contains(path.to_str().expect("UTF-8 path")); + let mut input_b = launch_input(&exists_b, &is_dir_b); + input_b.egress = agentdesktop_core::config::ContainmentEgress::Deny; + let plan_b = plan(&input_b, &CLAUDE_CODE); + let bytes_b = MxcEngine.request(&plan_b, LAUNCH_ID).unwrap(); + assert_eq!(String::from_utf8_lossy(&bytes_b), ORACLE_B); + + // Fixture (c) + let exists_c = |_: &Path| false; + let is_dir_c = |_: &Path| false; + let input_c = LaunchInput { + launch_id: LAUNCH_ID.into(), + executable: PathBuf::from(r"C:\Users\dev\.local\bin\claude.exe"), + client_exe_dir: PathBuf::from(r"C:\lab\agentdesktop"), + cwd: PathBuf::from(r"C:\ws"), + args: vec![], + home: PathBuf::from(r"C:\Users\dev"), + state_dir: PathBuf::from(r"C:\Users\dev\.local/state\agentdesktop"), + launch_dir: PathBuf::from( + r"C:\Users\dev\.local/state\agentdesktop\launches\0123456789abcdef", + ), + git_dir: None, + program_files_claude: None, + egress: agentdesktop_core::config::ContainmentEgress::Allow, + relay_base_url: "http://127.0.0.1:4000/sandbox".into(), + pairing: "test-pairing-value".into(), + base_env: BTreeMap::new(), + exists: &exists_c, + is_dir: &is_dir_c, + }; + let plan_c = plan(&input_c, &CLAUDE_CODE); + let bytes_c = MxcEngine.request(&plan_c, LAUNCH_ID).unwrap(); + assert_eq!(String::from_utf8_lossy(&bytes_c), ORACLE_C); +} + +#[test] +fn engine_selection_absent_key_defaults_to_mxc() { + use agentdesktop_core::config::ContainmentEngine; + let json = r#"{"executor": "C:\\path\\to\\wxc-exec.exe"}"#; + let cfg: agentdesktop_core::config::ContainmentStartupConfig = + serde_json::from_str(json).unwrap(); + assert_eq!(cfg.engine, ContainmentEngine::Mxc); +} + +#[test] +fn engine_selection_unknown_value_fails() { + let json = r#"{"executor": "C:\\path\\to\\wxc-exec.exe", "engine": "unknown"}"#; + let result: Result = + serde_json::from_str(json); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("mxc"), + "error should name accepted values: {}", + err + ); +} + +#[test] +fn posture_carries_engine_name() { + use engine::Engine; + use engine::mxc::MxcEngine; + + let info = containment_info( + Some(&agentdesktop_core::config::ContainmentStartupConfig { + executor: p("path/to/wxc-exec.exe"), + egress: agentdesktop_core::config::ContainmentEgress::Allow, + engine: agentdesktop_core::config::ContainmentEngine::Mxc, + }), + true, + true, + false, + &|_| true, + MxcEngine.name(), + ); + assert_eq!(info.as_ref().unwrap().engine, "mxc"); +} + +#[test] +fn posture_carries_engine_when_containment_unavailable() { + use engine::Engine; + use engine::mxc::MxcEngine; + + let info = containment_info( + Some(&agentdesktop_core::config::ContainmentStartupConfig { + executor: p("path/to/missing.exe"), + egress: agentdesktop_core::config::ContainmentEgress::Allow, + engine: agentdesktop_core::config::ContainmentEngine::Mxc, + }), + true, + true, + false, + &|_| false, + MxcEngine.name(), + ); + let info = info.unwrap(); + assert!(!info.available); + assert_eq!(info.engine, "mxc"); +} + +#[test] +fn mxc_engine_name_is_mxc() { + use engine::Engine; + use engine::mxc::MxcEngine; + assert_eq!(MxcEngine.name(), "mxc"); +} + +#[test] +fn mxc_engine_container_id() { + use engine::Engine; + use engine::mxc::MxcEngine; + assert_eq!( + MxcEngine.container_id("0123456789abcdef"), + "agentdesktop-0123456789abcdef" + ); +} + +#[test] +fn plan_yields_expected_env_keys() { + use profile::CLAUDE_CODE; + + let exists = |path: &Path| snapshot_existing().contains(path.to_str().expect("UTF-8 path")); + let is_dir = |path: &Path| snapshot_directories().contains(path.to_str().expect("UTF-8 path")); + let input = launch_input(&exists, &is_dir); + let plan_result = plan(&input, &CLAUDE_CODE); + + // Check that plan has the relay URL and pairing in env + assert!(plan_result.env.contains_key("ANTHROPIC_BASE_URL")); + assert!(plan_result.env.contains_key("ANTHROPIC_CUSTOM_HEADERS")); + assert_eq!( + plan_result.env.get("ANTHROPIC_BASE_URL").unwrap(), + "http://127.0.0.1:4000/sandbox" + ); +} + +// --- Profile equivalence (spec "Tests first"): every `ClaudeCodeProfile` +// field equals the hard-coded list it replaced, written out literally from +// the baseline commit 8910230 and compared entry by entry in order, so a +// future profile edit shows up as a reviewable test diff here. + +#[test] +fn the_profile_base_env_keys_equal_the_replaced_base_env() { + // Baseline 8910230 crates/agent/src/launch/host.rs:24-38 (`BASE_ENV`). + let expected: &[&str] = &[ + "SystemRoot", + "windir", + "SystemDrive", + "ComSpec", + "PATHEXT", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "ProgramFiles", + "ProgramData", + "USERNAME", + ]; + assert_eq!(profile::CLAUDE_CODE.base_env_keys, expected); +} + +#[test] +fn the_profile_fixed_env_equals_the_replaced_env_inserts() { + // Baseline 8910230 crates/agent/src/launch/mod.rs:463-480: the six fixed + // `env.insert` literals, in insertion order (the dynamic inserts between + // them stay in `plan`; the map is a `BTreeMap`, so order is not + // observable, but the list is pinned verbatim anyway). + let expected: &[(&str, &str)] = &[ + ("TERM", "xterm-256color"), + ("CLAUDE_CODE_USE_POWERSHELL_TOOL", "1"), + ("DISABLE_AUTOUPDATER", "1"), + ("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"), + ("ANTHROPIC_AUTH_TOKEN", "placeholder"), + ("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1"), + ]; + assert_eq!(profile::CLAUDE_CODE.fixed_env, expected); +} + +#[test] +fn the_profile_runtime_entries_equal_the_replaced_runtime_list() { + // Baseline 8910230 crates/agent/src/launch/mod.rs:495-506 (the `runtime` + // array): the three home-relative entries and their order among the + // discovered directories; the byte oracle (fixture (a)) pins the + // positions in the request. + assert_eq!(profile::CLAUDE_CODE.runtime_share, r".local\share\claude"); + assert_eq!(profile::CLAUDE_CODE.git_user_config, ".gitconfig"); + assert_eq!(profile::CLAUDE_CODE.git_xdg_config, r".config\git\config"); +} + +#[test] +fn the_profile_home_executable_config_equals_the_replaced_list() { + // Baseline 8910230 crates/agent/src/launch/mod.rs:514-516 (the + // `claude_dir` joins of `executable_config`). + let expected: &[&str] = &["settings.json", "hooks", "plugins"]; + assert_eq!(profile::CLAUDE_CODE.home_executable_config, expected); +} + +#[test] +fn the_profile_workspace_entries_equal_the_replaced_lists() { + // Baseline 8910230 crates/agent/src/launch/mod.rs:517-520 (the two `cwd` + // joins of `executable_config`) and :526-530 + // (`workspace_claude_entries`). + assert_eq!(profile::CLAUDE_CODE.mcp_json, ".mcp.json"); + assert_eq!(profile::CLAUDE_CODE.workspace_head, "HEAD"); + let expected: &[&str] = &["settings.json", "settings.local.json", "hooks"]; + assert_eq!(profile::CLAUDE_CODE.workspace_claude_entries, expected); +} + +#[test] +fn the_profile_git_executable_config_equals_the_replaced_list() { + // Baseline 8910230 crates/agent/src/launch/mod.rs:548-550 (the `.git` + // pushes onto `executable_config`). + let expected: &[&str] = &["config", "hooks", "commondir"]; + assert_eq!(profile::CLAUDE_CODE.git_executable_config, expected); +} + +#[test] +fn the_profile_denied_secrets_equal_the_replaced_secret_list() { + // Baseline 8910230 crates/agent/src/launch/mod.rs:560-569: the first + // three were joined onto `claude_dir = join(home, ".claude")`, the rest + // onto `home`; a `.claude\` segment joined onto `home` is + // string-identical through `join`. + let expected: &[&str] = &[ + r".claude\.credentials.json", + r".claude\history.jsonl", + r".claude\ide", + ".ssh", + ".aws", + ".azure", + ".kube", + ]; + assert_eq!(profile::CLAUDE_CODE.denied_secrets, expected); + // The single-segment entries double as the workspace-validation denied + // home directories (baseline host.rs:50, `DENIED_HOME_DIRS`). + let denied_home_dirs: Vec<&str> = profile::CLAUDE_CODE + .denied_secrets + .iter() + .copied() + .filter(|entry| !entry.contains('\\')) + .collect(); + assert_eq!(denied_home_dirs, [".ssh", ".aws", ".azure", ".kube"]); +} + +#[test] +fn the_profile_window_title_equals_the_replaced_command_line_title() { + // Baseline 8910230 crates/agent/src/launch/mod.rs:483-485 (the `start` + // window title in the command line). + assert_eq!(profile::CLAUDE_CODE.window_title, "agentdesktop: claude"); +} diff --git a/crates/agent/src/launch/writer_tests.rs b/crates/agent/src/launch/writer_tests.rs index 4c8acbf8..3495b80a 100644 --- a/crates/agent/src/launch/writer_tests.rs +++ b/crates/agent/src/launch/writer_tests.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; +use super::engine::mxc::build_request; use super::*; #[test] @@ -145,6 +146,7 @@ mod handler { use async_trait::async_trait; use axum::{Json, extract::State, http::StatusCode}; + use super::super::engine::mxc::build_request; use super::super::*; /// Maps a request path (`\`-joined, possibly mixed) back to the real @@ -325,6 +327,7 @@ mod handler { registry: LaunchRegistry::new(), availability: Some(ContainmentInfo { executor: "wxc-exec.exe".into(), + engine: "mxc".to_owned(), available: true, error: None, managed_settings_source: None, @@ -343,6 +346,7 @@ mod handler { state_dir: std::env::temp_dir(), }), egress: agentdesktop_core::config::ContainmentEgress::default(), + engine: Arc::new(engine::mxc::MxcEngine), } } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 80f308e0..cde0ee42 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -145,6 +145,10 @@ pub struct ContainmentStartupConfig { /// Outbound network for the sandboxed program: `allow` (default) or `deny`. With `deny` the box reaches only loopback: model requests go through the local LLM proxy, and git over HTTPS and package downloads fail. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`. #[serde(default, skip_serializing_if = "ContainmentEgress::is_default")] pub egress: ContainmentEgress, + /// Sandbox engine: `mxc` (default, the only engine). Read at startup only + /// (restart the daemon after changing it); ignored with `--once` and `--dry-run`. + #[serde(default, skip_serializing_if = "ContainmentEngine::is_default")] + pub engine: ContainmentEngine, } /// Checks `daemon.containment`. With `windows == true` the executor must be an @@ -392,6 +396,23 @@ impl ContainmentEgress { } } +/// Sandbox engine: `mxc` (default, the only engine). Read at startup only +/// (restart the daemon after changing it); ignored with `--once` and `--dry-run`. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ContainmentEngine { + /// MXC (Windows AppContainer). + #[default] + Mxc, +} + +impl ContainmentEngine { + fn is_default(&self) -> bool { + *self == Self::default() + } +} + /// GitHub App user authorization for Copilot requests through the local proxy. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -2491,6 +2512,7 @@ programs: super::ContainmentStartupConfig { executor: executor.into(), egress: super::ContainmentEgress::default(), + engine: super::ContainmentEngine::default(), } } @@ -2890,6 +2912,7 @@ programs: let cfg = super::ContainmentStartupConfig { executor: std::path::PathBuf::from("/x/wxc-exec"), egress: super::ContainmentEgress::Allow, + engine: super::ContainmentEngine::default(), }; let json = serde_json::to_value(&cfg).unwrap(); assert!( @@ -2900,6 +2923,7 @@ programs: let cfg_deny = super::ContainmentStartupConfig { executor: std::path::PathBuf::from("/x/wxc-exec"), egress: super::ContainmentEgress::Deny, + engine: super::ContainmentEngine::default(), }; let json_deny = serde_json::to_value(&cfg_deny).unwrap(); assert_eq!( @@ -2908,4 +2932,39 @@ programs: "deny egress is serialized" ); } + + #[test] + fn containment_engine_explicit_mxc_parses() { + let config = super::parse_local_daemon( + "daemon:\n containment:\n executor: 'C:\\mxc\\wxc-exec.exe'\n engine: mxc\n", + ) + .expect("explicit mxc engine parses"); + let engine = config.daemon.unwrap().containment.unwrap().engine; + assert_eq!(engine, super::ContainmentEngine::Mxc); + } + + #[test] + fn containment_engine_rejects_mxc_capitalized() { + assert!( + super::parse_local_daemon( + "daemon:\n containment:\n executor: 'C:\\mxc\\wxc-exec.exe'\n engine: MXC\n" + ) + .is_err(), + "MXC capitalized is rejected" + ); + } + + #[test] + fn containment_engine_unset_not_serialized() { + let cfg = super::ContainmentStartupConfig { + executor: std::path::PathBuf::from("/x/wxc-exec"), + egress: super::ContainmentEgress::Allow, + engine: super::ContainmentEngine::default(), + }; + let json = serde_json::to_value(&cfg).unwrap(); + assert!( + json.get("engine").is_none(), + "default engine is not serialized" + ); + } } diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index a9029d9b..feece4bf 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -163,6 +163,8 @@ pub enum ManagedSettingsSource { pub struct ContainmentInfo { /// Configured `daemon.containment.executor` (lossy display string). pub executor: String, + /// The sandbox engine name. + pub engine: String, /// Whether launches are possible. `false` carries the reason in `error`. pub available: bool, /// Why launches are impossible, when `available` is `false`. @@ -556,6 +558,7 @@ mod tests { fn containment_info_is_camel_case_and_omits_an_absent_error() { let available = ContainmentInfo { executor: r"C:\mxc\x64\wxc-exec.exe".into(), + engine: "mxc".to_owned(), available: true, error: None, managed_settings_source: None, @@ -567,6 +570,7 @@ mod tests { serde_json::to_value(&available).unwrap(), serde_json::json!({ "executor": r"C:\mxc\x64\wxc-exec.exe", + "engine": "mxc", "available": true, "managedSettingsSource": null, "managedHooksLocked": false, @@ -575,7 +579,7 @@ mod tests { ); let unavailable: ContainmentInfo = serde_json::from_str( - r#"{"executor":"x","available":false,"error":"requires user mode"}"#, + r#"{"executor":"x","engine":"mxc","available":false,"error":"requires user mode"}"#, ) .unwrap(); assert_eq!(unavailable.error.as_deref(), Some("requires user mode")); @@ -741,6 +745,7 @@ mod tests { // ContainmentInfo with managed-settings fields let info_with_fields = ContainmentInfo { executor: r"C:\mxc\x64\wxc-exec.exe".into(), + engine: "mxc".to_owned(), available: true, error: None, managed_settings_source: Some(ManagedSettingsSource::Hklm), @@ -759,6 +764,7 @@ mod tests { // ContainmentInfo without managed-settings fields let info_without_fields = ContainmentInfo { executor: "x".into(), + engine: "mxc".to_owned(), available: false, error: Some("requires user mode".into()), managed_settings_source: None, diff --git a/frontend/desktop/src/stories/fixtures.ts b/frontend/desktop/src/stories/fixtures.ts index cb9552f5..98167c5e 100644 --- a/frontend/desktop/src/stories/fixtures.ts +++ b/frontend/desktop/src/stories/fixtures.ts @@ -45,6 +45,7 @@ export const containmentDaemonInfo: DaemonInfo = { ...standaloneDaemonInfo, containment: { executor: "C:\\Program Files\\mxc\\wxc-exec.exe", + engine: "mxc", available: false, error: "executor not found: C:\\Program Files\\mxc\\wxc-exec.exe", managedSettingsSource: null, diff --git a/frontend/desktop/src/types.ts b/frontend/desktop/src/types.ts index 6e8db079..0d49dc46 100644 --- a/frontend/desktop/src/types.ts +++ b/frontend/desktop/src/types.ts @@ -71,6 +71,8 @@ export interface DaemonInfo { /** Sandboxed launch (`agentdesktop run`), when `daemon.containment` is configured. */ containment?: { executor: string; + /** The sandbox engine name (`mxc`). */ + engine: string; available: boolean; error?: string; managedSettingsSource?: "hklm" | "file" | "unknown" | null; diff --git a/schema/daemon-config.json b/schema/daemon-config.json index 5c6bf35a..e8d16794 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -188,6 +188,16 @@ } ] }, + "ContainmentEngine": { + "description": "Sandbox engine: `mxc` (default, the only engine). Read at startup only\n(restart the daemon after changing it); ignored with `--once` and `--dry-run`.", + "oneOf": [ + { + "description": "MXC (Windows AppContainer).", + "type": "string", + "const": "mxc" + } + ] + }, "ContainmentStartupConfig": { "description": "Sandboxed launch settings.", "type": "object", @@ -196,6 +206,10 @@ "description": "Outbound network for the sandboxed program: `allow` (default) or `deny`. With `deny` the box reaches only loopback: model requests go through the local LLM proxy, and git over HTTPS and package downloads fail. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`.", "$ref": "#/$defs/ContainmentEgress" }, + "engine": { + "description": "Sandbox engine: `mxc` (default, the only engine). Read at startup only\n(restart the daemon after changing it); ignored with `--once` and `--dry-run`.", + "$ref": "#/$defs/ContainmentEngine" + }, "executor": { "description": "Windows only. Absolute path to MXC `wxc-exec.exe`; no default. Read at\nstartup only (restart the daemon after changing it). Requires user\nmode; ignored with `--once` and `--dry-run`.", "type": "string" diff --git a/schema/daemon-config.md b/schema/daemon-config.md index 84ddb9b5..546af7af 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -17,6 +17,7 @@ |`daemon.codex.config`|string|Configuration file.| |`daemon.containment`|object|Sandboxed launch (`agentdesktop run`).| |`daemon.containment.egress`|enum|Outbound network for the sandboxed program: `allow` (default) or `deny`. With `deny` the box reaches only loopback: model requests go through the local LLM proxy, and git over HTTPS and package downloads fail. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`.
Possible values: `allow`, `deny`.| +|`daemon.containment.engine`|enum|Sandbox engine: `mxc` (default, the only engine). Read at startup only
(restart the daemon after changing it); ignored with `--once` and `--dry-run`.
Possible values: `mxc`.| |`daemon.containment.executor`|string|Windows only. Absolute path to MXC `wxc-exec.exe`; no default. Read at
startup only (restart the daemon after changing it). Requires user
mode; ignored with `--once` and `--dry-run`.| |`daemon.copilot`|object|GitHub Copilot CLI paths (`config` = the `providers.json` to manage;
defaults to `COPILOT_PROVIDERS_CONFIG`, `$COPILOT_HOME/providers.json`,
then `~/.copilot/providers.json`).| |`daemon.copilot.config`|string|Configuration file.| From 8869d817af3808eddaf6b99733828e5750b0ce35 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Wed, 28 Oct 2026 11:06:14 +0100 Subject: [PATCH 08/19] llm-proxy: git over the relay behind an allowlist; egress denied by default The egress default flips to deny (ContainmentEgress::default = Deny); an explicit `egress: allow` logs "egress allow: the box has unrestricted outbound" at every launch. Rollback skew: a config without the key means deny here and allow to an older daemon. - Origin-form `/git//` routes on the loopback listener, session paired: smart-HTTP only (dumb protocol and /objects refused), git.allow prefix match (host case-insensitive, default port, per path element, case-sensitive path), pushAllow with git's pkt-line ERR on both legs, static credentials injected host-side (longest prefix, allowed hosts only), redirects followed by the daemon (3 hops, same allow entry), and revocation cancels in-flight requests and streaming bodies (outcome="canceled"). - egressUpstream chains the git leg to a central L7 egress endpoint (path case preserved, Host = egress host, Authorization = git credential only, a 503 retried once); https only, no gateway-hop auth in v1. - CONNECT and absolute-form requests are always refused after the pairing check (403 egress_not_supported). This reverses the listener's old "not tunnels" stance in wording only: nothing is tunnelled. A stolen session pairing now grants that session's git egress scope, not just the model relay. - Per-launch gitconfig (GIT_CONFIG_GLOBAL, GIT_TERMINAL_PROMPT=0): insteadOf rewrites, the pairing as a loopback-scoped extraHeader, loopback proxy bypass, emptied credential.helper, identity allowlist, ssh-form refusal mapping; granted read-only, swept secrets-first. The real ~/.gitconfig is denied, and ~/.config/git/config is denied only when its parent directory exists at plan time (the sandbox engine refuses a denied path under a missing folder, and that home path is unreachable in the box by default, so the deny is belt-and-braces). - Request log lines gain host and decision (allow/refuse/upstream). All new tests live in the Linux-gated llm_proxy test module (cfg(all(test, target_os = "linux"))); Windows runs none of them. The request-bytes snapshot and integrity-root tests pin the gitconfig grants and denials. Co-Authored-By: Claude Fable 5 --- README.md | 72 +- crates/agent/src/daemon.rs | 21 +- crates/agent/src/launch/mod.rs | 236 +- crates/agent/src/launch/profile.rs | 12 +- .../src/launch/testdata/request-claude.json | 6 +- crates/agent/src/launch/tests.rs | 106 +- crates/agent/src/launch/writer_tests.rs | 34 +- crates/agent/src/llm_proxy/mod.rs | 2917 ++++++++++++++++- crates/agent/src/llm_proxy/sessions.rs | 9 + crates/core/src/config.rs | 344 +- schema/daemon-config.json | 102 +- schema/daemon-config.md | 22 +- 12 files changed, 3780 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index c7e33be4..8592f795 100644 --- a/README.md +++ b/README.md @@ -327,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): @@ -699,10 +700,55 @@ daemon: listen: 127.0.0.1:4000 containment: executor: C:\Program Files\mxc\x64\wxc-exec.exe - # egress: deny # opt-in: the box loses direct outbound network + # 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 only) + # 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: + # egressUpstream: # chain the git leg through a central agentgateway + # url: https://egress.example.com ``` +**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 get a clear refusal (ssh is not available in the box yet). +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. + `run` needs the local LLM proxy: without `daemon.llmProxy.listen` it answers `409`. @@ -730,18 +776,16 @@ 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, `~\.gitconfig` and `~\.config\git\config` when they exist (git reads them -before anything else; open egress means a git config can carry secrets, for -example `http..extraHeader` with a bearer PAT, or credentials in -`url.*.insteadOf` URLs; `[include]` and `[includeIf]` targets of the git configuration -are not granted, so git fails closed in the box when a config uses them), `~\.claude\settings.json`, `~\.claude\hooks`, +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). Each file or folder in that list is denied when it does not exist, so the box -cannot create it in place, except for the git configuration files (granted when -present, not emitted otherwise); a workspace without a `.claude` folder gets +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 @@ -754,13 +798,14 @@ with its own config and hooks, or into `\HEAD` so that git treats the works 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`. The workspace may not be a drive or share root, the home +`~\.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`, the daemon's state directory or a denied path (`%ProgramFiles(x86)%` -counts as a system directory). Outbound network is open by default (`daemon.containment.egress: -deny` opts out); the box can reach every loopback listener on this machine +counts as a system directory). 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. @@ -769,7 +814,8 @@ 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. Sign-in still happens once, at `run` time. The session +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 diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index ec5df781..29f0fc2d 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -760,12 +760,21 @@ where .and_then(|listener| listener.local_addr().ok()), session_policy, egress: args.containment_egress, + git: args.containment.as_ref().and_then(|c| c.git.clone()), 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. @@ -778,6 +787,8 @@ where 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 @@ -2482,6 +2493,8 @@ programs: 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() } @@ -2566,7 +2579,8 @@ programs: } #[test] - fn containment_egress_defaults_to_allow_when_absent() { + 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(), @@ -2574,10 +2588,7 @@ programs: PathBuf::from(DEFAULT_SOCKET_PATH), ) .unwrap(); - assert_eq!( - resolved.containment_egress, - config::ContainmentEgress::Allow - ); + assert_eq!(resolved.containment_egress, config::ContainmentEgress::Deny); } fn startup_with_llm_proxy(listen: &str) -> config::DaemonStartupConfig { diff --git a/crates/agent/src/launch/mod.rs b/crates/agent/src/launch/mod.rs index 9b88834b..9f481d99 100644 --- a/crates/agent/src/launch/mod.rs +++ b/crates/agent/src/launch/mod.rs @@ -418,6 +418,15 @@ pub(crate) fn plan(input: &LaunchInput, profile: &profile::ClaudeCodeProfile) -> format!("{}: {}", crate::llm_proxy::PAIRING_HEADER, input.pairing), ); env.insert("HOME".into(), home.clone()); + // AC 6: the box's git reads only the generated per-launch gitconfig. + // GIT_CONFIG_GLOBAL replaces both global files (XDG and ~/.gitconfig), + // which also fixes the documented [include] fail-closed breakage and + // stops the box reading PATs out of the user's configuration. + let git_config = join(&launch_dir, GIT_CONFIG_FILE); + env.insert("GIT_CONFIG_GLOBAL".into(), git_config.clone()); + // A 401 or refusal fails fast instead of git prompting on a console + // that cannot answer. + env.insert("GIT_TERMINAL_PROMPT".into(), "0".into()); let mut readonly: Vec = Vec::new(); let mut denied: Vec = Vec::new(); @@ -431,14 +440,15 @@ pub(crate) fn plan(input: &LaunchInput, profile: &profile::ClaudeCodeProfile) -> git_dir, Some(client_exe_dir), program_files_claude, - Some(join(&home, profile.git_user_config)), - Some(join(&home, profile.git_xdg_config)), ]; for candidate in runtime.into_iter().flatten() { if exists(&candidate) && !readonly.contains(&candidate) { readonly.push(candidate); } } + // The generated gitconfig: exactly the file, read-only (it is written + // before the box starts). + readonly.push(git_config); // Executable configuration: read-only when present, denied when absent, so // the box can neither change nor create it. let mut executable_config: Vec = profile @@ -492,6 +502,29 @@ pub(crate) fn plan(input: &LaunchInput, profile: &profile::ClaudeCodeProfile) -> for secret in profile.denied_secrets { denied.push(join(&home, secret)); } + // AC 6: both real git user-configuration paths are denied, not granted + // (the box reads the generated file via GIT_CONFIG_GLOBAL). Under MXC an + // ungranted home path is unreachable by default, but we deny explicitly, + // matching the denied-secrets posture. MXC's denied-path rule requires + // the parent directory to exist at launch time (CreateProcessSecurityEnvironment + // 0x80070003), else launch fails. `~/.gitconfig`'s parent ($HOME) always + // exists; the XDG path's parent (`~/.config/git`) may not, so we only + // deny it when the parent is present. Skipping the XDG deny when the + // parent is absent is safe: under MXC that home path is unreachable anyway. + denied.push(join(&home, profile.git_user_config)); + let xdg_config_path = join(&home, profile.git_xdg_config); + // The XDG config path's parent directory may not exist on Windows hosts + // without `~/.config/git`. MXC's denied-path rule requires the parent to + // exist (CreateProcessSecurityEnvironment 0x80070003), so we only deny + // the XDG config when the parent exists. Extract the parent by finding + // the last backslash (these are Windows `\`-joined paths, even when + // tested on Linux). + if let Some(last_sep) = xdg_config_path.rfind('\\') { + let parent = &xdg_config_path[..last_sep]; + if exists(parent) { + denied.push(xdg_config_path); + } + } plan::LaunchPlan { program: input.executable.clone(), @@ -659,6 +692,14 @@ pub trait LaunchHost: Send + Sync { Arc::new(integrity::REAL_FS) } fn managed_sources(&self) -> crate::provider::claude_code::managed::ManagedSources; + + /// The user's global git identity values the generated gitconfig copies + /// read-only (AC 6), read host-side via `git config --global --get` + /// (`--get-all` for the multi-valued `safe.directory`), which honors + /// includes. Keys with no value are absent. + fn git_identity(&self) -> Vec<(String, Vec)> { + read_git_identity() + } } /// Running launches plus the last 32 exited ones. Clones share state. @@ -755,6 +796,9 @@ pub struct LaunchState { pub(crate) proxy_address: Option, pub(crate) session_policy: Arc, pub(crate) egress: agentdesktop_core::config::ContainmentEgress, + /// `daemon.containment.git`: the generated gitconfig's insteadOf rewrites + /// come from its `allow` list. + pub(crate) git: Option, pub(crate) engine: Arc, } @@ -958,6 +1002,33 @@ async fn start_launch( if let Err(error) = create_launch_dir(&launch_dir) { return fail(&launch_dir, format!("prepare launch: {error:#}")); } + // AC 6: the per-launch gitconfig is written before the box starts; the + // plan grants exactly this file read-only and points GIT_CONFIG_GLOBAL + // at it. + let git_allow = state + .git + .as_ref() + .map(|git| git.allow.clone()) + .unwrap_or_default(); + // The identity read spawns up to a handful of `git config` processes: + // off the async runtime, like the posture snapshot below. + let identity = { + let host = host.clone(); + tokio::task::spawn_blocking(move || host.git_identity()) + .await + .unwrap_or_default() + }; + if let Err(error) = generate_gitconfig( + &launch_dir, + &session.pairing, + input.proxy_address.port(), + &git_allow, + &identity, + ) { + return fail(&launch_dir, format!("prepare launch: {error:#}")); + } + // AC 4: deny is the default; an explicit allow is loud. + warn_on_open_egress(state.egress); let exists = |path: &Path| host.exists(path); let is_dir = |path: &Path| host.is_dir(path); let launch_plan = plan( @@ -1247,7 +1318,7 @@ fn copy_log_lines(launch_id: &str, log_path: &Path, offset: u64) -> u64 { } /// Removes what a previous daemon left behind in `\launches`: -/// `credential\` and `request.json` of every launch, and the whole launch +/// `credential\`, `request.json` and `git_config` of every launch, and the whole launch /// directory when `\pid` (`" "`) no longer names a /// live process according to `alive(pid, start_time)`. pub fn sweep(state_dir: &Path, alive: &dyn Fn(u32, u64) -> bool) -> anyhow::Result<()> { @@ -1277,8 +1348,13 @@ pub fn sweep(state_dir: &Path, alive: &dyn Fn(u32, u64) -> bool) -> anyhow::Resu .and_then(|text| parse_pid_file(&text)) .is_some_and(|(pid, start_time)| alive(pid, start_time)); // Secrets go first either way, so a directory that cannot be removed - // as a whole never keeps a token behind. - for secret in [dir.join("credential"), dir.join("request.json")] { + // as a whole never keeps a token behind. The generated gitconfig is + // one of them: it carries the launch's session pairing. + for secret in [ + dir.join("credential"), + dir.join("request.json"), + dir.join(GIT_CONFIG_FILE), + ] { let removed = if secret.is_dir() { std::fs::remove_dir_all(&secret) } else { @@ -1308,3 +1384,153 @@ fn parse_pid_file(text: &str) -> Option<(u32, u64)> { let start_time = parts.next()?.parse().ok()?; Some((pid, start_time)) } + +// --- PR 4: the generated per-launch gitconfig (AC 6). + +/// File name of the generated gitconfig in the launch directory. Same +/// lifecycle as the launch's other secrets: created before the box starts, +/// removed with the launch directory, and in the secrets-first `sweep` list +/// (it carries the session pairing; never log its content). +pub(crate) const GIT_CONFIG_FILE: &str = "git_config"; + +/// The identity allowlist the generated gitconfig copies from the user's +/// global git configuration, and whether the key is multi-valued (read with +/// `--get-all`). Intentionally NOT copied: aliases, advanced core settings, +/// credential helpers. +pub(crate) const GIT_IDENTITY_KEYS: &[(&str, bool)] = &[ + ("user.name", false), + ("user.email", false), + ("user.signingkey", false), + ("commit.gpgsign", false), + ("core.longpaths", false), + ("safe.directory", true), +]; + +/// Reads the identity allowlist host-side via `git config --global --get` +/// (`--get-all` for `safe.directory`), which honors includes. Absent keys and +/// a missing git are simply skipped: the box then commits without them. +fn read_git_identity() -> Vec<(String, Vec)> { + let mut identity = Vec::new(); + for (key, multi) in GIT_IDENTITY_KEYS { + let get = if *multi { "--get-all" } else { "--get" }; + let Ok(output) = std::process::Command::new("git") + .args(["config", "--global", get, key]) + .output() + else { + continue; + }; + if !output.status.success() { + continue; + } + let values: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned) + .collect(); + if !values.is_empty() { + identity.push(((*key).to_owned(), values)); + } + } + identity +} + +/// The authority (verbatim, port only when configured) and path of an allow +/// entry; the route shape mirrors the entry so the insteadOf rewrite is an +/// exact string prefix of the configured remote URL. +fn split_prefix_authority(entry: &str) -> Option<(&str, &str)> { + let rest = entry + .strip_prefix("https://") + .or_else(|| entry.strip_prefix("http://"))?; + Some(match rest.find('/') { + Some(index) => (&rest[..index], &rest[index..]), + None => (rest, ""), + }) +} + +/// A git config value in git's quoted form: `\` and `"` escaped, so `;`, `#` +/// and Windows paths survive git's value parser. +fn git_config_quote(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +/// Renders the per-launch gitconfig (AC 6): insteadOf rewrites of every +/// allowed prefix to the loopback `/git/...` route, the session pairing as a +/// url-scoped `extraHeader` (sent only to the loopback relay), an empty +/// url-scoped `proxy` (so the loopback requests bypass any ambient proxy the +/// user's environment carries — the daemon itself sets no proxy variables), +/// an emptied `credential.helper`, the copied identity values, and ssh-form +/// insteadOf mappings of allowlisted hosts to the `/git-ssh` refusal route. +pub(crate) fn gitconfig_contents( + pairing: &str, + proxy_port: u16, + git_allow: &[String], + identity: &[(String, Vec)], +) -> String { + let loopback = format!("http://127.0.0.1:{proxy_port}/"); + let mut contents = String::new(); + contents.push_str("# Generated by agentdesktop for one sandboxed launch; do not edit.\n"); + // Git for Windows' SYSTEM config sets credential.helper=manager and the + // git install dir is granted, so the empty first entry is necessary. + contents.push_str("[credential]\n\thelper =\n"); + contents.push_str(&format!( + "[http \"{loopback}\"]\n\textraHeader = {}: {pairing}\n\tproxy =\n", + crate::llm_proxy::PAIRING_HEADER + )); + let mut ssh_hosts: Vec<&str> = Vec::new(); + for entry in git_allow { + let Some((authority, path)) = split_prefix_authority(entry) else { + continue; + }; + contents.push_str(&format!( + "[url \"{loopback}git/{authority}{path}\"]\n\tinsteadOf = {}\n", + git_config_quote(entry), + )); + let host = authority.split(':').next().unwrap_or(authority); + if !host.is_empty() && !ssh_hosts.contains(&host) { + ssh_hosts.push(host); + } + } + for host in ssh_hosts { + contents.push_str(&format!( + "[url \"{loopback}git-ssh/{host}/\"]\n\tinsteadOf = ssh://{host}/\n\tinsteadOf = git@{host}:\n", + )); + } + for (key, values) in identity { + let Some((section, name)) = key.split_once('.') else { + continue; + }; + contents.push_str(&format!("[{section}]\n")); + for value in values { + if value.chars().any(char::is_control) { + continue; + } + contents.push_str(&format!("\t{name} = {}\n", git_config_quote(value))); + } + } + contents +} + +/// Writes the per-launch gitconfig into the launch directory (mode 0600; the +/// box gets exactly the file granted read-only) and returns its path. +pub(crate) fn generate_gitconfig( + launch_dir: &Path, + pairing: &str, + proxy_port: u16, + git_allow: &[String], + identity: &[(String, Vec)], +) -> anyhow::Result { + let path = launch_dir.join(GIT_CONFIG_FILE); + let contents = gitconfig_contents(pairing, proxy_port, git_allow, identity); + secure_fs::atomic_write(&path, contents.as_bytes(), 0o600) + .with_context(|| format!("write {}", path.display()))?; + Ok(path) +} + +/// AC 4: the egress default is deny; an explicit `egress: allow` keeps +/// today's open outbound and is called out once per launch. +pub(crate) fn warn_on_open_egress(egress: agentdesktop_core::config::ContainmentEgress) { + if egress == agentdesktop_core::config::ContainmentEgress::Allow { + tracing::warn!("egress allow: the box has unrestricted outbound"); + } +} diff --git a/crates/agent/src/launch/profile.rs b/crates/agent/src/launch/profile.rs index bce51244..90082b81 100644 --- a/crates/agent/src/launch/profile.rs +++ b/crates/agent/src/launch/profile.rs @@ -18,13 +18,13 @@ pub(crate) struct ClaudeCodeProfile { /// part of the request: this entry sits between the executable's /// directory and the git installation. pub runtime_share: &'static str, - /// The git user configuration (home-relative), granted read-only when it - /// exists; comes after the discovered directories (git reads the user's - /// configuration before anything else; unreadable means every git - /// command fails in the box, lab L4e). + /// The git user configuration (home-relative). Since PR 4 this is DENIED + /// (not granted): the box reads the generated per-launch gitconfig via + /// `GIT_CONFIG_GLOBAL` instead, so the real file (and any PAT in it) + /// stays out of reach. pub git_user_config: &'static str, - /// The XDG fallback of the git user configuration (`\`-joined), granted - /// read-only directly after `git_user_config`. + /// The XDG fallback of the git user configuration (`\`-joined), denied + /// alongside `git_user_config` since PR 4. pub git_xdg_config: &'static str, /// Executable configuration under `\.claude`: read-only when /// present, denied when absent, so the box can neither change nor diff --git a/crates/agent/src/launch/testdata/request-claude.json b/crates/agent/src/launch/testdata/request-claude.json index 20e12848..9e418b30 100644 --- a/crates/agent/src/launch/testdata/request-claude.json +++ b/crates/agent/src/launch/testdata/request-claude.json @@ -22,6 +22,8 @@ "CLAUDE_CONFIG_DIR=C:\\Users\\dev\\.claude", "ComSpec=C:\\Windows\\system32\\cmd.exe", "DISABLE_AUTOUPDATER=1", + "GIT_CONFIG_GLOBAL=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\git_config", + "GIT_TERMINAL_PROMPT=0", "HOME=C:\\Users\\dev", "HOMEDRIVE=C:", "HOMEPATH=\\Users\\dev", @@ -52,6 +54,7 @@ "C:\\Program Files\\Git", "C:\\lab\\agentdesktop", "C:\\Program Files\\ClaudeCode", + "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\git_config", "C:\\Users\\dev\\.claude\\settings.json", "C:\\Users\\dev\\.claude\\plugins", "C:\\lab\\ws pr1\\.claude\\settings.local.json", @@ -71,7 +74,8 @@ "C:\\Users\\dev\\.kube", "C:\\lab\\ws pr1\\.git\\commondir", "C:\\Users\\dev\\.claude\\ide", - "C:\\lab\\ws pr1\\HEAD" + "C:\\lab\\ws pr1\\HEAD", + "C:\\Users\\dev\\.gitconfig" ] }, "network": { diff --git a/crates/agent/src/launch/tests.rs b/crates/agent/src/launch/tests.rs index 243f3053..d651db3a 100644 --- a/crates/agent/src/launch/tests.rs +++ b/crates/agent/src/launch/tests.rs @@ -107,6 +107,8 @@ fn executor_cfg(path: &str) -> ContainmentStartupConfig { executor: PathBuf::from(path), egress: agentdesktop_core::config::ContainmentEgress::default(), engine: agentdesktop_core::config::ContainmentEngine::default(), + egress_upstream: None, + git: None, } } @@ -1004,8 +1006,10 @@ fn the_integrity_roots_are_protected_in_the_request() { r"C:\lab\agentdesktop".into(), r"C:\Program Files\ClaudeCode".into(), r"C:\Users\dev\.gitconfig".into(), - r"C:\Users\dev\.config\git\config".into(), + // XDG config is conditionally denied (only when parent exists); it's + // not in the default snapshot's denied list. format!(r"{LAUNCH_DIR}\credential"), + format!(r"{LAUNCH_DIR}\git_config"), r"C:\Users\dev\.claude\.credentials.json".into(), r"C:\Users\dev\.claude\history.jsonl".into(), r"C:\Users\dev\.claude\ide".into(), @@ -1332,12 +1336,15 @@ fn a_git_file_of_a_worktree_or_submodule_is_left_as_it_is() { existing.remove(gone); } let request = request_json(&existing, &directories, |_| {}); + // PR 4: the home git user-configuration files are always denied and the + // generated git_config always granted, so the check is scoped to the + // workspace `.git` the test is about. for path in readwrite(&request) .into_iter() .chain(readonly(&request)) .chain(denied(&request)) { - assert!(!path.contains(r"\.git"), "{path}"); + assert!(!path.contains(r"C:\lab\ws pr1\.git"), "{path}"); } } @@ -1363,6 +1370,47 @@ fn credential_and_ssh_style_paths_are_denied_whether_or_not_they_exist() { } } +#[test] +fn git_user_config_always_denied_and_xdg_config_denied_only_when_parent_exists() { + // PR 4 AC 6: `~/.gitconfig` is always denied (parent `$HOME` always exists). + // The XDG path `~/.config/git/config` is denied only when its parent + // `~/.config/git` exists, to satisfy MXC's denied-path parent rule. + let gitconfig = r"C:\Users\dev\.gitconfig"; + let xdg_config = r"C:\Users\dev\.config\git\config"; + let xdg_parent = r"C:\Users\dev\.config\git"; + + // Case 1: XDG parent directory exists. + let mut existing_with_parent = snapshot_existing(); + existing_with_parent.insert(xdg_parent.to_owned()); + let mut dirs_with_parent = snapshot_directories(); + dirs_with_parent.insert(xdg_parent.to_owned()); + let request_parent_exists = request_json(&existing_with_parent, &dirs_with_parent, |_| {}); + + assert!( + denied(&request_parent_exists).contains(&gitconfig.to_owned()), + "~/.gitconfig is always denied" + ); + assert!( + denied(&request_parent_exists).contains(&xdg_config.to_owned()), + "XDG config is denied when parent exists; got {:?}", + denied(&request_parent_exists) + ); + + // Case 2: XDG parent directory does not exist. + let existing_no_parent = snapshot_existing(); + let dirs_no_parent = snapshot_directories(); + let request_parent_absent = request_json(&existing_no_parent, &dirs_no_parent, |_| {}); + + assert!( + denied(&request_parent_absent).contains(&gitconfig.to_owned()), + "~/.gitconfig is always denied even when XDG parent absent" + ); + assert!( + !denied(&request_parent_absent).contains(&xdg_config.to_owned()), + "XDG config is not denied when parent does not exist (MXC parent rule)" + ); +} + #[test] fn write_request_creates_the_private_launch_dir_and_writes_json_without_a_bom() { let temp = tempfile::tempdir().unwrap(); @@ -1713,6 +1761,11 @@ impl LaunchHost for FakeHost { self.home.clone() } + fn git_identity(&self) -> Vec<(String, Vec)> { + // Deterministic: never read the test machine's git configuration. + Vec::new() + } + fn path_dirs(&self) -> Vec { Vec::new() } @@ -2135,6 +2188,7 @@ impl Harness { proxy_address: setup.proxy_address, session_policy: setup.session_policy.clone(), egress: setup.egress, + git: None, engine: Arc::new(engine::mxc::MxcEngine), }; @@ -3532,9 +3586,11 @@ fn the_engine_reproduces_the_baseline_request_bytes() { const LAUNCH_ID: &str = "0123456789abcdef"; // Oracle (a): launch_input + snapshot_existing extended with both git - // user-configuration files, egress: Allow. The extension pins the order - // of the profile's two git user-configuration entries in `readonlyPaths`; - // re-captured at the baseline with the same fixture (ledger ## v2). + // user-configuration files, egress: Allow. Re-captured for PR 4 with the + // same fixture (ledger ## v5 writer): the git user-configuration files + // moved from readonlyPaths to deniedPaths, the generated git_config is + // granted read-only, and the box env gains GIT_CONFIG_GLOBAL and + // GIT_TERMINAL_PROMPT=0 (AC 6). const ORACLE_A: &str = r#"{ "version": "1.0.0", "containment": "processcontainer", @@ -3559,6 +3615,8 @@ fn the_engine_reproduces_the_baseline_request_bytes() { "CLAUDE_CONFIG_DIR=C:\\Users\\dev\\.claude", "ComSpec=C:\\Windows\\system32\\cmd.exe", "DISABLE_AUTOUPDATER=1", + "GIT_CONFIG_GLOBAL=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\git_config", + "GIT_TERMINAL_PROMPT=0", "HOME=C:\\Users\\dev", "HOMEDRIVE=C:", "HOMEPATH=\\Users\\dev", @@ -3589,8 +3647,7 @@ fn the_engine_reproduces_the_baseline_request_bytes() { "C:\\Program Files\\Git", "C:\\lab\\agentdesktop", "C:\\Program Files\\ClaudeCode", - "C:\\Users\\dev\\.gitconfig", - "C:\\Users\\dev\\.config\\git\\config", + "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\git_config", "C:\\Users\\dev\\.claude\\settings.json", "C:\\Users\\dev\\.claude\\plugins", "C:\\lab\\ws pr1\\.mcp.json", @@ -3610,7 +3667,9 @@ fn the_engine_reproduces_the_baseline_request_bytes() { "C:\\Users\\dev\\.ssh", "C:\\Users\\dev\\.aws", "C:\\Users\\dev\\.azure", - "C:\\Users\\dev\\.kube" + "C:\\Users\\dev\\.kube", + "C:\\Users\\dev\\.gitconfig", + "C:\\Users\\dev\\.config\\git\\config" ] }, "network": { @@ -3649,6 +3708,8 @@ fn the_engine_reproduces_the_baseline_request_bytes() { "CLAUDE_CONFIG_DIR=C:\\Users\\dev\\.claude", "ComSpec=C:\\Windows\\system32\\cmd.exe", "DISABLE_AUTOUPDATER=1", + "GIT_CONFIG_GLOBAL=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\git_config", + "GIT_TERMINAL_PROMPT=0", "HOME=C:\\Users\\dev", "HOMEDRIVE=C:", "HOMEPATH=\\Users\\dev", @@ -3679,6 +3740,7 @@ fn the_engine_reproduces_the_baseline_request_bytes() { "C:\\Program Files\\Git", "C:\\lab\\agentdesktop", "C:\\Program Files\\ClaudeCode", + "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\git_config", "C:\\Users\\dev\\.claude\\settings.json", "C:\\Users\\dev\\.claude\\plugins", "C:\\lab\\ws pr1\\.mcp.json", @@ -3698,7 +3760,8 @@ fn the_engine_reproduces_the_baseline_request_bytes() { "C:\\Users\\dev\\.ssh", "C:\\Users\\dev\\.aws", "C:\\Users\\dev\\.azure", - "C:\\Users\\dev\\.kube" + "C:\\Users\\dev\\.kube", + "C:\\Users\\dev\\.gitconfig" ] }, "network": { @@ -3735,6 +3798,8 @@ fn the_engine_reproduces_the_baseline_request_bytes() { "CLAUDE_CODE_USE_POWERSHELL_TOOL=1", "CLAUDE_CONFIG_DIR=C:\\Users\\dev\\.claude", "DISABLE_AUTOUPDATER=1", + "GIT_CONFIG_GLOBAL=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\git_config", + "GIT_TERMINAL_PROMPT=0", "HOME=C:\\Users\\dev", "PATH=C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\WindowsPowerShell\\v1.0;C:\\Users\\dev\\.local\\bin;C:\\lab\\agentdesktop", "TEMP=C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp", @@ -3748,7 +3813,9 @@ fn the_engine_reproduces_the_baseline_request_bytes() { "C:\\Users\\dev\\.claude", "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\tmp" ], - "readonlyPaths": [], + "readonlyPaths": [ + "C:\\Users\\dev\\.local\\state\\agentdesktop\\launches\\0123456789abcdef\\git_config" + ], "deniedPaths": [ "C:\\ws\\.claude", "C:\\ws\\.git", @@ -3763,7 +3830,8 @@ fn the_engine_reproduces_the_baseline_request_bytes() { "C:\\Users\\dev\\.ssh", "C:\\Users\\dev\\.aws", "C:\\Users\\dev\\.azure", - "C:\\Users\\dev\\.kube" + "C:\\Users\\dev\\.kube", + "C:\\Users\\dev\\.gitconfig" ] }, "network": { @@ -3778,16 +3846,22 @@ fn the_engine_reproduces_the_baseline_request_bytes() { }"#; // Fixture (a): both git user-configuration files exist, so the oracle - // covers their positions among the read-only grants. + // covers their positions among the read-only grants. The XDG config's + // parent directory also exists (required for MXC denied-path rule). let existing_a = { let mut existing = snapshot_existing(); existing.insert(r"C:\Users\dev\.gitconfig".to_owned()); + existing.insert(r"C:\Users\dev\.config\git".to_owned()); existing.insert(r"C:\Users\dev\.config\git\config".to_owned()); existing }; + let directories_a = { + let mut dirs = snapshot_directories(); + dirs.insert(r"C:\Users\dev\.config\git".to_owned()); + dirs + }; let exists_a = |path: &Path| existing_a.contains(path.to_str().expect("UTF-8 path")); - let is_dir_a = - |path: &Path| snapshot_directories().contains(path.to_str().expect("UTF-8 path")); + let is_dir_a = |path: &Path| directories_a.contains(path.to_str().expect("UTF-8 path")); let input_a = launch_input(&exists_a, &is_dir_a); let plan_a = plan(&input_a, &CLAUDE_CODE); let bytes_a = MxcEngine.request(&plan_a, LAUNCH_ID).unwrap(); @@ -3864,6 +3938,8 @@ fn posture_carries_engine_name() { executor: p("path/to/wxc-exec.exe"), egress: agentdesktop_core::config::ContainmentEgress::Allow, engine: agentdesktop_core::config::ContainmentEngine::Mxc, + egress_upstream: None, + git: None, }), true, true, @@ -3884,6 +3960,8 @@ fn posture_carries_engine_when_containment_unavailable() { executor: p("path/to/missing.exe"), egress: agentdesktop_core::config::ContainmentEgress::Allow, engine: agentdesktop_core::config::ContainmentEngine::Mxc, + egress_upstream: None, + git: None, }), true, true, diff --git a/crates/agent/src/launch/writer_tests.rs b/crates/agent/src/launch/writer_tests.rs index 3495b80a..bcf7f214 100644 --- a/crates/agent/src/launch/writer_tests.rs +++ b/crates/agent/src/launch/writer_tests.rs @@ -191,6 +191,10 @@ mod handler { fn home(&self) -> PathBuf { self.home.clone() } + fn git_identity(&self) -> Vec<(String, Vec)> { + // Deterministic: never read the test machine's git configuration. + Vec::new() + } fn path_dirs(&self) -> Vec { vec![] } @@ -324,6 +328,7 @@ mod handler { executor, credentials: Arc::new(Credentials), host: Arc::new(Host { home, state_dir }), + git: None, registry: LaunchRegistry::new(), availability: Some(ContainmentInfo { executor: "wxc-exec.exe".into(), @@ -409,9 +414,11 @@ mod handler { ); } - /// The user's git configuration is granted read-only when it exists (m-a). + /// PR 4 (AC 6) reverses the m-a grant: the user's real git configuration + /// is DENIED whether or not it exists, and the box reads only the + /// generated per-launch `git_config`, granted read-only. #[tokio::test] - async fn the_git_configuration_is_granted_when_it_exists() { + async fn the_real_git_configuration_is_denied_and_the_generated_one_granted() { use std::collections::{BTreeMap, BTreeSet}; let existing: BTreeSet = [r"C:\Users\dev\.gitconfig"] @@ -441,20 +448,25 @@ mod handler { is_dir: &is_dir, }); let readonly = &request.filesystem.readonly_paths; + let denied = &request.filesystem.denied_paths; + assert!( + !readonly.contains(&r"C:\Users\dev\.gitconfig".to_owned()), + "~\\.gitconfig is never granted, even when it exists; got {readonly:?}" + ); assert!( - readonly.contains(&r"C:\Users\dev\.gitconfig".to_owned()), - "~\\.gitconfig is granted when it exists; got {readonly:?}" + denied.contains(&r"C:\Users\dev\.gitconfig".to_owned()), + "~\\.gitconfig is denied; got {denied:?}" ); assert!( - !readonly.contains(&r"C:\Users\dev\.config\git\config".to_owned()), - "~\\.config\\git\\config is not in readonlyPaths when it does not exist" + !denied.contains(&r"C:\Users\dev\.config\git\config".to_owned()), + "~\\.config\\git\\config is not denied when parent does not exist (MXC parent rule); got {denied:?}" ); assert!( - !request - .filesystem - .denied_paths - .contains(&r"C:\Users\dev\.config\git\config".to_owned()), - "~\\.config\\git\\config is not in deniedPaths when it does not exist" + readonly.contains( + &r"C:\Users\dev\.local\state\agentdesktop\launches\0123456789abcdef\git_config" + .to_owned() + ), + "the generated git_config (exactly the file) is granted read-only; got {readonly:?}" ); } } diff --git a/crates/agent/src/llm_proxy/mod.rs b/crates/agent/src/llm_proxy/mod.rs index 2af8e7b2..07f45875 100644 --- a/crates/agent/src/llm_proxy/mod.rs +++ b/crates/agent/src/llm_proxy/mod.rs @@ -321,6 +321,14 @@ pub(crate) const CAPI_ROUTE: &str = "/vscode-copilot-capi"; /// it holds no credential and needs nothing but loopback. pub(crate) const SANDBOX_ROUTE: &str = "/sandbox"; +/// Git relay route for allowed git hosts. +pub(crate) const GIT_ROUTE: &str = "/git"; + +/// Refusal route the generated gitconfig maps ssh-form remotes of allowlisted +/// hosts to: it answers a pkt-line `ERR` so the user sees a clear message +/// instead of a deny-mode timeout (ssh itself is PR 5). +pub(crate) const GIT_SSH_ROUTE: &str = "/git-ssh"; + /// The prefixed routes: the Copilot programs and the sandbox route. A prefix /// matches only at a `/` boundary, so /// `/vscode-copilot-passthrough/...` never matches `/vscode-copilot`. @@ -467,6 +475,13 @@ pub(crate) struct ProxyConfig { pub pairing: Arc, /// Per-session pairings accepted on the `/sandbox` route. pub sessions: SessionRegistry, + /// `daemon.containment.git`: the fetch/push allowlists and static + /// credentials the git routes serve. `None` (or an empty allow list) + /// means no git egress: the listener is exactly the 3b shape. + pub git: Option, + /// `daemon.containment.egressUpstream.url`: when set, the git leg is sent + /// to this central L7 egress endpoint instead of directly to the remote. + pub egress_upstream: Option, } impl std::fmt::Debug for ProxyConfig { @@ -475,6 +490,11 @@ impl std::fmt::Debug for ProxyConfig { .field("default_client_id", &self.default_client_id) .field("pairing", &"") .field("sessions", &self.sessions.len(now_unix())) + .field( + "git_allow", + &self.git.as_ref().map_or(0, |git| git.allow.len()), + ) + .field("egress_upstream", &self.egress_upstream) .finish() } } @@ -690,6 +710,14 @@ pub(crate) struct SandboxRequest { pub code: Option<&'static str>, /// Buffered request body length; 0 when refused before the body was read. pub bytes_in: usize, + /// Destination host of a git or CONNECT request; `-` on model lines. + pub host: Option, + /// `allow` / `refuse` / `upstream` on git and CONNECT lines; `-` on + /// model lines. + pub decision: Option<&'static str>, + /// Set when a session revocation cut the response body short: the line + /// then reports `outcome="canceled"` (git routes only). + pub revoked: Option>, started: std::time::Instant, } @@ -703,6 +731,9 @@ impl SandboxRequest { status: 0, code: None, bytes_in: 0, + host: None, + decision: None, + revoked: None, started: std::time::Instant::now(), } } @@ -726,6 +757,8 @@ impl SandboxRequest { bytes_out, duration_ms = self.started.elapsed().as_millis() as u64, outcome, + host = self.host.as_deref().unwrap_or("-"), + decision = self.decision.unwrap_or("-"), "LLM proxy sandbox request" ); } @@ -754,6 +787,12 @@ impl LoggedBody { } fn finish(&mut self, outcome: &'static str) { + let revoked = self + .log + .revoked + .as_ref() + .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::SeqCst)); + let outcome = if revoked { "canceled" } else { outcome }; if !self.emitted { self.emitted = true; self.log.emit(self.bytes_out, outcome); @@ -1036,6 +1075,14 @@ async fn forward( { log.sandbox = Some(SandboxRequest::new(request.method(), rest)); } + // CONNECT and absolute-form requests (the forward-proxy request forms) + // skip the Host-loopback gate (their target is not this listener), carry + // their pairing as Proxy-Authorization credentials, and are ALWAYS + // refused after the pairing check: the egress architecture is L7 git + // routes on the loopback listener, never tunnels (AC 3). + if request.method() == Method::CONNECT || request.uri().scheme().is_some() { + return handle_connect_request(&request, config, log); + } if !host_is_loopback(request.headers()) { return Err(ProxyError::new( StatusCode::FORBIDDEN, @@ -1043,12 +1090,12 @@ async fn forward( "agentdesktop proxy: Host must be a loopback address", )); } - // This listener is for local native clients, not browser scripts or tunnels. - if request.headers().contains_key(ORIGIN) || request.method() == Method::CONNECT { + // This listener is for local native clients, not browser scripts. + if request.headers().contains_key(ORIGIN) { return Err(ProxyError::new( StatusCode::FORBIDDEN, "browser_not_allowed", - "agentdesktop proxy: browser requests and CONNECT are not supported", + "agentdesktop proxy: browser requests are not supported", )); } if request.method() == Method::OPTIONS { @@ -1058,6 +1105,41 @@ async fn forward( "agentdesktop proxy: OPTIONS is not supported", )); } + // The origin-form git routes (AC 5) and the ssh refusal route: session + // paired (the generated gitconfig carries the pairing in PAIRING_HEADER), + // served only for the hosts `git.allow` names. Not reachable without a + // registered session. + let git_route = match (route_rest(path, GIT_SSH_ROUTE), route_rest(path, GIT_ROUTE)) { + (Some(rest), _) => Some((true, rest)), + (None, Some(rest)) => Some((false, rest)), + (None, None) => None, + }; + if let Some((ssh_refusal, rest)) = git_route { + log.sandbox = Some(SandboxRequest::new(request.method(), rest)); + let offered = request + .headers() + .get(PAIRING_HEADER) + .and_then(|value| value.to_str().ok()); + let found = offered + .and_then(|offered| config.sessions.authorize(offered, now_unix())) + .ok_or_else(ProxyError::session_invalid)?; + if let Some(sandbox) = log.sandbox.as_mut() { + sandbox.session = Some(found.id.clone()); + sandbox.client_id = Some(found.client_id.clone()); + sandbox.decision = Some("refuse"); + } + if path_escapes_base(path) { + return Err(ProxyError::new( + StatusCode::BAD_REQUEST, + "path_invalid", + "agentdesktop proxy: path must not contain '..' segments", + )); + } + if ssh_refusal { + return Ok(ssh_refused_response(rest, query, log)); + } + return handle_git_request(request, client, config, found, rest, query, log).await; + } // A pairing value is required on every route: the device pairing, or a // session pairing on the sandbox route. It travels in its own // header, so Authorization keeps one meaning (the client's own token on the @@ -1603,6 +1685,634 @@ fn strip_hop_headers(headers: &mut HeaderMap) { } } +// --- PR 4: git over the relay (origin-form routes) and the CONNECT refusal. + +/// The smart-HTTP endpoint shapes the git routes serve (AC 5). Everything +/// else under `/git/...` — including dumb-protocol requests — is refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GitEndpoint { + FetchDiscovery, + FetchRpc, + PushDiscovery, + PushRpc, +} + +impl GitEndpoint { + fn is_push(self) -> bool { + matches!(self, Self::PushDiscovery | Self::PushRpc) + } + + fn is_rpc(self) -> bool { + matches!(self, Self::FetchRpc | Self::PushRpc) + } + + fn service(self) -> &'static str { + match self { + Self::FetchDiscovery | Self::FetchRpc => "git-upload-pack", + Self::PushDiscovery | Self::PushRpc => "git-receive-pack", + } + } +} + +/// Classifies a request under the git route by the final path segment and the +/// `service` query parameter only: discovery `GET /info/refs?service=…`, +/// RPC `POST /git-upload-pack` / `/git-receive-pack`. `None` for +/// every other shape, notably dumb-protocol requests (`info/refs` without +/// `service`, `/objects/...`). +fn classify_git_endpoint(path: &str, query: Option<&str>) -> Option { + let service = query.and_then(|query| { + query + .split('&') + .find_map(|pair| pair.strip_prefix("service=")) + }); + if path.ends_with("/info/refs") { + return match service { + Some("git-upload-pack") => Some(GitEndpoint::FetchDiscovery), + Some("git-receive-pack") => Some(GitEndpoint::PushDiscovery), + _ => None, + }; + } + match path.rsplit('/').next() { + Some("git-upload-pack") => Some(GitEndpoint::FetchRpc), + Some("git-receive-pack") => Some(GitEndpoint::PushRpc), + _ => None, + } +} + +/// A policy refusal rendered as git itself expects it: `200 OK` with the +/// service's advertisement (discovery) or result (RPC) content type and a +/// single `ERR` pkt-line, which git prints as `fatal: remote error: `. +fn pkt_line_err_response(service: &str, is_rpc: bool, message: &str) -> Response { + let kind = if is_rpc { "result" } else { "advertisement" }; + let line = format!("ERR {message}\n"); + let body = format!("{:04x}{line}", line.len() + 4); + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, format!("application/x-{service}-{kind}")) + .body( + Full::new(Bytes::from(body)) + .map_err(|never| match never {}) + .boxed(), + ) + .expect("static response") +} + +/// The upload-pack shape of [`pkt_line_err_response`]; the receive-pack legs +/// go through `pkt_line_err_response` with their own service name. +fn pkt_line_err(message: &str, is_rpc: bool) -> Response { + pkt_line_err_response("git-upload-pack", is_rpc, message) +} + +/// The path remainder after `prefix` when `path` matches it at a `/` boundary +/// (the `match_route` rule), starting with `/`. +fn route_rest<'p>(path: &'p str, prefix: &str) -> Option<&'p str> { + let rest = path.strip_prefix(prefix)?; + if rest.is_empty() { + Some("/") + } else { + rest.starts_with('/').then_some(rest) + } +} + +/// Whether `url` is under the configured prefix (AC 5): host compared +/// case-insensitively (both sides go through `url::Url`, which lowercases +/// hosts), explicit and default ports equal under `port_or_known_default`, +/// and the path matched on the configured prefix per path element — +/// case-sensitive, trailing slash normalized, never on a partial segment. +/// The scheme is not compared: startup validation pins configured prefixes +/// to `https`, and the relay connects with the prefix's scheme. +fn git_prefix_matches(prefix: &str, url: &url::Url) -> bool { + let Ok(prefix) = url::Url::parse(prefix) else { + return false; + }; + let (Some(prefix_host), Some(host)) = (prefix.host_str(), url.host_str()) else { + return false; + }; + if !prefix_host.eq_ignore_ascii_case(host) + || prefix.port_or_known_default() != url.port_or_known_default() + { + return false; + } + let wanted: Vec<&str> = prefix.path().split('/').filter(|s| !s.is_empty()).collect(); + let got: Vec<&str> = url.path().split('/').filter(|s| !s.is_empty()).collect(); + got.len() >= wanted.len() && got[..wanted.len()] == wanted[..] +} + +/// Whether a redirect target stays inside the allow entry: same scheme as the +/// entry (a Location may not downgrade the credentialed leg, e.g. +/// `http://host:443/…`, which the port-aware prefix match admits) and under +/// its prefix. +fn git_redirect_stays_in(allow_entry: &str, next: &url::Url) -> bool { + url::Url::parse(allow_entry).is_ok_and(|entry| entry.scheme() == next.scheme()) + && git_prefix_matches(allow_entry, next) +} + +/// How specific a configured prefix is: its path-element count, then its +/// length (the longest-prefix rule for credential selection). +fn git_prefix_specificity(prefix: &str) -> (usize, usize) { + let elements = url::Url::parse(prefix) + .map(|parsed| parsed.path().split('/').filter(|s| !s.is_empty()).count()) + .unwrap_or(0); + (elements, prefix.trim_end_matches('/').len()) +} + +/// The longest-prefix credential entry for the destination, if any. The +/// caller gates on the allowlist first: no credential is ever attached for a +/// host that is not in `git.allow`. +fn select_git_credential<'c>( + credentials: &'c [agentdesktop_core::config::GitCredentialEntry], + url: &url::Url, +) -> Option<&'c agentdesktop_core::config::GitCredentialEntry> { + credentials + .iter() + .filter(|entry| git_prefix_matches(&entry.url_prefix, url)) + .max_by_key(|entry| git_prefix_specificity(&entry.url_prefix)) +} + +/// The credential the relay attaches for `url`: `None` unless the destination +/// is admitted by `git.allow` (no credential is ever injected for a host +/// outside the allowlist, chained or not), else the longest-prefix static +/// entry, if any. +fn git_credential_for<'c>( + git: &'c agentdesktop_core::config::GitConfig, + url: &url::Url, +) -> Option<&'c agentdesktop_core::config::GitCredentialEntry> { + if !git + .allow + .iter() + .any(|prefix| git_prefix_matches(prefix, url)) + { + return None; + } + select_git_credential(&git.credentials, url) +} + +/// Answer on the `/git-ssh` refusal route: the generated gitconfig maps +/// ssh-form remotes of allowlisted hosts here so the user sees a pkt-line +/// `ERR` instead of a deny-mode timeout (ssh itself is PR 5). +fn ssh_refused_response( + rest: &str, + query: Option<&str>, + log: &mut RequestLog, +) -> Response { + if let Some(sandbox) = log.sandbox.as_mut() { + let host = rest.trim_start_matches('/').split('/').next().unwrap_or(""); + sandbox.host = Some(host.to_owned()); + sandbox.decision = Some("refuse"); + } + const MESSAGE: &str = + "ssh remotes are not available in the box yet (PR 5); use the https remote"; + match classify_git_endpoint(rest, query) { + Some(endpoint) if endpoint.is_push() => { + pkt_line_err_response(endpoint.service(), endpoint.is_rpc(), MESSAGE) + } + Some(endpoint) => pkt_line_err(MESSAGE, endpoint.is_rpc()), + None => pkt_line_err(MESSAGE, false), + } +} + +/// CONNECT and absolute-form requests: the pairing check first (the session +/// pairing travels as `Proxy-Authorization: Basic base64(":")`, +/// compared constant-time through the registry's digest comparison), then an +/// unconditional refusal — the product egress contract is L7 routes, there +/// is no tunnel to establish (AC 3). Never forwards, never returns `Ok`. +fn handle_connect_request( + request: &Request, + config: &ProxyConfig, + log: &mut RequestLog, +) -> Result, ProxyError> { + let host = request + .uri() + .authority() + .map(|authority| authority.to_string()) + .or_else(|| { + request + .headers() + .get(HOST) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }) + .unwrap_or_else(|| "-".to_owned()); + let mut entry = SandboxRequest::new(request.method(), "/"); + entry.host = Some(host); + entry.decision = Some("refuse"); + log.sandbox = Some(entry); + let session = request + .headers() + .get("proxy-authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + let (scheme, credentials) = value.split_once(' ')?; + scheme + .eq_ignore_ascii_case("basic") + .then(|| credentials.trim()) + }) + .and_then(|encoded| { + base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok() + }) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|credentials| { + // Pairing as the username, empty password. + let (pairing, password) = credentials.split_once(':')?; + password + .is_empty() + .then(|| config.sessions.authorize(pairing, now_unix())) + .flatten() + }) + .ok_or_else(ProxyError::session_invalid)?; + if let Some(sandbox) = log.sandbox.as_mut() { + sandbox.session = Some(session.id); + sandbox.client_id = Some(session.client_id); + } + Err(ProxyError::new( + StatusCode::FORBIDDEN, + "egress_not_supported", + "agentdesktop proxy: egress tunnels are not supported; outbound access is limited to the configured git routes", + )) +} + +/// How often an in-flight git request checks whether its session was revoked. +const REVOCATION_POLL: Duration = Duration::from_millis(50); + +/// A git response body that ends as soon as its session is revoked (AC 1). +/// The registry is read without restarting the idle timeout; the revocation +/// is recorded in `revoked` so the request log reports `outcome="canceled"`. +struct RevocableBody { + inner: ProxyBody, + sessions: SessionRegistry, + session_id: String, + check: std::pin::Pin>, + revoked: Arc, +} + +impl RevocableBody { + fn cut(&self) -> bool { + if self.revoked.load(std::sync::atomic::Ordering::SeqCst) { + return true; + } + if self.sessions.live(&self.session_id, now_unix()) { + return false; + } + self.revoked + .store(true, std::sync::atomic::Ordering::SeqCst); + true + } +} + +impl hyper::body::Body for RevocableBody { + type Data = Bytes; + type Error = hyper::Error; + + fn poll_frame( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, hyper::Error>>> { + if self.cut() { + return std::task::Poll::Ready(None); + } + let polled = std::pin::Pin::new(&mut self.inner).poll_frame(cx); + if polled.is_pending() { + // Wake up periodically to notice a revocation while the upstream + // is silent. + while self.check.as_mut().poll(cx).is_ready() { + if self.cut() { + return std::task::Poll::Ready(None); + } + let next = tokio::time::Instant::now() + REVOCATION_POLL; + self.check.as_mut().reset(next); + } + } + polled + } + + fn is_end_stream(&self) -> bool { + self.revoked.load(std::sync::atomic::Ordering::SeqCst) || self.inner.is_end_stream() + } + + fn size_hint(&self) -> hyper::body::SizeHint { + self.inner.size_hint() + } +} + +/// How many upstream redirects the daemon follows itself (never relayed to +/// the box); the next one is refused (AC 5). +const GIT_REDIRECT_HOPS: usize = 3; + +/// A request on the origin-form git route: allowlist gate, smart-HTTP +/// classification, pushAllow gate, host-side credential injection, redirect +/// following, and the chained leg through `egressUpstream` when configured +/// (direct otherwise). Revocation aborts an in-flight request; the log entry +/// then reports `outcome="canceled"`. +async fn handle_git_request( + request: Request, + client: &ProxyClient, + config: &ProxyConfig, + session: sessions::SessionRef, + rest: &str, + query: Option<&str>, + log: &mut RequestLog, +) -> Result, ProxyError> { + let refuse = |message: String| { + Err(ProxyError::new( + StatusCode::FORBIDDEN, + "git_host_not_allowed", + message, + )) + }; + // `/git//`: authority and path, taken as sent. + let without_slash = rest.trim_start_matches('/'); + let (authority, repo_path) = match without_slash.find('/') { + Some(index) => (&without_slash[..index], &without_slash[index..]), + None => (without_slash, "/"), + }; + if let Some(sandbox) = log.sandbox.as_mut() { + sandbox.host = Some(authority.to_owned()); + } + if authority.is_empty() { + return refuse("agentdesktop proxy: the git route is /git//".to_owned()); + } + // The reconstructed destination is matched against the configured + // prefixes again before connecting: the gitconfig rewrite is a + // convenience, not the gate (AC 5). + let destination_text = match query { + Some(query) => format!("https://{authority}{repo_path}?{query}"), + None => format!("https://{authority}{repo_path}"), + }; + let Ok(destination) = url::Url::parse(&destination_text) else { + return refuse(format!( + "agentdesktop proxy: not a valid git destination: {authority}{repo_path}" + )); + }; + let git = config.git.as_ref(); + let allow_entry = git.and_then(|git| { + git.allow + .iter() + .find(|prefix| git_prefix_matches(prefix, &destination)) + }); + let Some(allow_entry) = allow_entry else { + return refuse(format!( + "agentdesktop proxy: {authority} is not in daemon.containment.git.allow" + )); + }; + // The connection scheme comes from the matched allow entry; startup + // validation pins configured entries to https. + let scheme = url::Url::parse(allow_entry) + .map(|parsed| parsed.scheme().to_owned()) + .unwrap_or_else(|_| "https".to_owned()); + let mut destination = destination; + let _ = destination.set_scheme(&scheme); + let Some(endpoint) = classify_git_endpoint(repo_path, query) else { + return refuse( + "agentdesktop proxy: only the smart-HTTP git endpoints are served (info/refs?service=…, git-upload-pack, git-receive-pack)" + .to_owned(), + ); + }; + let method_ok = if endpoint.is_rpc() { + request.method() == Method::POST + } else { + request.method() == Method::GET + }; + if !method_ok { + return refuse(format!( + "agentdesktop proxy: {} is not how {} is requested", + request.method(), + endpoint.service(), + )); + } + if endpoint.is_push() { + let push_allowed = git.is_some_and(|git| { + git.push_allow + .iter() + .any(|prefix| git_prefix_matches(prefix, &destination)) + }); + if !push_allowed { + // git expects its own envelope here, not a JSON refusal. + return Ok(pkt_line_err_response( + endpoint.service(), + endpoint.is_rpc(), + &format!("push to {authority} is not allowed by daemon.containment.git.pushAllow"), + )); + } + } + // Host-side credential: static entries only in this PR, longest prefix, + // only for hosts the allowlist admitted (checked above). The box never + // sees it and the secret is never serialized back. + let credential = git + .and_then(|git| git_credential_for(git, &destination)) + .map(|entry| { + base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", entry.username, entry.secret)) + }); + + // Buffer the body so redirects and the 503 retry can resend it. + let (parts, body) = request.into_parts(); + let body = http_body_util::Limited::new(body, MAX_REQUEST_BODY) + .collect() + .await + .map_err(|error| { + if error + .downcast_ref::() + .is_some() + { + ProxyError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "body_too_large", + format!( + "agentdesktop proxy: request body exceeds {} bytes", + MAX_REQUEST_BODY + ), + ) + } else { + ProxyError::new( + StatusCode::BAD_REQUEST, + "body_invalid", + format!("agentdesktop proxy: could not read the request body: {error}"), + ) + } + })? + .to_bytes(); + if let Some(sandbox) = log.sandbox.as_mut() { + sandbox.bytes_in = body.len(); + } + let mut base_headers = parts.headers; + strip_hop_headers(&mut base_headers); + for header in [ + AUTHORIZATION.as_str(), + "x-api-key", + "api-key", + "x-llm-token", + PAIRING_HEADER, + ] { + base_headers.remove(header); + } + + let decision = if config.egress_upstream.is_some() { + "upstream" + } else { + "allow" + }; + let mut current = destination; + let mut hops = 0; + let mut retried_503 = false; + loop { + // The git leg: direct, or chained as plain smart-HTTP to the central + // L7 egress endpoint with the path case preserved and Host/SNI = the + // egress host. The Authorization header carries only the git + // credential — no gateway-hop auth in v1 (AC 2). + let target = match &config.egress_upstream { + Some(upstream) => { + let path_and_query = match current.query() { + Some(query) => format!("{}?{query}", current.path()), + None => current.path().to_owned(), + }; + format!( + "{}{path_and_query}", + upstream.as_str().trim_end_matches('/') + ) + } + None => current.to_string(), + }; + let uri: Uri = target.parse().map_err(|_| { + ProxyError::new( + StatusCode::BAD_GATEWAY, + "git_upstream_unreachable", + "agentdesktop proxy: could not form the upstream git URL", + ) + })?; + let host_value = HeaderValue::from_str( + uri.authority() + .map(|authority| authority.as_str()) + .unwrap_or(""), + ) + .map_err(|_| { + ProxyError::new( + StatusCode::BAD_GATEWAY, + "git_upstream_unreachable", + "agentdesktop proxy: invalid upstream git host", + ) + })?; + let mut attempt = Request::new(Full::new(body.clone())); + *attempt.method_mut() = parts.method.clone(); + *attempt.uri_mut() = uri; + *attempt.headers_mut() = base_headers.clone(); + attempt.headers_mut().insert(HOST, host_value); + if let Some(credential) = &credential { + let mut value = + HeaderValue::from_str(&format!("Basic {credential}")).map_err(|_| { + ProxyError::new( + StatusCode::BAD_GATEWAY, + "git_upstream_unreachable", + "agentdesktop proxy: invalid git credential header", + ) + })?; + value.set_sensitive(true); + attempt.headers_mut().insert(AUTHORIZATION, value); + } + // Revocation cancels an in-flight request (AC 1): the watch polls the + // registry read-only, so waiting does not keep the session alive. + let revoked = async { + loop { + if !config.sessions.live(&session.id, now_unix()) { + return; + } + tokio::time::sleep(REVOCATION_POLL).await; + } + }; + // `biased` with the revoked arm first: the revoked future checks + // `sessions.live` on its first poll, so a session revoked during body + // collect or between hops never sends anything upstream. + let response = tokio::select! { + biased; + () = revoked => { + if let Some(sandbox) = log.sandbox.take() { + sandbox + .with_status(StatusCode::FORBIDDEN.as_u16(), Some("pairing_invalid")) + .emit(0, "canceled"); + } + return Err(ProxyError::session_invalid()); + } + sent = tokio::time::timeout(RESPONSE_HEADERS_TIMEOUT, client.request(attempt)) => sent, + }; + let response = response + .map_err(|_| { + ProxyError::new( + StatusCode::GATEWAY_TIMEOUT, + "git_upstream_unreachable", + "agentdesktop proxy: the git upstream did not answer in time", + ) + })? + .map_err(|error| { + ProxyError::new( + StatusCode::BAD_GATEWAY, + "git_upstream_unreachable", + format!("agentdesktop proxy: git upstream unreachable: {error}"), + ) + })?; + // A 503 from the egress upstream is retried exactly once (observed + // once in validation, assumed transient), except a push RPC, which + // is not replayed (DV-7: a replayed push is not idempotent); the + // second — or a push's first — surfaces. + if config.egress_upstream.is_some() + && response.status() == StatusCode::SERVICE_UNAVAILABLE + && !retried_503 + && !matches!(endpoint, GitEndpoint::PushRpc) + { + retried_503 = true; + continue; + } + // Redirects are never relayed to the box: followed here, at most + // GIT_REDIRECT_HOPS hops, only while the target keeps the allow + // entry's scheme and prefix. + if response.status().is_redirection() { + let location = response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()) + .and_then(|location| current.join(location).ok()); + let Some(next) = location else { + return refuse(format!( + "agentdesktop proxy: {authority} answered with an unusable redirect" + )); + }; + if hops >= GIT_REDIRECT_HOPS { + return refuse(format!( + "agentdesktop proxy: {authority} redirected more than {GIT_REDIRECT_HOPS} times" + )); + } + if !git_redirect_stays_in(allow_entry, &next) { + return refuse(format!( + "agentdesktop proxy: {authority} redirected outside the allowed prefix" + )); + } + hops += 1; + current = next; + continue; + } + let revoked = Arc::new(std::sync::atomic::AtomicBool::new(false)); + if let Some(sandbox) = log.sandbox.as_mut() { + sandbox.decision = Some(decision); + sandbox.revoked = Some(revoked.clone()); + } + let mut response = response; + strip_hop_headers(response.headers_mut()); + // Revocation also cuts a response body that is already streaming + // (a long pack transfer), not just a request waiting for headers. + let sessions = config.sessions.clone(); + return Ok(response.map(|body| { + RevocableBody { + inner: body.boxed(), + sessions, + session_id: session.id.clone(), + check: Box::pin(tokio::time::sleep(REVOCATION_POLL)), + revoked, + } + .boxed() + })); + } +} + #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; @@ -1841,6 +2551,8 @@ mod tests { default_client_id: "vscode".into(), pairing: Arc::from(PAIRING), sessions: SessionRegistry::default(), + git: None, + egress_upstream: None, } } @@ -3832,6 +4544,7 @@ mod tests { ) } + #[derive(Clone)] struct Upstreamed { uri: String, headers: HeaderMap, @@ -3881,15 +4594,109 @@ mod tests { (address, seen, task) } - // The sandbox route is the copilot-cli shape with a session pairing: prefix - // stripped, every client credential and the pairing removed, the gateway - // identity added, other headers kept, and nothing forwarded when refused. - #[tokio::test] - async fn sandbox_route_accepts_a_session_pairing_and_forwards_with_the_gateway_identity() { - tokio::time::timeout(Duration::from_secs(15), async { - let (address, seen, upstream_task) = recording_upstream().await; - let (dir, state) = request_source_state(address).await; - let config = default_config(); + /// An upstream that answers with the scripted statuses in order (200 "ok" + /// once the script runs out) and counts the requests it saw. + async fn scripted_upstream( + script: Vec, + ) -> ( + std::net::SocketAddr, + Arc, + tokio::task::JoinHandle<()>, + ) { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = hits.clone(); + let task = tokio::spawn(async move { + loop { + let (socket, _) = upstream.accept().await.unwrap(); + let counter = counter.clone(); + let script = script.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| { + let counter = counter.clone(); + let script = script.clone(); + async move { + let _ = request.into_body().collect().await; + let index = + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let status = script.get(index).copied().unwrap_or(200); + Ok::<_, Infallible>( + Response::builder() + .status(status) + .body(Full::new(Bytes::from_static(b"ok"))) + .unwrap(), + ) + } + }), + ) + .await; + }); + } + }); + (address, hits, task) + } + + /// Sends raw request bytes over a fresh TCP connection (hyper's client + /// cannot send CONNECT or absolute-form request targets) and returns the + /// response status plus the full response text once the Content-Length + /// body arrived. + async fn raw_http(proxy: std::net::SocketAddr, request: String) -> (u16, String) { + let mut stream = TcpStream::connect(proxy).await.unwrap(); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut buffer = Vec::new(); + loop { + let mut chunk = [0u8; 4096]; + let read = stream.read(&mut chunk).await.unwrap(); + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + let text = String::from_utf8_lossy(&buffer); + if let Some(header_end) = text.find("\r\n\r\n") { + let length = text[..header_end] + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if buffer.len() >= header_end + 4 + length { + break; + } + } + } + let text = String::from_utf8_lossy(&buffer).into_owned(); + let status = text + .split(' ') + .nth(1) + .and_then(|status| status.parse().ok()) + .unwrap_or_else(|| panic!("no status line in {text:?}")); + (status, text) + } + + /// The `error.code` of a raw refusal (the JSON body after the headers). + fn raw_error_code(text: &str) -> String { + let body = text.split("\r\n\r\n").nth(1).unwrap_or(""); + let json: serde_json::Value = + serde_json::from_str(body).unwrap_or_else(|_| panic!("no JSON body in {text:?}")); + json["error"]["code"].as_str().unwrap_or("").to_owned() + } + + // The sandbox route is the copilot-cli shape with a session pairing: prefix + // stripped, every client credential and the pairing removed, the gateway + // identity added, other headers kept, and nothing forwarded when refused. + #[tokio::test] + async fn sandbox_route_accepts_a_session_pairing_and_forwards_with_the_gateway_identity() { + tokio::time::timeout(Duration::from_secs(15), async { + let (address, seen, upstream_task) = recording_upstream().await; + let (dir, state) = request_source_state(address).await; + let config = default_config(); let sessions = config.sessions.clone(); let session = new_session(&sessions, "claude-code"); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -5048,4 +5855,2090 @@ mod tests { .await .unwrap(); } + // PR 4 tests: forward proxy, git relay, deny default + + #[test] + fn git_endpoint_classification_fetch_discovery() { + let result = classify_git_endpoint("/repo.git/info/refs", Some("service=git-upload-pack")); + assert_eq!(result, Some(GitEndpoint::FetchDiscovery)); + } + + #[test] + fn git_endpoint_classification_push_discovery() { + let result = classify_git_endpoint("/repo.git/info/refs", Some("service=git-receive-pack")); + assert_eq!(result, Some(GitEndpoint::PushDiscovery)); + } + + #[test] + fn git_endpoint_classification_fetch_rpc() { + let result = classify_git_endpoint("/repo.git/git-upload-pack", None); + assert_eq!(result, Some(GitEndpoint::FetchRpc)); + } + + #[test] + fn git_endpoint_classification_push_rpc() { + let result = classify_git_endpoint("/repo.git/git-receive-pack", None); + assert_eq!(result, Some(GitEndpoint::PushRpc)); + } + + #[test] + fn git_endpoint_classification_dumb_protocol_refused() { + let result = classify_git_endpoint("/repo.git/info/refs", None); + assert_eq!(result, None); + } + + #[test] + fn git_endpoint_classification_objects_refused() { + let result = classify_git_endpoint("/repo.git/objects/ab/cd1234", None); + assert_eq!(result, None); + } + + #[test] + fn pkt_line_err_discovery_format() { + let response = pkt_line_err("push not allowed", false); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/x-git-upload-pack-advertisement" + ); + } + + #[test] + fn pkt_line_err_rpc_format() { + let response = pkt_line_err("host not allowed", true); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/x-git-upload-pack-result" + ); + } + + #[tokio::test] + async fn git_route_requires_pairing() { + tokio::time::timeout(Duration::from_secs(10), async { + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let _session = new_session(&sessions, "test-client"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + + // Request without pairing header should fail + let request = Request::builder() + .method(Method::GET) + .uri(format!( + "http://{}/git/github.com/org/repo.git/info/refs?service=git-upload-pack", + proxy_address + )) + .body(Full::new(Bytes::new())) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let (_, code, _) = refusal(response).await; + assert_eq!(code, "pairing_invalid"); + + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn git_route_host_not_allowed() { + tokio::time::timeout(Duration::from_secs(10), async { + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "test-client"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + + let mut request = Request::builder() + .method(Method::GET) + .uri(format!( + "http://{}/git/evil.com/repo.git/info/refs?service=git-upload-pack", + proxy_address + )) + .body(Full::new(Bytes::new())) + .unwrap(); + request.headers_mut().insert( + PAIRING_HEADER, + HeaderValue::from_str(&session.pairing).unwrap(), + ); + + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let (_, code, _) = refusal(response).await; + assert_eq!(code, "git_host_not_allowed"); + + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn git_leg_chains_to_the_egress_upstream() { + tokio::time::timeout(Duration::from_secs(10), async { + // The stub plays the central L7 egress endpoint (AC 2). + let (upstream_address, seen, upstream_task) = recording_upstream().await; + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let mut config = default_config(); + config.git = Some(agentdesktop_core::config::GitConfig { + allow: vec!["https://github.com/Org/".to_owned()], + push_allow: vec![], + credentials: vec![agentdesktop_core::config::GitCredentialEntry { + url_prefix: "https://github.com/".to_owned(), + username: "git-user".to_owned(), + secret: "git-pat".to_owned(), + }], + }); + config.egress_upstream = + Some(url::Url::parse(&format!("http://{upstream_address}/")).unwrap()); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "test-client"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + + let mut request = Request::builder() + .method(Method::GET) + .uri(format!( + "http://{proxy_address}/git/github.com/Org/MixedCase.git/info/refs?service=git-upload-pack" + )) + .body(Full::new(Bytes::new())) + .unwrap(); + request.headers_mut().insert( + PAIRING_HEADER, + HeaderValue::from_str(&session.pairing).unwrap(), + ); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let seen = std::mem::take(&mut *seen.lock().unwrap()); + assert_eq!(seen.len(), 1); + // Path case preserved, no /git prefix, no destination host in the path. + assert_eq!( + seen[0].uri, + "/Org/MixedCase.git/info/refs?service=git-upload-pack" + ); + // Host = the egress host, not the destination. + assert_eq!( + seen[0].headers[HOST], + upstream_address.to_string().as_str() + ); + // Authorization carries ONLY the git credential: no gateway-hop + // auth header in v1 (AC 2, F-B deferral). + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("git-user:git-pat") + ); + let authorization: Vec<_> = seen[0].headers.get_all(AUTHORIZATION).iter().collect(); + assert_eq!(authorization, [&HeaderValue::from_str(&expected).unwrap()]); + assert!(!seen[0].headers.contains_key(PAIRING_HEADER)); + assert!(!seen[0].headers.contains_key("proxy-authorization")); + + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn git_leg_retries_503_exactly_once() { + tokio::time::timeout(Duration::from_secs(10), async { + // Statuses the stub egress upstream answers with, in order: the + // first request is retried once to a 200; the second request + // gets two 503s and the second one surfaces to the box. + let (upstream_address, hits, upstream_task) = + scripted_upstream(vec![503, 200, 503, 503]).await; + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let mut config = default_config(); + config.git = Some(agentdesktop_core::config::GitConfig { + allow: vec!["https://github.com/org/".to_owned()], + push_allow: vec![], + credentials: vec![], + }); + config.egress_upstream = + Some(url::Url::parse(&format!("http://{upstream_address}/")).unwrap()); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "test-client"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + + let fetch = |pairing: String| { + move || { + let mut request = Request::builder() + .method(Method::GET) + .uri(format!( + "http://{proxy_address}/git/github.com/org/repo.git/info/refs?service=git-upload-pack" + )) + .body(Full::new(Bytes::new())) + .unwrap(); + request + .headers_mut() + .insert(PAIRING_HEADER, HeaderValue::from_str(&pairing).unwrap()); + request + } + }; + let request = fetch(session.pairing.to_string()); + + // First fetch: 503 then 200 — the retry is invisible to the box. + let response = client.request(request()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 2); + + // Second fetch: 503 twice — retried exactly once, then surfaced. + let response = client.request(request()).await.unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 4); + + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn connect_requires_pairing_in_proxy_authorization() { + tokio::time::timeout(Duration::from_secs(10), async { + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let config = default_config(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + + // CONNECT without Proxy-Authorization fails as pairing_invalid + // (raw socket: hyper's client cannot send CONNECT). + let (status, text) = raw_http( + proxy_address, + "CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\n\r\n".to_owned(), + ) + .await; + assert_eq!(status, 403, "{text}"); + assert_eq!(raw_error_code(&text), "pairing_invalid"); + + // A value that is not a registered session pairing fails the + // same way. + let bogus = base64::engine::general_purpose::STANDARD.encode("not-a-pairing:"); + let (status, text) = raw_http( + proxy_address, + format!( + "CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\nProxy-Authorization: Basic {bogus}\r\n\r\n" + ), + ) + .await; + assert_eq!(status, 403, "{text}"); + assert_eq!(raw_error_code(&text), "pairing_invalid"); + + proxy.abort(); + let _ = proxy.await; + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn connect_always_refused_egress_not_supported() { + tokio::time::timeout(Duration::from_secs(10), async { + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "test-client"); + + // Encode pairing as Basic auth (pairing:) + let pairing_auth = base64::engine::general_purpose::STANDARD + .encode(format!("{}:", session.pairing)); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + + // A paired CONNECT gets 403 egress_not_supported via the + // ProxyError envelope — never a tunnel (raw socket: hyper's + // client cannot send CONNECT). + let (status, text) = raw_http( + proxy_address, + format!( + "CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\nProxy-Authorization: Basic {pairing_auth}\r\n\r\n" + ), + ) + .await; + assert_eq!(status, 403, "{text}"); + assert_eq!(raw_error_code(&text), "egress_not_supported"); + + proxy.abort(); + let _ = proxy.await; + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn absolute_form_always_refused_like_connect() { + tokio::time::timeout(Duration::from_secs(10), async { + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let config = default_config(); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "test-client"); + let pairing_auth = base64::engine::general_purpose::STANDARD + .encode(format!("{}:", session.pairing)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + + // Absolute-form (the plain-proxy request target) carries the + // pairing like CONNECT and is always refused the same way. + let (status, text) = raw_http( + proxy_address, + format!( + "GET http://example.com/file HTTP/1.1\r\nHost: example.com\r\nProxy-Authorization: Basic {pairing_auth}\r\n\r\n" + ), + ) + .await; + assert_eq!(status, 403, "{text}"); + assert_eq!(raw_error_code(&text), "egress_not_supported"); + + // Unpaired absolute-form: pairing_invalid. + let (status, text) = raw_http( + proxy_address, + "GET http://example.com/file HTTP/1.1\r\nHost: example.com\r\n\r\n".to_owned(), + ) + .await; + assert_eq!(status, 403, "{text}"); + assert_eq!(raw_error_code(&text), "pairing_invalid"); + + proxy.abort(); + let _ = proxy.await; + drop(dir); + }) + .await + .unwrap(); + } + + #[test] + fn egress_default_is_deny() { + use agentdesktop_core::config::ContainmentEgress; + assert_eq!(ContainmentEgress::default(), ContainmentEgress::Deny); + } + + #[test] + fn validate_containment_egress_upstream_url() { + use agentdesktop_core::config::{ + ContainmentStartupConfig, EgressUpstreamConfig, validate_containment_startup, + }; + use std::path::PathBuf; + use std::str::FromStr; + use url::Url; + + let mut cfg = ContainmentStartupConfig { + executor: PathBuf::from("C:\\wxc\\wxc-exec.exe"), + egress: Default::default(), + engine: Default::default(), + egress_upstream: Some(EgressUpstreamConfig { + url: Url::from_str("https://gateway.example.com/forward").unwrap(), + authentication: None, + }), + git: None, + }; + + // Valid URL should pass + assert!(validate_containment_startup(&cfg, true).is_ok()); + + // URL with credentials should fail + cfg.egress_upstream = Some(EgressUpstreamConfig { + url: Url::from_str("https://user:pass@gateway.example.com/forward").unwrap(), + authentication: None, + }); + assert!(validate_containment_startup(&cfg, true).is_err()); + + // URL with query should fail + cfg.egress_upstream = Some(EgressUpstreamConfig { + url: Url::from_str("https://gateway.example.com/forward?key=value").unwrap(), + authentication: None, + }); + assert!(validate_containment_startup(&cfg, true).is_err()); + + // URL without host should fail + cfg.egress_upstream = Some(EgressUpstreamConfig { + url: Url::from_str("file:///path").unwrap(), + authentication: None, + }); + assert!(validate_containment_startup(&cfg, true).is_err()); + } + + #[test] + fn validate_containment_git_urls_must_be_https() { + use agentdesktop_core::config::{ + ContainmentStartupConfig, GitConfig, validate_containment_startup, + }; + use std::path::PathBuf; + + let mut cfg = ContainmentStartupConfig { + executor: PathBuf::from("C:\\wxc\\wxc-exec.exe"), + egress: Default::default(), + engine: Default::default(), + egress_upstream: None, + git: Some(GitConfig { + allow: vec!["https://github.com/org/".to_string()], + push_allow: vec!["https://github.com/org/".to_string()], + credentials: vec![], + }), + }; + + // HTTPS URLs should pass + assert!(validate_containment_startup(&cfg, true).is_ok()); + + // HTTP URLs in allow should fail + cfg.git = Some(GitConfig { + allow: vec!["http://github.com/org/".to_string()], + push_allow: vec![], + credentials: vec![], + }); + assert!(validate_containment_startup(&cfg, true).is_err()); + + // SSH URLs in allow should fail + cfg.git = Some(GitConfig { + allow: vec!["git@github.com:org/repo.git".to_string()], + push_allow: vec![], + credentials: vec![], + }); + assert!(validate_containment_startup(&cfg, true).is_err()); + + // HTTP URLs in pushAllow should fail + cfg.git = Some(GitConfig { + allow: vec!["https://github.com/org/".to_string()], + push_allow: vec!["http://github.com/org/".to_string()], + credentials: vec![], + }); + assert!(validate_containment_startup(&cfg, true).is_err()); + } + + /// A launch plan for the PR 4 gitconfig tests: fixed Windows paths, the + /// given paths existing, the given egress. + fn pr4_plan( + egress: agentdesktop_core::config::ContainmentEgress, + existing: &[&str], + ) -> crate::launch::plan::LaunchPlan { + let existing: Vec = existing.iter().map(|path| (*path).to_owned()).collect(); + let exists = |path: &std::path::Path| { + existing + .iter() + .any(|entry| std::path::Path::new(entry) == path) + }; + let is_dir = |_: &std::path::Path| false; + let input = crate::launch::LaunchInput { + launch_id: "0123456789abcdef".into(), + executable: std::path::PathBuf::from(r"C:\Users\dev\.local\bin\claude.exe"), + client_exe_dir: std::path::PathBuf::from(r"C:\lab\agentdesktop"), + cwd: std::path::PathBuf::from(r"C:\ws"), + args: vec![], + home: std::path::PathBuf::from(r"C:\Users\dev"), + state_dir: std::path::PathBuf::from(r"C:\Users\dev\state"), + launch_dir: std::path::PathBuf::from(r"C:\Users\dev\state\launches\0123456789abcdef"), + git_dir: None, + program_files_claude: None, + egress, + relay_base_url: "http://127.0.0.1:4000/sandbox".into(), + pairing: "test-pairing".into(), + base_env: std::collections::BTreeMap::new(), + exists: &exists, + is_dir: &is_dir, + }; + crate::launch::plan(&input, &crate::launch::profile::CLAUDE_CODE) + } + + /// An upstream answering by path: `/org/hop/...` redirects to + /// `hop` until K reaches `target` (then 200 "ok"); with `escape` + /// the first redirect points outside the `/org/` prefix. Records paths. + async fn redirect_upstream( + target: usize, + escape: bool, + ) -> ( + std::net::SocketAddr, + Arc>>, + tokio::task::JoinHandle<()>, + ) { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let seen: Arc>> = Arc::default(); + let record = seen.clone(); + let task = tokio::spawn(async move { + loop { + let (socket, _) = upstream.accept().await.unwrap(); + let record = record.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| { + let record = record.clone(); + async move { + let path = request.uri().path().to_owned(); + record.lock().unwrap().push(path.clone()); + let hop: usize = path + .split('/') + .find_map(|segment| segment.strip_prefix("hop")) + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + let response = if hop < target { + let location = if escape { + "/elsewhere/repo.git/info/refs?service=git-upload-pack" + .to_owned() + } else { + format!( + "/org/hop{}/info/refs?service=git-upload-pack", + hop + 1 + ) + }; + Response::builder() + .status(StatusCode::FOUND) + .header("location", location) + .body(Full::new(Bytes::new())) + .unwrap() + } else { + Response::new(Full::new(Bytes::from_static(b"ok"))) + }; + Ok::<_, Infallible>(response) + } + }), + ) + .await; + }); + } + }); + (address, seen, task) + } + + /// A proxy whose git allowlist points at a local plain-HTTP stub: the + /// allow entry's scheme decides the connection scheme (production entries + /// are pinned to https by startup validation). + async fn git_proxy( + allow: Vec, + egress_upstream: Option, + ) -> ( + tempfile::TempDir, + std::net::SocketAddr, + SessionRegistry, + sessions::Session, + tokio::task::JoinHandle>, + ) { + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let mut config = default_config(); + config.git = Some(agentdesktop_core::config::GitConfig { + allow, + push_allow: vec![], + credentials: vec![], + }); + config.egress_upstream = egress_upstream; + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + (dir, proxy_address, sessions, session, proxy) + } + + fn git_get(proxy: std::net::SocketAddr, path: &str, pairing: &str) -> Request> { + Request::builder() + .method(Method::GET) + .uri(format!("http://{proxy}{GIT_ROUTE}/{path}")) + .header(PAIRING_HEADER, pairing) + .body(Full::new(Bytes::new())) + .unwrap() + } + + #[tokio::test] + async fn revocation_cuts_a_streaming_git_response() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(10), async { + // The upstream sends the headers and a first pack chunk, then + // holds the body open (a long transfer). + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream_address = upstream.local_addr().unwrap(); + let (hold_tx, mut hold_rx) = mpsc::channel::<()>(1); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |_request: Request| { + let (tx, rx) = mpsc::channel::, Infallible>>(2); + async move { + tokio::spawn(async move { + tx.send(Ok(Frame::data(Bytes::from_static(b"PACK-first")))).await.unwrap(); + // Never sends more; keeps the stream open. + std::future::pending::<()>().await; + drop(tx); + }); + Ok::<_, Infallible>( + Response::builder() + .header("content-type", "application/x-git-upload-pack-result") + .body(StreamBody::new(ReceiverStream::new(rx))) + .unwrap(), + ) + } + }), + ) + .await; + let _ = hold_rx.recv().await; + }); + let (dir, proxy_address, sessions, session, proxy) = + git_proxy(vec![format!("http://{upstream_address}/org/")], None).await; + let client = http_client(); + let request = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}{GIT_ROUTE}/{upstream_address}/org/repo.git/git-upload-pack")) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::from_static(b"0000"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let mut body = response.into_body(); + let first = body.frame().await.unwrap().unwrap().into_data().unwrap(); + assert_eq!(first, "PACK-first"); + assert!(sessions.revoke(&session.id)); + // The body ends (cleanly or with an error) instead of hanging. + let _ = body.collect().await; + let lines = logs.wait_for_lines(1).await; + let line = lines.last().unwrap(); + assert_eq!(log_field(line, "outcome"), "canceled", "{line}"); + assert_eq!(log_field(line, "status"), "200", "{line}"); + proxy.abort(); + drop(hold_tx); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // Credential injection tests + #[test] + fn credential_injection_longest_prefix_match() { + use agentdesktop_core::config::GitCredentialEntry; + + let credentials = vec![ + GitCredentialEntry { + url_prefix: "https://github.com/".to_string(), + username: "user1".to_string(), + secret: "secret1".to_string(), + }, + GitCredentialEntry { + url_prefix: "https://github.com/org/".to_string(), + username: "user2".to_string(), + secret: "secret2".to_string(), + }, + ]; + + let url = |text: &str| url::Url::parse(text).unwrap(); + // The more specific entry wins under its prefix ... + let picked = select_git_credential( + &credentials, + &url("https://github.com/org/repo.git/info/refs"), + ); + assert_eq!(picked.map(|entry| entry.username.as_str()), Some("user2")); + // ... the broader one everywhere else on the host ... + let picked = select_git_credential( + &credentials, + &url("https://github.com/other/repo.git/info/refs"), + ); + assert_eq!(picked.map(|entry| entry.username.as_str()), Some("user1")); + // ... a partial path element is not the longer prefix ... + let picked = select_git_credential( + &credentials, + &url("https://github.com/organization/repo.git"), + ); + assert_eq!(picked.map(|entry| entry.username.as_str()), Some("user1")); + // ... and the order of the entries does not matter. + let reversed: Vec<_> = credentials.iter().rev().cloned().collect(); + let picked = select_git_credential(&reversed, &url("https://github.com/org/repo.git")); + assert_eq!(picked.map(|entry| entry.username.as_str()), Some("user2")); + } + + #[test] + fn credential_injection_only_for_allowed_hosts() { + use agentdesktop_core::config::{GitConfig, GitCredentialEntry}; + + let git_config = GitConfig { + allow: vec!["https://github.com/org/".to_string()], + push_allow: vec![], + credentials: vec![GitCredentialEntry { + url_prefix: "https://github.com/".to_string(), + username: "user".to_string(), + secret: "secret".to_string(), + }], + }; + + let url = |text: &str| url::Url::parse(text).unwrap(); + // Allowed destination: the (broader) credential entry applies. + let picked = git_credential_for( + &git_config, + &url("https://github.com/org/repo.git/info/refs"), + ); + assert_eq!(picked.map(|entry| entry.username.as_str()), Some("user")); + // Same host, outside the allow prefix: the credential entry covers + // it, but the allowlist gate comes first. + assert!( + git_credential_for( + &git_config, + &url("https://github.com/other/repo.git/info/refs") + ) + .is_none() + ); + } + + #[test] + fn credential_never_injected_for_non_allowed_host() { + use agentdesktop_core::config::{GitConfig, GitCredentialEntry}; + + let git_config = GitConfig { + allow: vec!["https://allowed.example.com/".to_string()], + push_allow: vec![], + credentials: vec![GitCredentialEntry { + url_prefix: "https://evil.example.com/".to_string(), + username: "user".to_string(), + secret: "secret".to_string(), + }], + }; + + let url = |text: &str| url::Url::parse(text).unwrap(); + // The credential's own host is not allowlisted: never injected. + assert!( + git_credential_for( + &git_config, + &url("https://evil.example.com/repo.git/info/refs") + ) + .is_none() + ); + // The allowed host has no matching credential entry: nothing injected. + assert!( + git_credential_for( + &git_config, + &url("https://allowed.example.com/repo.git/info/refs") + ) + .is_none() + ); + // The same holds with an egress upstream configured: the gate is the + // destination, not the leg (handle_git_request uses this selector on + // both legs; git_leg_chains_to_the_egress_upstream pins the chained + // header shape). + } + + #[test] + fn git_credential_secret_never_serialized() { + use agentdesktop_core::config::GitCredentialEntry; + + let entry = GitCredentialEntry { + url_prefix: "https://github.com/".to_string(), + username: "user".to_string(), + secret: "supersecret".to_string(), + }; + + let json = serde_json::to_value(&entry).unwrap(); + assert!( + json.get("secret").is_none(), + "secret field must not be serialized" + ); + assert_eq!(json.get("username").and_then(|v| v.as_str()), Some("user")); + } + + // Gitconfig generation tests + #[test] + fn gitconfig_insteadof_rewrites_to_git_route() { + let allow = vec![ + "https://github.com/org/".to_owned(), + "https://git.example.com:8443/team".to_owned(), + ]; + let contents = crate::launch::gitconfig_contents("pairing-x", 4000, &allow, &[]); + // One rewrite per allow entry, onto GIT_ROUTE on the loopback + // listener; the authority is verbatim (port only when configured), + // the value in git's quoted form. + assert!(contents.contains(&format!( + "[url \"http://127.0.0.1:4000{GIT_ROUTE}/github.com/org/\"]\n\tinsteadOf = \"https://github.com/org/\"\n" + )), "{contents}"); + assert!(contents.contains(&format!( + "[url \"http://127.0.0.1:4000{GIT_ROUTE}/git.example.com:8443/team\"]\n\tinsteadOf = \"https://git.example.com:8443/team\"\n" + )), "{contents}"); + // ssh-form remotes of allowlisted hosts map to the refusal route. + assert!(contents.contains(&format!( + "[url \"http://127.0.0.1:4000{GIT_SSH_ROUTE}/github.com/\"]\n\tinsteadOf = ssh://github.com/\n\tinsteadOf = git@github.com:\n" + )), "{contents}"); + // No allow entries: no rewrites at all. + let empty = crate::launch::gitconfig_contents("pairing-x", 4000, &[], &[]); + assert!(!empty.contains("insteadOf"), "{empty}"); + } + + #[test] + fn gitconfig_pairing_scoped_to_loopback() { + let pairing = "secret-session-pairing-value"; + let allow = vec!["https://github.com/org/".to_owned()]; + let contents = crate::launch::gitconfig_contents(pairing, 4123, &allow, &[]); + // The pairing appears exactly once, as the extraHeader of the + // url-matched loopback section; never in a global http section + // (which git would send to every remote). + assert_eq!(contents.matches(pairing).count(), 1, "{contents}"); + let section = "[http \"http://127.0.0.1:4123/\"]\n"; + let start = contents.find(section).expect("loopback http section"); + let body = &contents[start + section.len()..]; + let body = &body[..body.find("\n[").unwrap_or(body.len())]; + assert!( + body.contains(&format!("\textraHeader = {PAIRING_HEADER}: {pairing}\n")), + "{contents}" + ); + assert!(!contents.contains("[http]"), "{contents}"); + } + + #[test] + fn gitconfig_proxy_bypass() { + let contents = crate::launch::gitconfig_contents( + "p", + 4000, + &["https://github.com/org/".to_owned()], + &[], + ); + let section = "[http \"http://127.0.0.1:4000/\"]\n"; + let start = contents.find(section).expect("loopback http section"); + let body = &contents[start + section.len()..]; + let body = &body[..body.find("\n[").unwrap_or(body.len())]; + assert!(body.lines().any(|line| line == "\tproxy ="), "{contents}"); + // Url-scoped only: no global proxy override is written. + assert_eq!(contents.matches("proxy =").count(), 1, "{contents}"); + } + + #[test] + fn gitconfig_credential_helper_emptied() { + let contents = crate::launch::gitconfig_contents( + "p", + 4000, + &["https://github.com/org/".to_owned()], + &[], + ); + // The empty helper entry is the first (and only) credential.helper + // value, resetting Git for Windows' system `helper=manager`. + let section = contents + .find("[credential]\n\thelper =\n") + .expect("empty credential.helper"); + assert_eq!(contents.matches("helper").count(), 1, "{contents}"); + assert!( + contents.find("[url").is_none_or(|url| section < url), + "{contents}" + ); + } + + #[test] + fn gitconfig_identity_allowlist() { + // The allowlist and which key is read with --get-all. + assert_eq!( + crate::launch::GIT_IDENTITY_KEYS, + &[ + ("user.name", false), + ("user.email", false), + ("user.signingkey", false), + ("commit.gpgsign", false), + ("core.longpaths", false), + ("safe.directory", true), + ] + ); + let identity = vec![ + ("user.name".to_owned(), vec!["Dev User".to_owned()]), + ("user.email".to_owned(), vec!["dev@example.com".to_owned()]), + ("commit.gpgsign".to_owned(), vec!["true".to_owned()]), + ( + "safe.directory".to_owned(), + vec!["C:/ws/a".to_owned(), "C:/ws/b".to_owned()], + ), + ]; + let contents = crate::launch::gitconfig_contents("p", 4000, &[], &identity); + assert!( + contents.contains("[user]\n\tname = \"Dev User\"\n"), + "{contents}" + ); + assert!( + contents.contains("[user]\n\temail = \"dev@example.com\"\n"), + "{contents}" + ); + assert!( + contents.contains("[commit]\n\tgpgsign = \"true\"\n"), + "{contents}" + ); + // Every value of the multi-valued key is kept. + assert!( + contents.contains("[safe]\n\tdirectory = \"C:/ws/a\"\n\tdirectory = \"C:/ws/b\"\n"), + "{contents}" + ); + // A value that could break out of its line is dropped, not written. + let hostile = vec![( + "user.name".to_owned(), + vec!["x\n[alias]\n\tco = !evil".to_owned()], + )]; + let contents = crate::launch::gitconfig_contents("p", 4000, &[], &hostile); + assert!(!contents.contains("[alias]"), "{contents}"); + } + + #[test] + fn gitconfig_file_lifecycle() { + let state_dir = tempfile::tempdir().unwrap(); + let launch_dir = state_dir.path().join("launches").join("0123456789abcdef"); + crate::launch::create_launch_dir(&launch_dir).unwrap(); + let path = crate::launch::generate_gitconfig( + &launch_dir, + "pairing-x", + 4000, + &["https://github.com/org/".to_owned()], + &[], + ) + .unwrap(); + assert_eq!(path, launch_dir.join("git_config")); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("pairing-x") + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + // The sweep removes it secrets-first even while the box still runs + // (the launch directory itself is kept for a live pid). + std::fs::write(launch_dir.join("pid"), "1 2").unwrap(); + crate::launch::sweep(state_dir.path(), &|_, _| true).unwrap(); + assert!(launch_dir.is_dir()); + assert!( + !path.exists(), + "git_config is in the secrets-first sweep list" + ); + } + + #[test] + fn gitconfig_env_injection() { + for egress in [ + agentdesktop_core::config::ContainmentEgress::Deny, + agentdesktop_core::config::ContainmentEgress::Allow, + ] { + let plan = pr4_plan(egress, &[]); + assert_eq!( + plan.env.get("GIT_CONFIG_GLOBAL").map(String::as_str), + Some(r"C:\Users\dev\state\launches\0123456789abcdef\git_config") + ); + assert_eq!( + plan.env.get("GIT_TERMINAL_PROMPT").map(String::as_str), + Some("0") + ); + // AC 2a (v5): no proxy environment variables. + for key in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ] { + assert!(!plan.env.contains_key(key), "{key}"); + } + } + } + + #[test] + fn gitconfig_real_paths_denied() { + // `~/.gitconfig` is always denied (parent $HOME always exists); never granted. + // XDG config is only denied when its parent exists (MXC parent rule). + for existing in [ + vec![], + vec![ + r"C:\Users\dev\.gitconfig", + r"C:\Users\dev\.config\git\config", + ], + ] { + let plan = pr4_plan( + agentdesktop_core::config::ContainmentEgress::Deny, + &existing, + ); + // `~/.gitconfig` is always denied. + assert!( + plan.denied + .contains(&std::path::PathBuf::from(r"C:\Users\dev\.gitconfig")), + "~/.gitconfig denied" + ); + assert!( + !plan + .readonly + .contains(&std::path::PathBuf::from(r"C:\Users\dev\.gitconfig")), + "~/.gitconfig not granted" + ); + // XDG config is not denied when parent doesn't exist (pr4_plan has + // is_dir returning false, so `~/.config/git` never exists). + assert!( + !plan.denied.contains(&std::path::PathBuf::from( + r"C:\Users\dev\.config\git\config" + )), + "XDG config not denied when parent absent" + ); + assert!( + !plan.readonly.contains(&std::path::PathBuf::from( + r"C:\Users\dev\.config\git\config" + )), + "XDG config not granted" + ); + // The generated file is granted read-only: exactly the file. + let generated = std::path::PathBuf::from( + r"C:\Users\dev\state\launches\0123456789abcdef\git_config", + ); + assert!(plan.readonly.contains(&generated)); + assert!(!plan.readonly.iter().any(|path| path + == &std::path::PathBuf::from(r"C:\Users\dev\state\launches\0123456789abcdef"))); + } + } + + // Redirect policy tests + #[tokio::test] + async fn redirect_follows_in_prefix_to_3_hops() { + // Three in-prefix redirects are followed; the 4th request answers 200. + tokio::time::timeout(Duration::from_secs(10), async { + let (upstream, seen, upstream_task) = redirect_upstream(3, false).await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{upstream}/org/")], None).await; + let client = http_client(); + let response = client + .request(git_get( + proxy_address, + &format!("{upstream}/org/hop0/info/refs?service=git-upload-pack"), + &session.pairing, + )) + .await + .unwrap(); + assert!( + !response.status().is_redirection(), + "a 3xx is never relayed" + ); + assert!(!response.headers().contains_key("location")); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "ok" + ); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 4, "{seen:?}"); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn redirect_refuses_4th_hop() { + // A 4th redirect is refused, not followed (4 upstream requests, no 5th). + tokio::time::timeout(Duration::from_secs(10), async { + let (upstream, seen, upstream_task) = redirect_upstream(4, false).await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{upstream}/org/")], None).await; + let client = http_client(); + let response = client + .request(git_get( + proxy_address, + &format!("{upstream}/org/hop0/info/refs?service=git-upload-pack"), + &session.pairing, + )) + .await + .unwrap(); + assert!( + !response.status().is_redirection(), + "a 3xx is never relayed" + ); + assert!(!response.headers().contains_key("location")); + let (status, code, _) = refusal(response).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "git_host_not_allowed") + ); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 4, "{seen:?}"); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn redirect_refuses_out_of_prefix() { + // A redirect leaving the allow entry's prefix is refused, never followed. + tokio::time::timeout(Duration::from_secs(10), async { + let (upstream, seen, upstream_task) = redirect_upstream(1, true).await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{upstream}/org/")], None).await; + let client = http_client(); + let response = client + .request(git_get( + proxy_address, + &format!("{upstream}/org/hop0/info/refs?service=git-upload-pack"), + &session.pairing, + )) + .await + .unwrap(); + assert!( + !response.status().is_redirection(), + "a 3xx is never relayed" + ); + assert!(!response.headers().contains_key("location")); + let (status, code, _) = refusal(response).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "git_host_not_allowed") + ); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 1, "{seen:?}"); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn redirect_never_relayed_to_box() { + // One redirect: the box sees only the followed 200. + tokio::time::timeout(Duration::from_secs(10), async { + let (upstream, seen, upstream_task) = redirect_upstream(1, false).await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{upstream}/org/")], None).await; + let client = http_client(); + let response = client + .request(git_get( + proxy_address, + &format!("{upstream}/org/hop0/info/refs?service=git-upload-pack"), + &session.pairing, + )) + .await + .unwrap(); + assert!( + !response.status().is_redirection(), + "a 3xx is never relayed" + ); + assert!(!response.headers().contains_key("location")); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "ok" + ); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 2, "{seen:?}"); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // Revocation tests + #[tokio::test] + async fn revocation_aborts_inflight_git_request() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(10), async { + // An upstream that accepts and never answers: the git request is + // in flight until revocation aborts it. + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream_address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let mut held = Vec::new(); + loop { + let (socket, _) = upstream.accept().await.unwrap(); + held.push(socket); + } + }); + let (dir, proxy_address, sessions, session, proxy) = + git_proxy(vec![format!("http://{upstream_address}/org/")], None).await; + let client = http_client(); + let pending = tokio::spawn({ + let client = client.clone(); + let request = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}{GIT_ROUTE}/{upstream_address}/org/repo.git/git-upload-pack")) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::from_static(b"0000"))) + .unwrap(); + async move { client.request(request).await.unwrap() } + }); + tokio::time::sleep(Duration::from_millis(200)).await; + assert!(!pending.is_finished(), "the request is in flight"); + assert!(sessions.revoke(&session.id)); + let response = pending.await.unwrap(); + let (status, code, _) = refusal(response).await; + assert_eq!((status, code.as_str()), (StatusCode::FORBIDDEN, "pairing_invalid")); + let lines = logs.wait_for_lines(1).await; + let line = lines.last().unwrap(); + assert_eq!(log_field(line, "outcome"), "canceled", "{line}"); + assert_eq!(log_field(line, "session"), session.id, "{line}"); + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(logs.sandbox_lines().len(), 1, "exactly one line for the aborted request"); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // Log shape tests + #[tokio::test] + async fn git_request_log_has_host_and_decision() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(10), async { + let client = http_client(); + // allow (direct leg) and refuse, on one proxy. + let (direct, _seen, direct_task) = recording_upstream().await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{direct}/org/")], None).await; + let path = format!("{direct}/org/repo.git/info/refs?service=git-upload-pack"); + let response = client + .request(git_get(proxy_address, &path, &session.pairing)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let _ = response.into_body().collect().await; + let lines = logs.wait_for_lines(1).await; + assert_eq!( + log_field(&lines[0], "host"), + direct.to_string(), + "{}", + lines[0] + ); + assert_eq!(log_field(&lines[0], "decision"), "allow", "{}", lines[0]); + assert_eq!(log_field(&lines[0], "status"), "200", "{}", lines[0]); + assert_eq!(log_field(&lines[0], "session"), session.id, "{}", lines[0]); + assert_eq!( + lines[0].matches(" session=").count(), + 1, + "session id once: {}", + lines[0] + ); + let path = "evil.com/repo.git/info/refs?service=git-upload-pack"; + let response = client + .request(git_get(proxy_address, path, &session.pairing)) + .await + .unwrap(); + assert_eq!(refusal(response).await.1, "git_host_not_allowed"); + let lines = logs.wait_for_lines(2).await; + assert_eq!(log_field(&lines[1], "host"), "evil.com", "{}", lines[1]); + assert_eq!(log_field(&lines[1], "decision"), "refuse", "{}", lines[1]); + assert_eq!( + log_field(&lines[1], "code"), + "git_host_not_allowed", + "{}", + lines[1] + ); + // The query string never reaches the log. + assert!(!lines[1].contains("service="), "{}", lines[1]); + proxy.abort(); + direct_task.abort(); + drop(dir); + + // upstream (chained leg). + let (egress, _seen, egress_task) = recording_upstream().await; + let (dir, proxy_address, _sessions, session, proxy) = git_proxy( + vec!["https://github.com/org/".to_owned()], + Some(url::Url::parse(&format!("http://{egress}/")).unwrap()), + ) + .await; + let path = "github.com/org/repo.git/info/refs?service=git-upload-pack"; + let response = client + .request(git_get(proxy_address, path, &session.pairing)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let _ = response.into_body().collect().await; + let lines = logs.wait_for_lines(3).await; + assert_eq!(log_field(&lines[2], "host"), "github.com", "{}", lines[2]); + assert_eq!(log_field(&lines[2], "decision"), "upstream", "{}", lines[2]); + proxy.abort(); + egress_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn connect_request_log_has_host_and_decision() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(10), async { + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let config = default_config(); + let session = new_session(&config.sessions, "claude-code"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let auth = base64::engine::general_purpose::STANDARD.encode(format!("{}:", session.pairing)); + let (status, _) = raw_http( + proxy_address, + format!("CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\nProxy-Authorization: Basic {auth}\r\n\r\n"), + ) + .await; + assert_eq!(status, 403); + let lines = logs.wait_for_lines(1).await; + let line = &lines[0]; + assert_eq!(log_field(line, "method"), "CONNECT", "{line}"); + assert_eq!(log_field(line, "host"), "github.com:443", "{line}"); + assert_eq!(log_field(line, "decision"), "refuse", "{line}"); + assert_eq!(log_field(line, "code"), "egress_not_supported", "{line}"); + assert_eq!(log_field(line, "session"), session.id, "{line}"); + // The pairing never reaches the log. + assert!(!logs.text().contains(&*session.pairing)); + assert!(!logs.text().contains(&auth)); + proxy.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn log_outcome_values_unchanged() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(10), async { + let client = http_client(); + let (direct, _seen, direct_task) = recording_upstream().await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{direct}/org/")], None).await; + // allowed fetch, refused host, refused dumb protocol, refused push + // (pkt-line ERR), ssh refusal route, CONNECT refusal. + for path in [ + format!("{direct}/org/repo.git/info/refs?service=git-upload-pack"), + "evil.com/repo.git/info/refs?service=git-upload-pack".to_owned(), + format!("{direct}/org/repo.git/info/refs"), + format!("{direct}/org/repo.git/info/refs?service=git-receive-pack"), + ] { + let response = client.request(git_get(proxy_address, &path, &session.pairing)).await.unwrap(); + let _ = response.into_body().collect().await; + } + let ssh = Request::builder() + .method(Method::GET) + .uri(format!("http://{proxy_address}{GIT_SSH_ROUTE}/github.com/org/repo.git/info/refs?service=git-upload-pack")) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::new())) + .unwrap(); + let response = client.request(ssh).await.unwrap(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert!(String::from_utf8_lossy(&body).contains("ERR ssh remotes are not available in the box yet (PR 5); use the https remote")); + let auth = base64::engine::general_purpose::STANDARD.encode(format!("{}:", session.pairing)); + raw_http( + proxy_address, + format!("CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\nProxy-Authorization: Basic {auth}\r\n\r\n"), + ) + .await; + let lines = logs.wait_for_lines(6).await; + for line in &lines { + let outcome = log_field(line, "outcome"); + assert!(["complete", "aborted", "canceled"].contains(&outcome.as_str()), "{line}"); + let decision = log_field(line, "decision"); + assert!(["allow", "refuse", "upstream"].contains(&decision.as_str()), "{line}"); + } + proxy.abort(); + direct_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // Default flip warn test + #[tokio::test] + async fn explicit_egress_allow_warns_at_launch() { + let (logs, _guard) = capture_logs(); + // The launch path calls this once per launch (start_launch); deny, + // the default, is silent. + crate::launch::warn_on_open_egress(agentdesktop_core::config::ContainmentEgress::Deny); + assert!(!logs.text().contains("egress allow"), "{}", logs.text()); + crate::launch::warn_on_open_egress(agentdesktop_core::config::ContainmentEgress::Allow); + let text = logs.text(); + let line = text + .lines() + .find(|line| line.contains("egress allow: the box has unrestricted outbound")) + .unwrap_or_else(|| panic!("no warn line in {text}")); + assert!(line.contains("WARN"), "{line}"); + assert_eq!(text.matches("egress allow:").count(), 1); + } + + // Prefix edge cases from AC 5 + #[test] + fn prefix_matching_exact_on_configured() { + let url = |text: &str| url::Url::parse(text).unwrap(); + let prefix = "https://github.com/org/"; + assert!(git_prefix_matches( + prefix, + &url("https://github.com/org/repo.git/info/refs") + )); + // A partial path element is not a match ... + assert!(!git_prefix_matches( + prefix, + &url("https://github.com/organization/repo.git") + )); + // ... nor is the prefix appearing deeper in the path (a repo segment). + assert!(!git_prefix_matches( + prefix, + &url("https://github.com/other/org/repo.git") + )); + // Path matching is case-sensitive, like the gateway's PathPrefix. + assert!(!git_prefix_matches( + prefix, + &url("https://github.com/Org/repo.git") + )); + // Another host never matches. + assert!(!git_prefix_matches( + prefix, + &url("https://github.com.evil.example/org/repo.git") + )); + } + + #[test] + fn prefix_matching_case_insensitive_host() { + let url = |text: &str| url::Url::parse(text).unwrap(); + assert!(git_prefix_matches( + "https://GitHub.COM/org/", + &url("https://github.com/org/repo.git") + )); + assert!(git_prefix_matches( + "https://github.com/org/", + &url("https://GITHUB.com/org/repo.git") + )); + } + + #[test] + fn prefix_matching_default_port_handled() { + let url = |text: &str| url::Url::parse(text).unwrap(); + assert!(git_prefix_matches( + "https://github.com:443/org/", + &url("https://github.com/org/repo.git") + )); + assert!(git_prefix_matches( + "https://github.com/org/", + &url("https://github.com:443/org/repo.git") + )); + assert!(!git_prefix_matches( + "https://github.com:8443/org/", + &url("https://github.com/org/repo.git") + )); + assert!(!git_prefix_matches( + "https://github.com/org/", + &url("https://github.com:8443/org/repo.git") + )); + assert!(git_prefix_matches( + "https://git.example.com:8443/team/", + &url("https://git.example.com:8443/team/x.git") + )); + } + + #[test] + fn prefix_matching_trailing_slash_normalized() { + let url = |text: &str| url::Url::parse(text).unwrap(); + for prefix in ["https://github.com/org", "https://github.com/org/"] { + assert!( + git_prefix_matches(prefix, &url("https://github.com/org/repo.git")), + "{prefix}" + ); + assert!( + git_prefix_matches(prefix, &url("https://github.com/org")), + "{prefix}" + ); + assert!( + git_prefix_matches(prefix, &url("https://github.com/org/")), + "{prefix}" + ); + assert!( + !git_prefix_matches(prefix, &url("https://github.com/orgx/repo.git")), + "{prefix}" + ); + } + } + // Delta fix-round tests (2026-10-21) + + // T1a (F1): a redirect may not change the allow entry's scheme. + #[test] + fn git_redirect_stays_in_requires_same_scheme() { + let url = |text: &str| url::Url::parse(text).unwrap(); + assert!(!git_redirect_stays_in( + "https://github.com/org/", + &url("http://github.com:443/org/r.git/info/refs") + )); + assert!(!git_redirect_stays_in( + "https://git.corp:8443/org/", + &url("http://git.corp:8443/org/r") + )); + assert!(git_redirect_stays_in( + "https://github.com/org/", + &url("https://github.com/org/r.git/info/refs") + )); + assert!(!git_redirect_stays_in( + "https://github.com/org/", + &url("https://github.com/other/r.git") + )); + } + + // T1b (F1): a scheme-changing redirect is refused end to end, never + // followed (at 833e878c it was followed: 502 with the https hop attempted). + #[tokio::test] + async fn git_redirect_scheme_change_refused() { + tokio::time::timeout(Duration::from_secs(10), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let stub = upstream.local_addr().unwrap(); + let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = hits.clone(); + let upstream_task = tokio::spawn(async move { + loop { + let (socket, _) = upstream.accept().await.unwrap(); + let counter = counter.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |_request: Request| { + let counter = counter.clone(); + async move { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok::<_, Infallible>( + Response::builder() + .status(StatusCode::FOUND) + .header( + "location", + format!("https://{stub}/org/r.git/info/refs?service=git-upload-pack"), + ) + .body(Full::new(Bytes::new())) + .unwrap(), + ) + } + }), + ) + .await; + }); + } + }); + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{stub}/org/")], None).await; + let client = http_client(); + let response = client + .request(git_get( + proxy_address, + &format!("{stub}/org/r.git/info/refs?service=git-upload-pack"), + &session.pairing, + )) + .await + .unwrap(); + let (status, code, _) = refusal(response).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "git_host_not_allowed") + ); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 1); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T2a (F3, adv M3): a push to a fetch-only remote answers git's own + // pkt-line ERR envelope on both legs, and nothing reaches the upstream. + #[tokio::test] + async fn push_to_fetch_only_remote_answers_pkt_line_err() { + tokio::time::timeout(Duration::from_secs(10), async { + let (up, seen, upstream_task) = recording_upstream().await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{up}/org/")], None).await; + let client = http_client(); + let line = + format!("ERR push to {up} is not allowed by daemon.containment.git.pushAllow\n"); + let expected = format!("{:04x}{line}", line.len() + 4); + // Discovery leg. + let response = client + .request(git_get( + proxy_address, + &format!("{up}/org/r.git/info/refs?service=git-receive-pack"), + &session.pairing, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[CONTENT_TYPE], + "application/x-git-receive-pack-advertisement" + ); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + expected.as_bytes() + ); + // RPC leg. + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{GIT_ROUTE}/{up}/org/r.git/git-receive-pack" + )) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::from_static(b"0000"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[CONTENT_TYPE], + "application/x-git-receive-pack-result" + ); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + expected.as_bytes() + ); + assert_eq!(seen.lock().unwrap().len(), 0); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T2b (F3): a pushAllow-listed remote's push RPC reaches the upstream. + #[tokio::test] + async fn push_allowed_remote_reaches_upstream() { + tokio::time::timeout(Duration::from_secs(10), async { + let (up, seen, upstream_task) = recording_upstream().await; + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let mut config = default_config(); + config.git = Some(agentdesktop_core::config::GitConfig { + allow: vec![format!("http://{up}/org/")], + push_allow: vec![format!("http://{up}/org/")], + credentials: vec![], + }); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "test-client"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{GIT_ROUTE}/{up}/org/r.git/git-receive-pack" + )) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::from_static(b"0000"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let seen = seen.lock().unwrap().clone(); + assert_eq!( + seen.len(), + 1, + "{:?}", + seen.iter().map(|s| &s.uri).collect::>() + ); + assert_eq!(seen[0].uri, "/org/r.git/git-receive-pack"); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T3 (F2, adv M1): gitconfig values are written in git's quoted form, so + // Windows paths, `;` and `"` survive git's value parser. + #[test] + fn gitconfig_values_are_quoted() { + let identity = vec![ + ( + "user.signingkey".to_owned(), + vec![r"C:\Users\dev\.ssh\id.pub".to_owned()], + ), + ("user.name".to_owned(), vec!["Max ; Mustermann".to_owned()]), + ("user.email".to_owned(), vec![r#"a"b@x"#.to_owned()]), + ]; + let contents = crate::launch::gitconfig_contents("p", 4000, &[], &identity); + assert!( + contents.contains(r#"signingkey = "C:\\Users\\dev\\.ssh\\id.pub""#), + "{contents}" + ); + assert!( + contents.contains(r#"name = "Max ; Mustermann""#), + "{contents}" + ); + assert!(contents.contains(r#"email = "a\"b@x""#), "{contents}"); + } + + // T4 (F4, adv minor 8): a host-only allow entry maps without a `//` path. + #[test] + fn gitconfig_host_only_entry_has_no_double_slash() { + let allow = vec!["https://github.com".to_owned()]; + let contents = crate::launch::gitconfig_contents("p", 4000, &allow, &[]); + assert!( + contents.contains(&format!( + "[url \"http://127.0.0.1:4000{GIT_ROUTE}/github.com\"]\n\tinsteadOf = \"https://github.com\"\n" + )), + "{contents}" + ); + assert!( + !contents.contains(&format!("{GIT_ROUTE}/github.com/\"")), + "{contents}" + ); + } + + // T7 (M5b): the longest-prefix credential is selected per destination, + // end to end on the chained leg. + #[tokio::test] + async fn credential_selected_per_prefix_end_to_end() { + tokio::time::timeout(Duration::from_secs(10), async { + let (up, seen, upstream_task) = recording_upstream().await; + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let mut config = default_config(); + config.git = Some(agentdesktop_core::config::GitConfig { + allow: vec![ + "https://github.com/org/".to_owned(), + "https://github.com/team/".to_owned(), + ], + push_allow: vec![], + credentials: vec![ + agentdesktop_core::config::GitCredentialEntry { + url_prefix: "https://github.com/org/".to_owned(), + username: "u1".to_owned(), + secret: "s1".to_owned(), + }, + agentdesktop_core::config::GitCredentialEntry { + url_prefix: "https://github.com/org/deep/".to_owned(), + username: "u2".to_owned(), + secret: "s2".to_owned(), + }, + ], + }); + config.egress_upstream = Some(url::Url::parse(&format!("http://{up}/")).unwrap()); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "test-client"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + for path in [ + "github.com/team/r.git/info/refs?service=git-upload-pack", + "github.com/org/r.git/info/refs?service=git-upload-pack", + "github.com/org/deep/r.git/info/refs?service=git-upload-pack", + ] { + let response = client + .request(git_get(proxy_address, path, &session.pairing)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + } + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 3); + let basic = |user: &str, secret: &str| { + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{secret}")) + ) + }; + assert!( + !seen[0].headers.contains_key(AUTHORIZATION), + "no entry matches /team/" + ); + assert_eq!(seen[1].headers[AUTHORIZATION], basic("u1", "s1").as_str()); + assert_eq!(seen[2].headers[AUTHORIZATION], basic("u2", "s2").as_str()); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T8 (M7b): a redirect into ANOTHER allow entry's prefix is still + // refused — the gate is the matched entry, not the whole allowlist. + #[tokio::test] + async fn redirect_refuses_other_allow_entry() { + tokio::time::timeout(Duration::from_secs(10), async { + let (up, seen, upstream_task) = redirect_upstream(1, true).await; + let (dir, proxy_address, _sessions, session, proxy) = git_proxy( + vec![ + format!("http://{up}/org/"), + format!("http://{up}/elsewhere/"), + ], + None, + ) + .await; + let client = http_client(); + let response = client + .request(git_get( + proxy_address, + &format!("{up}/org/hop0/info/refs?service=git-upload-pack"), + &session.pairing, + )) + .await + .unwrap(); + let (status, code, _) = refusal(response).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "git_host_not_allowed") + ); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 1, "{seen:?}"); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T9 (M14): an Authorization header the box sends is never forwarded. + #[tokio::test] + async fn box_authorization_never_forwarded() { + tokio::time::timeout(Duration::from_secs(10), async { + let (up, seen, upstream_task) = recording_upstream().await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{up}/org/")], None).await; + let client = http_client(); + let mut request = git_get( + proxy_address, + &format!("{up}/org/r.git/info/refs?service=git-upload-pack"), + &session.pairing, + ); + request.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_static("Basic Ym94OmNyZWQ="), + ); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 1); + assert!( + !seen[0].headers.contains_key(AUTHORIZATION), + "{:?}", + seen[0].headers + ); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T10 (M27): the loopback Host gate still guards the git route. + #[tokio::test] + async fn git_route_keeps_host_loopback_gate() { + tokio::time::timeout(Duration::from_secs(10), async { + let (up, seen, upstream_task) = recording_upstream().await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{up}/org/")], None).await; + let client = http_client(); + let mut request = git_get( + proxy_address, + &format!("{up}/org/r.git/info/refs?service=git-upload-pack"), + &session.pairing, + ); + request + .headers_mut() + .insert(HOST, HeaderValue::from_static("evil.example")); + let response = client.request(request).await.unwrap(); + let (status, code, _) = refusal(response).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "host_not_allowed") + ); + assert_eq!(seen.lock().unwrap().len(), 0); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T11 (M28b): a git request body over the cap is refused before any + // upstream contact. + #[tokio::test] + async fn git_body_over_cap_refused() { + tokio::time::timeout(Duration::from_secs(10), async { + let (up, seen, upstream_task) = recording_upstream().await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{up}/org/")], None).await; + let client = http_client(); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{GIT_ROUTE}/{up}/org/r.git/git-upload-pack" + )) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::from(vec![b'0'; MAX_REQUEST_BODY + 1]))) + .unwrap(); + let response = client.request(request).await.unwrap(); + let (status, code, _) = refusal(response).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::PAYLOAD_TOO_LARGE, "body_too_large") + ); + assert_eq!(seen.lock().unwrap().len(), 0); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T12 (M23): DV-6 — a smart-HTTP path with the wrong method is refused. + #[tokio::test] + async fn git_wrong_method_refused() { + tokio::time::timeout(Duration::from_secs(10), async { + let (up, seen, upstream_task) = recording_upstream().await; + let (dir, proxy_address, _sessions, session, proxy) = + git_proxy(vec![format!("http://{up}/org/")], None).await; + let client = http_client(); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{GIT_ROUTE}/{up}/org/r.git/info/refs?service=git-upload-pack" + )) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::from_static(b"0000"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + let (status, code, _) = refusal(response).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "git_host_not_allowed") + ); + assert_eq!(seen.lock().unwrap().len(), 0); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T13 (F7, DV-7): a push RPC's 503 is never retried — it surfaces on the + // first answer and the upstream saw exactly one request. + #[tokio::test] + async fn push_rpc_503_is_not_retried() { + tokio::time::timeout(Duration::from_secs(10), async { + let (up, hits, upstream_task) = scripted_upstream(vec![503, 200]).await; + let (dir, state) = request_source_state(SocketAddr::from(([127, 0, 0, 1], 0))).await; + let mut config = default_config(); + config.git = Some(agentdesktop_core::config::GitConfig { + allow: vec!["https://github.com/org/".to_owned()], + push_allow: vec!["https://github.com/org/".to_owned()], + credentials: vec![], + }); + config.egress_upstream = Some(url::Url::parse(&format!("http://{up}/")).unwrap()); + let sessions = config.sessions.clone(); + let session = new_session(&sessions, "test-client"); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, config)); + let client = http_client(); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{GIT_ROUTE}/github.com/org/r.git/git-receive-pack" + )) + .header(PAIRING_HEADER, &*session.pairing) + .body(Full::new(Bytes::from_static(b"0000"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 1); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // T14 (F6, adv minor 10): a session revoked while the request body is + // still being collected sends nothing upstream. This pins the revocation + // watch during body collect; the `biased` ordering itself is not + // observable from the current-thread test runtime. + /// A request body streamed from an mpsc channel (T14). + type ChannelBody = StreamBody, Infallible>>>; + + #[tokio::test] + async fn revocation_during_body_upload_sends_nothing_upstream() { + let (logs, _guard) = capture_logs(); + tokio::time::timeout(Duration::from_secs(10), async { + let (up, seen, upstream_task) = recording_upstream().await; + let (dir, proxy_address, sessions, session, proxy) = + git_proxy(vec![format!("http://{up}/org/")], None).await; + let client: Client = + Client::builder(TokioExecutor::new()).build_http(); + let (tx, rx) = mpsc::channel::, Infallible>>(2); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{GIT_ROUTE}/{up}/org/r.git/git-upload-pack" + )) + .header(PAIRING_HEADER, &*session.pairing) + .body(StreamBody::new(ReceiverStream::new(rx))) + .unwrap(); + let pending = tokio::spawn({ + let client = client.clone(); + async move { client.request(request).await.unwrap() } + }); + tx.send(Ok(Frame::data(Bytes::from_static(b"0032want ")))) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(sessions.revoke(&session.id)); + let _ = tx.send(Ok(Frame::data(Bytes::from_static(b"0000")))).await; + drop(tx); + let response = pending.await.unwrap(); + let (status, code, _) = refusal(response).await; + assert_eq!( + (status, code.as_str()), + (StatusCode::FORBIDDEN, "pairing_invalid") + ); + assert_eq!( + seen.lock().unwrap().len(), + 0, + "nothing reached the upstream" + ); + let lines = logs.wait_for_lines(1).await; + let line = lines.last().unwrap(); + assert_eq!(log_field(line, "outcome"), "canceled", "{line}"); + proxy.abort(); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } } diff --git a/crates/agent/src/llm_proxy/sessions.rs b/crates/agent/src/llm_proxy/sessions.rs index 8fb8e2f6..0ebbe485 100644 --- a/crates/agent/src/llm_proxy/sessions.rs +++ b/crates/agent/src/llm_proxy/sessions.rs @@ -221,6 +221,15 @@ impl SessionRegistry { entries.len() != before } + /// Whether the session still exists and has not expired. Read-only: unlike + /// `authorize`, this does not restart the idle timeout, so an in-flight + /// request can poll it without keeping its session alive. + pub(crate) fn live(&self, id: &str, now: u64) -> bool { + self.lock() + .iter() + .any(|entry| entry.id == id && !entry.expired(now)) + } + /// Unexpired sessions, oldest first, without their pairings. pub(crate) fn list(&self, now: u64) -> Vec { let entries = self.lock(); diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index cde0ee42..55c15efb 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -142,13 +142,83 @@ pub struct ContainmentStartupConfig { /// startup only (restart the daemon after changing it). Requires user /// mode; ignored with `--once` and `--dry-run`. pub executor: PathBuf, - /// Outbound network for the sandboxed program: `allow` (default) or `deny`. With `deny` the box reaches only loopback: model requests go through the local LLM proxy, and git over HTTPS and package downloads fail. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`. + /// Outbound network for the sandboxed program: `deny` (default) or `allow`. With `deny` the box reaches only loopback and configured egress routes. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`. #[serde(default, skip_serializing_if = "ContainmentEgress::is_default")] pub egress: ContainmentEgress, /// Sandbox engine: `mxc` (default, the only engine). Read at startup only /// (restart the daemon after changing it); ignored with `--once` and `--dry-run`. #[serde(default, skip_serializing_if = "ContainmentEngine::is_default")] pub engine: ContainmentEngine, + /// Central L7 egress endpoint (an agentgateway HTTPS listener whose + /// per-prefix routes are the central allowlist). When set, the relay sends + /// the box's git smart-HTTP requests to `https:///` + /// instead of directly to the remote. CONNECT is always refused, with or + /// without it. Read at startup only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub egress_upstream: Option, + /// Git over the relay: the remotes the box may fetch from and push to, and + /// the static credentials the relay attaches host-side. Absent = no git + /// egress. Read at startup only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, +} + +/// The central L7 egress endpoint the git leg chains to. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EgressUpstreamConfig { + /// HTTPS URL of the egress endpoint; must include a host and no + /// credentials, query or fragment. + #[cfg_attr(feature = "schema", schemars(with = "String"))] + pub url: Url, + /// Reserved for gateway-hop authentication, which is not available yet: + /// startup refuses a config that sets it (the relay sends only the git + /// credential in `Authorization`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authentication: Option, +} + +/// Git repository access configuration. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GitConfig { + /// List of HTTPS URL prefixes the box may fetch from. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow: Vec, + /// List of HTTPS URL prefixes the box may push to (subset of allow). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub push_allow: Vec, + /// Static git credentials for allowed hosts. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub credentials: Vec, +} + +/// Git credential entry for a URL prefix. +#[derive(Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GitCredentialEntry { + /// URL prefix this credential applies to. + pub url_prefix: String, + /// Username for HTTP basic auth. + pub username: String, + /// Secret (password or token) for HTTP basic auth (never serialized). + #[serde(skip_serializing)] + pub secret: String, +} + +// The secret never reaches a log or error through `{:?}` (the containing +// configs derive Debug over this). +impl std::fmt::Debug for GitCredentialEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GitCredentialEntry") + .field("url_prefix", &self.url_prefix) + .field("username", &self.username) + .field("secret", &"") + .finish() + } } /// Checks `daemon.containment`. With `windows == true` the executor must be an @@ -158,28 +228,110 @@ pub fn validate_containment_startup( cfg: &ContainmentStartupConfig, windows: bool, ) -> Result<(), String> { - if !windows { - return Ok(()); - } - let text = cfg.executor.to_string_lossy(); - let bytes = text.as_bytes(); - let drive_absolute = - bytes.len() > 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\'; - let unc_absolute = text.starts_with("\\\\") - && text[2..] - .split('\\') - .filter(|part| !part.is_empty()) - .count() - >= 3; - let exe = text - .rsplit('\\') - .next() - .is_some_and(|name| name.to_ascii_lowercase().ends_with(".exe")); - if (drive_absolute || unc_absolute) && exe { - Ok(()) - } else { - Err("daemon.containment.executor must be an absolute path to wxc-exec.exe".to_owned()) + if windows { + let text = cfg.executor.to_string_lossy(); + let bytes = text.as_bytes(); + let drive_absolute = bytes.len() > 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'\\'; + let unc_absolute = text.starts_with("\\\\") + && text[2..] + .split('\\') + .filter(|part| !part.is_empty()) + .count() + >= 3; + let exe = text + .rsplit('\\') + .next() + .is_some_and(|name| name.to_ascii_lowercase().ends_with(".exe")); + if !(drive_absolute || unc_absolute) || !exe { + return Err( + "daemon.containment.executor must be an absolute path to wxc-exec.exe".to_owned(), + ); + } + } + + // Validate egress_upstream URL like LlmGatewayConfig.url, HTTPS only + // (the leg carries the git credential). + if let Some(upstream) = &cfg.egress_upstream { + if upstream.url.scheme() != "https" { + return Err("daemon.containment.egressUpstream.url must be an https:// URL".to_owned()); + } + // v1 defers gateway-hop authentication (AC 2): a configured block + // would otherwise be silently ignored. + if upstream.authentication.is_some() { + return Err( + "daemon.containment.egressUpstream.authentication is not supported yet; remove it" + .to_owned(), + ); + } + if upstream.url.host().is_none() { + return Err("daemon.containment.egressUpstream.url must include a host".to_owned()); + } + if upstream.url.username() != "" || upstream.url.password().is_some() { + return Err( + "daemon.containment.egressUpstream.url cannot include credentials".to_owned(), + ); + } + if upstream.url.query().is_some() { + return Err("daemon.containment.egressUpstream.url cannot include a query".to_owned()); + } + if upstream.url.fragment().is_some() { + return Err( + "daemon.containment.egressUpstream.url cannot include a fragment".to_owned(), + ); + } + } + + // Validate git allow/pushAllow URLs must be https:// (and parse, with a + // host: the relay reparses them on every request). + if let Some(git) = &cfg.git { + for (idx, url_str) in git.allow.iter().enumerate() { + if !Url::parse(url_str) + .is_ok_and(|url| url.scheme() == "https" && url.host_str().is_some()) + { + return Err(format!( + "daemon.containment.git.allow[{}] must be an https:// URL", + idx + )); + } + } + for (idx, url_str) in git.push_allow.iter().enumerate() { + if !Url::parse(url_str) + .is_ok_and(|url| url.scheme() == "https" && url.host_str().is_some()) + { + return Err(format!( + "daemon.containment.git.pushAllow[{}] must be an https:// URL", + idx + )); + } + } + // With egressUpstream the relay sends only the path to the egress + // endpoint, so a second host could never be told apart there: refuse + // more than one distinct (host, port) across both lists (DV-8). + if cfg.egress_upstream.is_some() { + let mut hosts: Vec<(String, Option)> = Vec::new(); + for url_str in git.allow.iter().chain(&git.push_allow) { + if let Ok(url) = Url::parse(url_str) + && let Some(host) = url.host_str() + { + let key = (host.to_ascii_lowercase(), url.port_or_known_default()); + if !hosts.contains(&key) { + hosts.push(key); + } + } + } + if hosts.len() > 1 { + return Err( + "daemon.containment.git: with egressUpstream set, git.allow and git.pushAllow must name a single host (the egress endpoint routes by path only)" + .to_owned(), + ); + } + } } + + Ok(()) } /// Token and cost collection for a daemon without a controller. agentgateway @@ -384,9 +536,9 @@ impl ProxyUnavailable { #[serde(rename_all = "camelCase")] pub enum ContainmentEgress { /// Plain outbound access; model requests still go through the local LLM proxy. - #[default] Allow, /// Loopback only. + #[default] Deny, } @@ -2513,6 +2665,8 @@ programs: executor: executor.into(), egress: super::ContainmentEgress::default(), engine: super::ContainmentEngine::default(), + egress_upstream: None, + git: None, } } @@ -2876,13 +3030,14 @@ programs: } #[test] - fn containment_egress_absent_defaults_to_allow() { + fn containment_egress_absent_defaults_to_deny() { + // PR 4 (AC 4): the default flipped from allow to deny. let config = super::parse_local_daemon( "daemon:\n containment:\n executor: 'C:\\mxc\\wxc-exec.exe'\n", ) .expect("absent defaults"); let egress = config.daemon.unwrap().containment.unwrap().egress; - assert_eq!(egress, super::ContainmentEgress::Allow); + assert_eq!(egress, super::ContainmentEgress::Deny); } #[test] @@ -2909,26 +3064,31 @@ programs: #[test] fn containment_egress_unset_not_serialized() { + // PR 4: default flipped to Deny, so Deny is not serialized and Allow is let cfg = super::ContainmentStartupConfig { executor: std::path::PathBuf::from("/x/wxc-exec"), - egress: super::ContainmentEgress::Allow, + egress: super::ContainmentEgress::Deny, engine: super::ContainmentEngine::default(), + egress_upstream: None, + git: None, }; let json = serde_json::to_value(&cfg).unwrap(); assert!( json.get("egress").is_none(), - "default egress is not serialized" + "default egress (Deny) is not serialized" ); - let cfg_deny = super::ContainmentStartupConfig { + let cfg_allow = super::ContainmentStartupConfig { executor: std::path::PathBuf::from("/x/wxc-exec"), - egress: super::ContainmentEgress::Deny, + egress: super::ContainmentEgress::Allow, engine: super::ContainmentEngine::default(), + egress_upstream: None, + git: None, }; - let json_deny = serde_json::to_value(&cfg_deny).unwrap(); + let json_allow = serde_json::to_value(&cfg_allow).unwrap(); assert_eq!( - json_deny.get("egress").and_then(|v| v.as_str()), - Some("deny"), + json_allow.get("egress").and_then(|v| v.as_str()), + Some("allow"), "deny egress is serialized" ); } @@ -2960,6 +3120,8 @@ programs: executor: std::path::PathBuf::from("/x/wxc-exec"), egress: super::ContainmentEgress::Allow, engine: super::ContainmentEngine::default(), + egress_upstream: None, + git: None, }; let json = serde_json::to_value(&cfg).unwrap(); assert!( @@ -2967,4 +3129,122 @@ programs: "default engine is not serialized" ); } + + /// DV-8: with egressUpstream set, git.allow + git.pushAllow must name a + /// single (host, port) — the egress endpoint routes by path only. + #[test] + fn containment_git_single_host_with_egress_upstream() { + let upstream = || { + Some(super::EgressUpstreamConfig { + url: url::Url::parse("https://egress.example.com").unwrap(), + authentication: None, + }) + }; + let cfg = |upstream, allow: &[&str], push_allow: &[&str]| super::ContainmentStartupConfig { + executor: std::path::PathBuf::from("C:\\mxc\\wxc-exec.exe"), + egress: super::ContainmentEgress::default(), + engine: super::ContainmentEngine::default(), + egress_upstream: upstream, + git: Some(super::GitConfig { + allow: allow.iter().map(|s| (*s).to_owned()).collect(), + push_allow: push_allow.iter().map(|s| (*s).to_owned()).collect(), + credentials: vec![], + }), + }; + // Two hosts in allow: refused. + let result = super::validate_containment_startup( + &cfg( + upstream(), + &["https://github.com/org/", "https://gitlab.com/org/"], + &[], + ), + true, + ); + assert!( + result + .as_ref() + .is_err_and(|error| error.contains("single host")), + "{result:?}" + ); + // A second host via pushAllow: refused too. + assert!( + super::validate_containment_startup( + &cfg( + upstream(), + &["https://github.com/org/"], + &["https://gitlab.com/x/"] + ), + true, + ) + .is_err() + ); + // Case-insensitive host, default port: one key. + assert_eq!( + super::validate_containment_startup( + &cfg( + upstream(), + &["https://github.com/a/", "https://GitHub.com:443/b/"], + &[] + ), + true, + ), + Ok(()) + ); + // Same host, different ports: two keys, refused. + assert!( + super::validate_containment_startup( + &cfg( + upstream(), + &["https://git.corp/", "https://git.corp:8443/"], + &[] + ), + true, + ) + .is_err() + ); + // Without egressUpstream, two hosts stay allowed. + assert_eq!( + super::validate_containment_startup( + &cfg( + None, + &["https://github.com/org/", "https://gitlab.com/org/"], + &[] + ), + true, + ), + Ok(()) + ); + // An entry without a host never passes (with or without upstream). + assert!( + super::validate_containment_startup(&cfg(upstream(), &["https://"], &[]), true) + .is_err() + ); + } + + /// The credential secret never reaches a `{:?}` rendering (manual Debug). + #[test] + fn git_credential_entry_debug_redacts_secret() { + let entry = super::GitCredentialEntry { + url_prefix: "https://github.com/org/".to_owned(), + username: "x-access-token".to_owned(), + secret: "sup3rs3cret-PAT".to_owned(), + }; + let rendered = format!("{entry:?}"); + assert!(!rendered.contains("sup3rs3cret-PAT"), "{rendered}"); + assert!(rendered.contains(""), "{rendered}"); + let containment = super::ContainmentStartupConfig { + executor: std::path::PathBuf::from("C:\\mxc\\wxc-exec.exe"), + egress: super::ContainmentEgress::default(), + engine: super::ContainmentEngine::default(), + egress_upstream: None, + git: Some(super::GitConfig { + allow: vec!["https://github.com/org/".to_owned()], + push_allow: vec![], + credentials: vec![entry], + }), + }; + let rendered = format!("{containment:?}"); + assert!(!rendered.contains("sup3rs3cret-PAT"), "{rendered}"); + assert!(rendered.contains(""), "{rendered}"); + } } diff --git a/schema/daemon-config.json b/schema/daemon-config.json index e8d16794..d78f8949 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -203,9 +203,20 @@ "type": "object", "properties": { "egress": { - "description": "Outbound network for the sandboxed program: `allow` (default) or `deny`. With `deny` the box reaches only loopback: model requests go through the local LLM proxy, and git over HTTPS and package downloads fail. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`.", + "description": "Outbound network for the sandboxed program: `deny` (default) or `allow`. With `deny` the box reaches only loopback and configured egress routes. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`.", "$ref": "#/$defs/ContainmentEgress" }, + "egressUpstream": { + "description": "Central L7 egress endpoint (an agentgateway HTTPS listener whose\nper-prefix routes are the central allowlist). When set, the relay sends\nthe box's git smart-HTTP requests to `https:///`\ninstead of directly to the remote. CONNECT is always refused, with or\nwithout it. Read at startup only.", + "anyOf": [ + { + "$ref": "#/$defs/EgressUpstreamConfig" + }, + { + "type": "null" + } + ] + }, "engine": { "description": "Sandbox engine: `mxc` (default, the only engine). Read at startup only\n(restart the daemon after changing it); ignored with `--once` and `--dry-run`.", "$ref": "#/$defs/ContainmentEngine" @@ -213,6 +224,17 @@ "executor": { "description": "Windows only. Absolute path to MXC `wxc-exec.exe`; no default. Read at\nstartup only (restart the daemon after changing it). Requires user\nmode; ignored with `--once` and `--dry-run`.", "type": "string" + }, + "git": { + "description": "Git over the relay: the remotes the box may fetch from and push to, and\nthe static credentials the relay attaches host-side. Absent = no git\negress. Read at startup only.", + "anyOf": [ + { + "$ref": "#/$defs/GitConfig" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false, @@ -413,6 +435,84 @@ }, "additionalProperties": false }, + "EgressUpstreamConfig": { + "description": "The central L7 egress endpoint the git leg chains to.", + "type": "object", + "properties": { + "authentication": { + "description": "Reserved for gateway-hop authentication, which is not available yet:\nstartup refuses a config that sets it (the relay sends only the git\ncredential in `Authorization`).", + "anyOf": [ + { + "$ref": "#/$defs/LlmGatewayAuthentication" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "HTTPS URL of the egress endpoint; must include a host and no\ncredentials, query or fragment.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "url" + ] + }, + "GitConfig": { + "description": "Git repository access configuration.", + "type": "object", + "properties": { + "allow": { + "description": "List of HTTPS URL prefixes the box may fetch from.", + "type": "array", + "items": { + "type": "string" + } + }, + "credentials": { + "description": "Static git credentials for allowed hosts.", + "type": "array", + "items": { + "$ref": "#/$defs/GitCredentialEntry" + } + }, + "pushAllow": { + "description": "List of HTTPS URL prefixes the box may push to (subset of allow).", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "GitCredentialEntry": { + "description": "Git credential entry for a URL prefix.", + "type": "object", + "properties": { + "secret": { + "description": "Secret (password or token) for HTTP basic auth (never serialized).", + "type": "string", + "writeOnly": true + }, + "urlPrefix": { + "description": "URL prefix this credential applies to.", + "type": "string" + }, + "username": { + "description": "Username for HTTP basic auth.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "urlPrefix", + "username", + "secret" + ] + }, "GitHubOAuthConfig": { "description": "GitHub App user authorization for Copilot requests through the local proxy.", "type": "object", diff --git a/schema/daemon-config.md b/schema/daemon-config.md index 546af7af..979eaab4 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -16,9 +16,29 @@ |`daemon.codex`|object|Codex paths.| |`daemon.codex.config`|string|Configuration file.| |`daemon.containment`|object|Sandboxed launch (`agentdesktop run`).| -|`daemon.containment.egress`|enum|Outbound network for the sandboxed program: `allow` (default) or `deny`. With `deny` the box reaches only loopback: model requests go through the local LLM proxy, and git over HTTPS and package downloads fail. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`.
Possible values: `allow`, `deny`.| +|`daemon.containment.egress`|enum|Outbound network for the sandboxed program: `deny` (default) or `allow`. With `deny` the box reaches only loopback and configured egress routes. Read at startup only (restart the daemon after changing it); ignored with `--once` and `--dry-run`.
Possible values: `allow`, `deny`.| +|`daemon.containment.egressUpstream`|object|Central L7 egress endpoint (an agentgateway HTTPS listener whose
per-prefix routes are the central allowlist). When set, the relay sends
the box's git smart-HTTP requests to `https:///`
instead of directly to the remote. CONNECT is always refused, with or
without it. Read at startup only.| +|`daemon.containment.egressUpstream.authentication`|object|Reserved for gateway-hop authentication, which is not available yet:
startup refuses a config that sets it (the relay sends only the git
credential in `Authorization`).| +|`daemon.containment.egressUpstream.authentication.allowedClientIds`|[]string|Client identifiers permitted to request credentials for this gateway.| +|`daemon.containment.egressUpstream.authentication.audience`|string|Audience placed in the issued JWT. This must match the gateway's expected audience.| +|`daemon.containment.egressUpstream.authentication.type`|enum|Possible values: `controllerJwt`.| +|`daemon.containment.egressUpstream.authentication.allowInsecure`|boolean|Permit loopback HTTP endpoints for isolated local development.| +|`daemon.containment.egressUpstream.authentication.clientId`|string|Public OpenID Connect client identifier.| +|`daemon.containment.egressUpstream.authentication.deviceAuthorization`|boolean|Sign in with the OAuth 2.0 Device Authorization Grant (RFC 8628)
instead of a browser redirect to a local callback.

Use this on hosts without a local browser, such as servers reached
over SSH. Run `agentdesktop-headless login` to get a verification
URL and user code to approve from any other device. The identity
provider must support device authorization for this client.| +|`daemon.containment.egressUpstream.authentication.issuer`|string|Exact OpenID Connect issuer URL.| +|`daemon.containment.egressUpstream.authentication.redirectUri`|string|Loopback redirect URI registered for the native client.| +|`daemon.containment.egressUpstream.authentication.scopes`|[]string|Scopes requested during sign-in.| +|`daemon.containment.egressUpstream.authentication.type`|enum|Possible values: `oidc`.| +|`daemon.containment.egressUpstream.url`|string|HTTPS URL of the egress endpoint; must include a host and no
credentials, query or fragment.| |`daemon.containment.engine`|enum|Sandbox engine: `mxc` (default, the only engine). Read at startup only
(restart the daemon after changing it); ignored with `--once` and `--dry-run`.
Possible values: `mxc`.| |`daemon.containment.executor`|string|Windows only. Absolute path to MXC `wxc-exec.exe`; no default. Read at
startup only (restart the daemon after changing it). Requires user
mode; ignored with `--once` and `--dry-run`.| +|`daemon.containment.git`|object|Git over the relay: the remotes the box may fetch from and push to, and
the static credentials the relay attaches host-side. Absent = no git
egress. Read at startup only.| +|`daemon.containment.git.allow`|[]string|List of HTTPS URL prefixes the box may fetch from.| +|`daemon.containment.git.credentials`|[]object|Static git credentials for allowed hosts.| +|`daemon.containment.git.credentials[].secret`|string|Secret (password or token) for HTTP basic auth (never serialized).| +|`daemon.containment.git.credentials[].urlPrefix`|string|URL prefix this credential applies to.| +|`daemon.containment.git.credentials[].username`|string|Username for HTTP basic auth.| +|`daemon.containment.git.pushAllow`|[]string|List of HTTPS URL prefixes the box may push to (subset of allow).| |`daemon.copilot`|object|GitHub Copilot CLI paths (`config` = the `providers.json` to manage;
defaults to `COPILOT_PROVIDERS_CONFIG`, `$COPILOT_HOME/providers.json`,
then `~/.copilot/providers.json`).| |`daemon.copilot.config`|string|Configuration file.| |`daemon.grok`|object|Grok Build paths.| From aec58664acc4806dc642ac0dcb9d093ec6aa5f64 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Wed, 28 Oct 2026 11:06:20 +0100 Subject: [PATCH 09/19] ssh: agent broker and git over ssh Git over ssh from the box, without any key material inside it: the daemon brokers the user's ssh agent through a per-launch channel, and only explicitly granted keys are usable. Broker core (launch/ssh/mod.rs, OS-neutral): ssh-agent framing with a 256 KiB cap (zero/oversize close with one log line, truncated = silent EOF); op policy forwards 11/13, answers 17-27 and unknown ops SSH_AGENT_FAILURE locally; deny-by-default key filter (IDENTITIES_ANSWER re-encoded with the allowed keys, SIGN_REQUEST for other keys refused before upstream); upstream failure or a bad reply frame answers FAILURE with decision=upstream_unreachable; per-request session check plus a 50 ms revocation poll (an in-flight request gets EOF, outcome=canceled); one decision log line per request. key_fingerprint = SHA256:. A connection budget caps the channel at 8; serve_leg binds one loopback leg per configured host and dials host:22 with a 10 s timeout. Launch wiring (only with git.ssh.hosts non-empty; without it the request bytes are unchanged): legs bound before spawn; ssh_config + known_hosts (0600, seeded host-side) written, ssh_config granted read-only and known_hosts read-write; SSH_AUTH_SOCK set; the gitconfig drops the /git-ssh insteadOf for ssh hosts. Post-spawn pipe setup fails the launch closed (executor stopped, session revoked, legs closed). Git runs GIT_SSH_COMMAND through sh, and Git for Windows' msys sh cannot start in the AppContainer, so the box gets GIT_SSH=\git_ssh and GIT_SSH_VARIANT=ssh instead. git_ssh is a one-line "#!/" script written beside ssh_config and granted read-only; the executable (hooked first in main) runs %SystemRoot%\System32\OpenSSH\ssh.exe -F \ssh_config with git's arguments unchanged. A client stem with whitespace fails the launch, because git cuts the #! line at the first space. %SystemRoot%\System32\OpenSSH joins the box PATH when ssh hosts are configured. Windows (cfg(windows)): AppContainer-descendant SID scan and TokenAppContainerSid reader; per-launch pipe with SDDL D:(A;;GA;;;)(A;;GRGW;;;); first-pipe-instance squat check (a squatter fails the launch closed); client SID verification; per-request upstream to \\.\pipe\openssh-ssh-agent; create/teardown log lines. Non-Windows stub bails. README documents the posture: signatures are not destination-bound; the key filter is the blast-radius control; legs are machine-wide; ssh hosts are host-granular. Co-Authored-By: Claude Fable 5 --- README.md | 54 +- crates/agent/Cargo.toml | 1 + crates/agent/src/bin/agentdesktop-headless.rs | 6 + crates/agent/src/daemon.rs | 5 +- crates/agent/src/launch/git_ssh.rs | 164 ++ crates/agent/src/launch/host.rs | 225 +- crates/agent/src/launch/mod.rs | 314 ++- crates/agent/src/launch/ssh/mod.rs | 2408 +++++++++++++++++ crates/agent/src/launch/ssh/pipe.rs | 312 +++ crates/agent/src/launch/tests.rs | 576 +++- crates/agent/src/launch/writer_tests.rs | 4 + crates/agent/src/llm_proxy/mod.rs | 82 +- crates/agentdesktop/src/main.rs | 6 + crates/core/src/config.rs | 447 +++ schema/daemon-config.json | 32 + schema/daemon-config.md | 3 + 16 files changed, 4566 insertions(+), 73 deletions(-) create mode 100644 crates/agent/src/launch/git_ssh.rs create mode 100644 crates/agent/src/launch/ssh/mod.rs create mode 100644 crates/agent/src/launch/ssh/pipe.rs diff --git a/README.md b/README.md index 8592f795..0bf7407b 100644 --- a/README.md +++ b/README.md @@ -702,13 +702,16 @@ daemon: 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 only) + # 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 ``` @@ -733,7 +736,9 @@ 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 get a clear refusal (ssh is not available in the box yet). +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 @@ -749,6 +754,51 @@ 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`. diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 5f714c52..5ab7f6bf 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -91,6 +91,7 @@ windows-sys = { version = "0.61", features = [ "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", + "Win32_System_Diagnostics_ToolHelp", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", 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/daemon.rs b/crates/agent/src/daemon.rs index 29f0fc2d..cf255a7c 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -746,10 +746,7 @@ where host: Arc::new(launch::host::HostEnvironment::new( home_directory().unwrap_or_default(), state.state_dir.clone(), - agentdesktop_client_executable()? - .parent() - .map(Path::to_path_buf) - .unwrap_or_default(), + &agentdesktop_client_executable()?, )), registry: launch::LaunchRegistry::new(), availability: containment, 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 +//! `