diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index f5942a8..392d902 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -84,6 +84,9 @@ on: - "app/**" - "VERSION" - ".gitea/workflows/build-app-preview.yml" + # The app embeds the agent's sources for its local image build, and the + # agent's own tests run in the `test` job below. + - "container/triple-c-agent/**" workflow_dispatch: jobs: @@ -332,6 +335,16 @@ jobs: export PATH="$HOME/.cargo/bin:$PATH" cargo test --locked + # The in-container agent is its own crate (container/triple-c-agent): a + # real server on loopback, exercised over HTTP/WebSocket as the current + # user. Its root-only test is #[ignore]d. The Docker-parity test against + # a real container lives in the app (host::agent::live_tests, ignored). + - name: Agent tests (cargo test) + working-directory: ./container/triple-c-agent + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo test --locked + build-linux: runs-on: ubuntu-latest needs: [compute-version, create-release] diff --git a/.gitignore b/.gitignore index 1cbdf74..acfd1df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ app/dist/ app/src-tauri/target/ +container/triple-c-agent/target/ # Written by build.rs (tauri-build AppManifest); gen/schemas/acl-manifests.json is the # tracked, reviewable form of the same information. app/src-tauri/permissions/autogenerated/ diff --git a/CLAUDE.md b/CLAUDE.md index 439ac46..d2b4cd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -283,8 +283,12 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once users have migrated. - **`host/`** — Where a project's container runs, and the only way into it. `ContainerTarget` - (one `Docker` variant today; AWS planned — `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`) + (`Docker`, and `Agent` — reached through the in-container `triple-c-agent` by `agent.rs`, per + `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`; AWS planned — + `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`) wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`. + `agent.rs` reuses `docker/exec.rs`'s `OneshotOutput`, host-file reader and uid probe so the two + variants cannot drift; an `Agent` target's token never appears in `Debug` or error text. **Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or `get_docker()`** — `host::tests::no_container_interior_bollard_outside_the_host_layer` fails the build on it. `lifecycle.rs`'s `ProjectHost` owns create/start/stop/find/remove and @@ -334,6 +338,19 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li subscription) is what flips a project whose container exits on its own to Stopped and emits `project-container-exited` with a `reason` (`exited` toasts; `found_stopped`, the first look of a run, is silent); `useProjectStatusEvents` applies it - **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations +- **`triple-c-agent/`** — the in-container agent (its own Rust crate; protocol in + `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`). Built static by the Dockerfile's + `agent-build` stage, started by `entrypoint.sh` **only when a token is configured**, last, as root + under a supervisor loop. **Its contract is Docker parity**: an `ExecSpec` must behave through the + agent exactly as through `docker exec`. Any change to exec semantics on either side is checked by + `host::agent::live_tests::the_agent_answers_exactly_as_docker_exec_does` (ignored; needs an image + with the agent — see the crate README). The token never stays in the environment and never touches the + writable layer: the entrypoint moves it to a root-only file on `/dev/shm` (tmpfs — a file in the + writable layer would ride `docker commit` into the snapshot image) before anything runs, and the + agent refuses a token file others can read. The agent is started from an env/cwd snapshot taken + at the top of the entrypoint and held **in memory only** (it contains every API key in the env), + so execs see the container's configured env, not the script's. The app treats the agent as + untrusted: no redirects, capped bodies, timeouts on every small call (`host/agent.rs`). **`/home/claude` in the image is seed-only.** It is the mount point of the named volume `triple-c-home-{projectId}`, so after a project's *first* start the image's copy of that directory diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 27b38b6..663962f 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -6642,6 +6642,8 @@ dependencies = [ "tauri-plugin-opener", "tempfile", "tokio", + "tokio-tungstenite", + "tokio-util", "tower-http", "url", "uuid", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index d2f8c1d..8579095 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -33,6 +33,13 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus iana-time-zone = "0.1" sha2 = "0.10" axum = { version = "0.8", features = ["ws"] } +# Client side of triple-c-agent's two WebSocket endpoints (host/agent.rs). The +# same version axum's `ws` already pulls in; plain ws:// only (the transport is +# loopback or an SSM port-forward), so no TLS features. +tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect"] } +# `PollSender`: the agent attach's stdin `AsyncWrite` over a bounded channel. +# Already in the tree through tokio's ecosystem. +tokio-util = "0.7" tower-http = { version = "0.6", features = ["cors"] } base64 = "0.22" rand = "0.9" diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index 3a7ab60..19a23f4 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -206,31 +206,8 @@ pub async fn upload_host_file_with_ids( let mtime = now_epoch_secs(); let tar_buf = tokio::task::spawn_blocking(move || -> Result, String> { - // The caller resolved this path (`resolve_host_read_path`); opening it - // is a second trip through the same directories, so the descriptor is - // checked against the path that was validated before its bytes are - // packed into anything. Two paths reach here: the terminal's drop - // target, and the Files pane's upload via `upload_host_file_with_ids`. - // Between them they are how host bytes enter a container. - let file = std::fs::File::open(&host_path) - .map_err(|e| format!("Failed to read {}: {}", host_path, e))?; - crate::commands::file_commands::verify_opened_path( - &file, - std::path::Path::new(&host_path), - )?; - let mut data = Vec::new(); - std::io::Read::read_to_end( - &mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)), - &mut data, - ) - .map_err(|e| format!("Failed to read {}: {}", host_path, e))?; - if data.len() as u64 > MAX_DROP_BYTES { - return Err(format!( - "File too large to upload (limit {} MB)", - MAX_DROP_BYTES / (1024 * 1024) - )); - } - build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime) + let data = read_host_file_for_upload(&host_path)?; + build_single_file_tar(&dest_for_blk, &data[..], HOST_FILE_UPLOAD_MODE, uid, gid, mtime) }) .await .map_err(|e| format!("Upload task panicked: {}", e))??; @@ -251,13 +228,46 @@ pub async fn upload_host_file_with_ids( Ok(container_join(dest_dir, &dest_name)) } +/// The mode a host file lands with in the container, on every host. +pub(crate) const HOST_FILE_UPLOAD_MODE: u32 = 0o644; + +/// Read a host file that is about to be copied into a container. Blocking: +/// call it from `spawn_blocking`. Shared by every host's upload so the checks +/// cannot drift apart. +/// +/// The caller resolved this path (`resolve_host_read_path`); opening it is a +/// second trip through the same directories, so the descriptor is checked +/// against the path that was validated before its bytes are packed into +/// anything. Two paths reach here: the terminal's drop target, and the Files +/// pane's upload via `upload_host_file_with_ids`. Between them they are how +/// host bytes enter a container. [`MAX_DROP_BYTES`] is applied to what the +/// open descriptor actually yields, not to an earlier `stat`. +pub(crate) fn read_host_file_for_upload(host_path: &str) -> Result, String> { + let file = std::fs::File::open(host_path) + .map_err(|e| format!("Failed to read {}: {}", host_path, e))?; + crate::commands::file_commands::verify_opened_path(&file, std::path::Path::new(host_path))?; + let mut data = Vec::new(); + std::io::Read::read_to_end( + &mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)), + &mut data, + ) + .map_err(|e| format!("Failed to read {}: {}", host_path, e))?; + if data.len() as u64 > MAX_DROP_BYTES { + return Err(format!( + "File too large to upload (limit {} MB)", + MAX_DROP_BYTES / (1024 * 1024) + )); + } + Ok(data) +} + /// Join a container directory to a name that may itself carry separators. /// /// Only the *reported* path — the bytes have already landed by the time this is /// called — but that path is what the terminal echoes and what the Files pane /// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x` /// is worth the four lines. `"/"` trims to `""` and yields `/x`. -fn container_join(dir: &str, name: &str) -> String { +pub(crate) fn container_join(dir: &str, name: &str) -> String { format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/')) } @@ -362,12 +372,26 @@ pub async fn container_user_ids(container_id: &str) -> (u64, u64) { let (out, _) = exec_oneshot_limited( container_id, "claude", - vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()], - 256, + user_ids_cmd(), + USER_IDS_OUTPUT_LIMIT, ) .await .unwrap_or_default(); + parse_user_ids(&out) +} +/// The command [`container_user_ids`] runs (as `claude`, under +/// [`USER_IDS_OUTPUT_LIMIT`]); every host asks the same question. +pub(crate) fn user_ids_cmd() -> Vec { + vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()] +} + +/// Output ceiling for the uid probe: two short numbers. +pub(crate) const USER_IDS_OUTPUT_LIMIT: usize = 256; + +/// `id -u; id -g` output -> `(uid, gid)`, falling back to the image's +/// build-time `(1000, 1000)`. +pub(crate) fn parse_user_ids(out: &str) -> (u64, u64) { let mut ids = out.lines().filter_map(|l| l.trim().parse::().ok()); match (ids.next(), ids.next()) { (Some(uid), Some(gid)) => (uid, gid), @@ -495,21 +519,54 @@ pub async fn exec_oneshot_as( /// fail". `stdout_ranges` indexes the parts of it that came from stdout, so a /// caller that is *parsing* output can have just that without the buffer being /// held twice. -struct OneshotOutput { +pub(crate) struct OneshotOutput { combined: Vec, stdout_ranges: Vec<(usize, usize)>, - exit_code: i64, + pub(crate) exit_code: i64, +} + +/// The refusal for output past `limit`, starting with [`OUTPUT_LIMIT_MARKER`]. +pub(crate) fn output_limit_error(limit: usize) -> String { + format!( + "{}: Command output exceeded {} bytes and was abandoned", + OUTPUT_LIMIT_MARKER, limit + ) } impl OneshotOutput { + pub(crate) fn new() -> Self { + Self { + combined: Vec::new(), + stdout_ranges: Vec::new(), + exit_code: 0, + } + } + + /// Append one chunk in arrival order, or refuse it (appending nothing) + /// once the combined output would pass `limit`. The caller must stop + /// reading on an error rather than truncate silently: every caller parses + /// this output, and a half-read manifest or JSON array is worse than an + /// error. + pub(crate) fn push(&mut self, from_stdout: bool, bytes: &[u8], limit: usize) -> Result<(), String> { + match push_capped(&mut self.combined, bytes, limit) { + Some(range) => { + if from_stdout { + self.stdout_ranges.push(range); + } + Ok(()) + } + None => Err(output_limit_error(limit)), + } + } + /// Everything the command printed, in the order it printed it. - fn text(&self) -> String { + pub(crate) fn text(&self) -> String { String::from_utf8_lossy(&self.combined).into_owned() } /// stdout alone — for callers that parse it, where a diagnostic spliced in /// mid-record is a parse error at best. - fn stdout(&self) -> String { + pub(crate) fn stdout(&self) -> String { let mut out = Vec::with_capacity(self.combined.len()); for (start, end) in &self.stdout_ranges { out.extend_from_slice(&self.combined[*start..*end]); @@ -518,7 +575,7 @@ impl OneshotOutput { } /// stderr alone — the complement of [`Self::stdout`], i.e. the diagnostics. - fn stderr(&self) -> String { + pub(crate) fn stderr(&self) -> String { let mut out = Vec::with_capacity(self.combined.len()); let mut cursor = 0usize; for (start, end) in &self.stdout_ranges { @@ -587,8 +644,7 @@ async fn exec_oneshot_raw( .await .map_err(|e| format!("Failed to start exec: {}", e))?; - let mut combined: Vec = Vec::new(); - let mut stdout_ranges: Vec<(usize, usize)> = Vec::new(); + let mut out = OneshotOutput::new(); match result { StartExecResults::Attached { mut output, .. } => { while let Some(msg) = output.next().await { @@ -596,23 +652,9 @@ async fn exec_oneshot_raw( Ok(data) => { let from_stdout = matches!(data, LogOutput::StdOut { .. }); let bytes = data.into_bytes(); - match push_capped(&mut combined, &bytes, limit) { - Some(range) => { - if from_stdout { - stdout_ranges.push(range); - } - } - // Stop reading rather than truncate silently: every - // caller parses this output, and a half-read - // manifest or JSON array is worse than an error. - // Dropping `output` kills the exec's stream. - None => { - return Err(format!( - "{}: Command output exceeded {} bytes and was abandoned", - OUTPUT_LIMIT_MARKER, limit - )) - } - } + // Over the limit: returning drops `output`, which + // kills the exec's stream. + out.push(from_stdout, &bytes, limit)?; } Err(e) => return Err(format!("Exec output error: {}", e)), } @@ -623,13 +665,8 @@ async fn exec_oneshot_raw( // The output stream draining doesn't strictly guarantee inspect_exec has the // final exit_code populated yet, so poll until the exec reports finished. - let exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?; - - Ok(OneshotOutput { - combined, - stdout_ranges, - exit_code, - }) + out.exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?; + Ok(out) } /// Turn "the exit code could not be determined" into an error rather than a 0. @@ -662,7 +699,7 @@ fn require_exit_code(code: Option) -> Result { /// spurious "the rename failed" and a real one. pub async fn wait_for_exec_exit(exec_id: &str) -> Option { let docker = get_docker().ok()?; - for _ in 0..200 { + for _ in 0..EXEC_EXIT_POLLS { match docker.inspect_exec(exec_id).await { Ok(info) => { if info.running != Some(true) { @@ -681,30 +718,27 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option { } Err(_) => return None, } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; + tokio::time::sleep(EXEC_EXIT_POLL_INTERVAL).await; } None } +/// [`wait_for_exec_exit`]'s budget: polls, and the pause between them. Every +/// host's `wait_exec_exit` uses the same numbers. +pub(crate) const EXEC_EXIT_POLLS: usize = 200; +pub(crate) const EXEC_EXIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25); + #[cfg(test)] mod tests { use super::*; /// The frames a demultiplexed exec hands back, as `(is_stdout, bytes)`. fn collect(frames: &[(bool, &[u8])]) -> OneshotOutput { - let mut combined = Vec::new(); - let mut stdout_ranges = Vec::new(); + let mut out = OneshotOutput::new(); for (from_stdout, bytes) in frames { - let range = push_capped(&mut combined, bytes, usize::MAX).unwrap(); - if *from_stdout { - stdout_ranges.push(range); - } - } - OneshotOutput { - combined, - stdout_ranges, - exit_code: 0, + out.push(*from_stdout, bytes, usize::MAX).unwrap(); } + out } #[test] @@ -763,10 +797,7 @@ mod tests { // The marker is what lets `list_container_files` say "too many entries" // instead, which is the thing that actually happened. assert!(!OUTPUT_LIMIT_MARKER.is_empty()); - let refusal = format!( - "{}: Command output exceeded {} bytes and was abandoned", - OUTPUT_LIMIT_MARKER, MAX_ONESHOT_OUTPUT - ); + let refusal = output_limit_error(MAX_ONESHOT_OUTPUT); assert!(refusal.starts_with(OUTPUT_LIMIT_MARKER)); } diff --git a/app/src-tauri/src/docker/image.rs b/app/src-tauri/src/docker/image.rs index d1556cb..9f5ddae 100644 --- a/app/src-tauri/src/docker/image.rs +++ b/app/src-tauri/src/docker/image.rs @@ -15,8 +15,16 @@ const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runn const OSC52_CLIPBOARD: &str = include_str!("../../../../container/osc52-clipboard"); const AUDIO_SHIM: &str = include_str!("../../../../container/audio-shim"); const SSO_REFRESH: &str = include_str!("../../../../container/triple-c-sso-refresh"); +const TRIPLE_C_OPEN: &str = include_str!("../../../../container/triple-c-open"); +const PLAYWRIGHT_HEAL: &str = include_str!("../../../../container/triple-c-playwright-heal"); static MISSION_CONTROL_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/mission-control"); +static SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/skills"); +// The agent crate's sources only — never the whole crate directory, which can +// hold a multi-hundred-megabyte `target/` from a local build. +const AGENT_CARGO_TOML: &str = include_str!("../../../../container/triple-c-agent/Cargo.toml"); +const AGENT_CARGO_LOCK: &str = include_str!("../../../../container/triple-c-agent/Cargo.lock"); +static AGENT_SRC_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/triple-c-agent/src"); pub async fn image_exists(image_name: &str) -> Result { let docker = get_docker()?; @@ -137,10 +145,28 @@ where let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?; + // The Dockerfile builds `triple-c-agent` in a stage pinned to + // `$BUILDPLATFORM` and cross-compiles for `$TARGETARCH`, so CI's arm64 + // image compiles natively instead of under QEMU. BuildKit predefines both; + // the classic builder this API call uses does not, and an empty + // `--platform=` fails the build outright (verified, Engine 29.9). So pass + // them, describing the daemon's own platform: a local build is always + // native. + let arch = docker + .version() + .await + .ok() + .and_then(|v| v.arch) + .filter(|a| !a.is_empty()) + .unwrap_or_else(|| native_docker_arch().to_string()); + let build_platform = format!("linux/{}", arch); + let buildargs = local_build_args(&build_platform, &arch); + let options = BuildImageOptions { t: full_name.as_str(), rm: true, forcerm: true, + buildargs, ..Default::default() }; @@ -191,6 +217,20 @@ fn append_embedded_dir( Ok(()) } +/// Docker's name for this machine's architecture, for when the daemon does +/// not say. +fn native_docker_arch() -> &'static str { + match std::env::consts::ARCH { + "aarch64" => "arm64", + "x86_64" => "amd64", + other => other, + } +} + +fn local_build_args<'a>(build_platform: &'a str, arch: &'a str) -> HashMap<&'a str, &'a str> { + HashMap::from([("BUILDPLATFORM", build_platform), ("TARGETARCH", arch)]) +} + fn create_build_context() -> Result, std::io::Error> { let mut buf = Vec::new(); { @@ -203,8 +243,14 @@ fn create_build_context() -> Result, std::io::Error> { append_file_to_archive(&mut archive, "osc52-clipboard", OSC52_CLIPBOARD.as_bytes(), 0o755)?; append_file_to_archive(&mut archive, "audio-shim", AUDIO_SHIM.as_bytes(), 0o755)?; append_file_to_archive(&mut archive, "triple-c-sso-refresh", SSO_REFRESH.as_bytes(), 0o755)?; + append_file_to_archive(&mut archive, "triple-c-open", TRIPLE_C_OPEN.as_bytes(), 0o755)?; + append_file_to_archive(&mut archive, "triple-c-playwright-heal", PLAYWRIGHT_HEAL.as_bytes(), 0o755)?; append_embedded_dir(&mut archive, &MISSION_CONTROL_DIR, "mission-control")?; + append_embedded_dir(&mut archive, &SKILLS_DIR, "skills")?; + append_file_to_archive(&mut archive, "triple-c-agent/Cargo.toml", AGENT_CARGO_TOML.as_bytes(), 0o644)?; + append_file_to_archive(&mut archive, "triple-c-agent/Cargo.lock", AGENT_CARGO_LOCK.as_bytes(), 0o644)?; + append_embedded_dir(&mut archive, &AGENT_SRC_DIR, "triple-c-agent/src")?; archive.finish()?; } @@ -213,6 +259,46 @@ fn create_build_context() -> Result, std::io::Error> { Ok(buf) } +#[cfg(test)] +mod build_context_tests { + use super::{create_build_context, DOCKERFILE}; + + /// The local build (`ImageSource::LocalBuild`) ships the Dockerfile with a + /// hand-assembled context, and nothing else checks the two agree: CI builds + /// from the real `container/` directory, so a `COPY` of something missing + /// here only fails for users who build locally. That went unnoticed for + /// `skills`, `triple-c-open` and `triple-c-playwright-heal`. Every `COPY` + /// source (not `--from=` stages) must be present as a file or directory + /// prefix in the archive. + #[test] + fn every_dockerfile_copy_source_is_in_the_local_build_context() { + let tar_bytes = create_build_context().expect("context builds"); + let mut archive = tar::Archive::new(&tar_bytes[..]); + let paths: Vec = archive + .entries() + .unwrap() + .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned()) + .collect(); + let mut missing = Vec::new(); + for line in DOCKERFILE.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("COPY ") else { continue }; + let args: Vec<&str> = rest.split_whitespace().filter(|a| !a.starts_with("--")).collect(); + if rest.contains("--from=") || args.len() < 2 { + continue; + } + for src in &args[..args.len() - 1] { + let src = src.trim_end_matches('/'); + let present = paths.iter().any(|p| p == src || p.starts_with(&format!("{}/", src))); + if !present { + missing.push(src.to_string()); + } + } + } + assert!(missing.is_empty(), "COPY sources missing from the local build context: {:?}", missing); + } +} + #[cfg(all(test, unix))] mod tests { use super::ENTRYPOINT; diff --git a/app/src-tauri/src/host/agent.rs b/app/src-tauri/src/host/agent.rs new file mode 100644 index 0000000..a8f7001 --- /dev/null +++ b/app/src-tauri/src/host/agent.rs @@ -0,0 +1,915 @@ +//! The app's side of `triple-c-agent`, the small server that runs inside a +//! project container and does over HTTP what the Docker variant does through +//! the Docker API. +//! +//! Wire protocol: `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`. +//! Its "Client mapping" table is what this file implements, one function per +//! [`ContainerTarget`](super::ContainerTarget) method. The contract is Docker +//! parity: every function here returns exactly what its `docker/exec.rs` +//! counterpart returns for the same command, including the shape of one-shot +//! output (combined in arrival order, or stdout/stderr split), the +//! `OUTPUT_LIMIT` refusal text, and the path an upload reports. +//! +//! The token never leaves this file except as the `Authorization` header: +//! [`AgentEndpoint`]'s `Debug` redacts it and no error string is built from a +//! request (reqwest and tungstenite errors name the URL, which carries no +//! secret, never the headers). + +use std::collections::{HashMap, VecDeque}; +use std::io; +use std::pin::Pin; +use std::sync::{Mutex, OnceLock}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use base64::Engine; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use serde::Deserialize; +use tokio::io::AsyncWrite; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use crate::host::OUTPUT_LIMIT_MARKER; +use tokio_tungstenite::tungstenite::Message; +use tokio_util::sync::PollSender; + +use super::{ArchiveStream, AttachedExec, ExecChunk, ExecSpec}; +use crate::docker::exec::{ + container_join, output_limit_error, parse_user_ids, read_host_file_for_upload, user_ids_cmd, + OneshotOutput, EXEC_EXIT_POLLS, EXEC_EXIT_POLL_INTERVAL, HOST_FILE_UPLOAD_MODE, + MAX_ONESHOT_OUTPUT, USER_IDS_OUTPUT_LIMIT, +}; + +/// Connecting (TCP, and the WebSocket handshake) must not hang a caller on an +/// agent that is not there. Nothing else has an overall timeout: a one-shot +/// exec, an attach or an archive download can legitimately run for minutes. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// `is_running` is a liveness probe; it answers quickly or says "no". +const HEALTH_TIMEOUT: Duration = Duration::from_secs(3); +/// Whole-request ceiling for the small calls (detached start, exec status, +/// resize) and for reading any small body. The agent is not trusted: one that +/// accepts a connection and then says nothing must not hang the app. One-shot +/// execs, attach and archive have *no* overall timeout — commands can +/// legitimately run long and send nothing until they finish, as with Docker. +#[cfg(not(test))] +const SMALL_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +/// Short in tests, so the hostile-agent tests finish quickly. +#[cfg(test)] +const SMALL_REQUEST_TIMEOUT: Duration = Duration::from_secs(1); +/// Largest WebSocket message either side may send; the agent enforces the same +/// (its output frames are at most 32 KiB). Stdin is split to fit. +const WS_MAX_MESSAGE: usize = 1024 * 1024; +const STDIN_FRAME_MAX: usize = 64 * 1024; +/// How long an attach waits for the agent's `started` reply after the +/// handshake. Spawning a process is fast; this only bounds a wedged agent. +const ATTACH_START_TIMEOUT: Duration = Duration::from_secs(30); +/// In-flight stdin frames per attach before `poll_write` applies backpressure. +const STDIN_QUEUE: usize = 64; + +/// Where an agent listens and the bearer token it wants. +/// +/// `base_url` is `http://127.0.0.1:/`: the local end of an SSM +/// port-forward on AWS, or a published loopback port in the local harness. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct AgentEndpoint { + pub base_url: url::Url, + token: String, +} + +impl std::fmt::Debug for AgentEndpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AgentEndpoint") + .field("base_url", &self.base_url.as_str()) + .field("token", &"") + .finish() + } +} + +impl AgentEndpoint { + /// An endpoint at `base_url`, which must be a plain `http://` URL with a + /// host. A trailing `/` is added so endpoint paths join beneath it. + pub fn new(mut base_url: url::Url, token: impl Into) -> Result { + if base_url.scheme() != "http" || base_url.host().is_none() { + return Err(format!( + "An agent endpoint must be an http:// URL with a host, not {}", + base_url + )); + } + if !base_url.path().ends_with('/') { + let path = format!("{}/", base_url.path()); + base_url.set_path(&path); + } + base_url.set_query(None); + base_url.set_fragment(None); + let token = token.into(); + // It goes in an HTTP header; refuse anything that cannot, here, once, + // rather than failing (or silently degrading) on every request. + if token.is_empty() || !token.bytes().all(|b| b.is_ascii_graphic()) { + return Err("An agent token must be non-empty printable ASCII with no spaces".to_string()); + } + Ok(Self { + base_url, + token, + }) + } + + /// The agent on `127.0.0.1:`. + pub fn loopback(port: u16, token: impl Into) -> Self { + let url = url::Url::parse(&format!("http://127.0.0.1:{}/", port)) + .expect("a loopback URL always parses"); + Self::new(url, token).expect("a loopback URL is a valid endpoint") + } + + fn url(&self, path: &str) -> url::Url { + self.base_url + .join(path) + .expect("endpoint paths are fixed relative paths") + } + + fn ws_url(&self, path: &str) -> url::Url { + let mut u = self.url(path); + u.set_scheme("ws").expect("http -> ws is a valid scheme change"); + u + } + + fn bearer(&self) -> String { + format!("Bearer {}", self.token) + } + + /// The `Authorization` header, marked sensitive so it is never logged or + /// shown by `Debug` (reqwest/hyper honour the flag). + fn auth(&self) -> HeaderValue { + let mut v = HeaderValue::from_str(&self.bearer()) + .unwrap_or_else(|_| HeaderValue::from_static("Bearer invalid-token")); + v.set_sensitive(true); + v + } +} + +// ── HTTP plumbing ──────────────────────────────────────────────────────────── + +/// One client for every agent: connection pooling is per host, and building a +/// client per call would redo TLS-less setup for nothing. No proxy — the agent +/// is always on loopback, and a corporate `HTTP_PROXY` must not capture it. +fn http() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .no_proxy() + // The agent lives inside the container, which is not trusted with + // the host: a redirect would let it point this client — running in + // the host app — at any other URL. A 3xx is an error (it fails the + // `is_success` checks like any other non-2xx). + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("a reqwest client with no TLS config always builds") + }) +} + +#[derive(Deserialize)] +struct ErrorBody { + error: String, +} + +/// Ceiling on any small JSON body from the agent (errors, exec ids, exec +/// status). The agent is inside the container, so a body it sends must not be +/// able to grow host memory without bound; real ones are tens of bytes. +const SMALL_BODY_CAP: usize = 64 * 1024; + +/// Ceiling on error text passed through to the UI. +const ERROR_TEXT_CAP: usize = 4 * 1024; + +/// Parse a small JSON body, reading at most [`SMALL_BODY_CAP`] bytes. +async fn small_json(resp: reqwest::Response) -> Option { + let raw = tokio::time::timeout(SMALL_REQUEST_TIMEOUT, read_capped(resp, SMALL_BODY_CAP)) + .await + .ok()? + .ok()??; + serde_json::from_slice(&raw).ok() +} + +/// The agent's `{"error": …}` text, if the body has one — bounded in both +/// bytes read and length returned. +async fn error_text(resp: reqwest::Response) -> Option { + let mut e = small_json::(resp).await?.error; + if e.len() > ERROR_TEXT_CAP { + let mut cut = ERROR_TEXT_CAP; + while !e.is_char_boundary(cut) { + cut -= 1; + } + e.truncate(cut); + e.push('…'); + } + Some(e) +} + +/// `": "` for a non-success response. +async fn status_error(context: &str, resp: reqwest::Response) -> String { + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return format!("{}: the agent rejected the credentials (401)", context); + } + match error_text(resp).await { + Some(e) => format!("{}: {}", context, e), + None => format!("{}: agent returned HTTP {}", context, status.as_u16()), + } +} + +async fn send( + context: &str, + req: reqwest::RequestBuilder, +) -> Result { + req.send().await.map_err(|e| format!("{}: {}", context, e)) +} + +/// Read a response body into memory, refusing past `cap` bytes. +async fn read_capped(mut resp: reqwest::Response, cap: usize) -> Result>, String> { + let mut body = Vec::new(); + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| format!("Exec output error: {}", e))? + { + if body.len() + chunk.len() > cap { + return Ok(None); + } + body.extend_from_slice(&chunk); + } + Ok(Some(body)) +} + +/// ExecSpec as the protocol spells it. +fn spec_json(spec: &ExecSpec) -> serde_json::Map { + let v = serde_json::json!({ + "cmd": spec.cmd, + "user": spec.user, + "working_dir": spec.working_dir, + "env": spec.env, + "tty": spec.tty, + }); + match v { + serde_json::Value::Object(m) => m, + _ => unreachable!("json! of an object literal is an object"), + } +} + +// ── one-shot execs ─────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct OneshotChunk { + s: String, + d: String, +} + +#[derive(Deserialize)] +struct OneshotResponse { + chunks: Vec, + exit_code: i64, +} + +/// `POST /v1/exec`, reassembled into the same [`OneshotOutput`] the Docker +/// path builds. Like Docker's one-shot, there is no tty, stdin is empty, and +/// the working directory is the container's default. +async fn exec_oneshot_raw( + ep: &AgentEndpoint, + user: &str, + cmd: Vec, + env: Vec, + limit: usize, +) -> Result { + const CONTEXT: &str = "Failed to start exec"; + let spec = ExecSpec::new(cmd).user(user).working_dir(None).env(env).tty(false); + let mut body = spec_json(&spec); + body.insert("limit".into(), serde_json::json!(limit)); + + let resp = send( + CONTEXT, + http() + .post(ep.url("v1/exec")) + .header(reqwest::header::AUTHORIZATION, ep.auth()) + .json(&body), + ) + .await?; + + let status = resp.status(); + if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE { + // Verbatim when it is the limit message (callers match on + // OUTPUT_LIMIT_MARKER); anything else the agent puts in a 413 is not + // trusted to speak for it. + return Err(error_text(resp) + .await + .filter(|e| e.starts_with(OUTPUT_LIMIT_MARKER)) + .unwrap_or_else(|| output_limit_error(limit))); + } + if !status.is_success() { + return Err(status_error(CONTEXT, resp).await); + } + + // The agent enforces `limit`, but this is container-controlled input + // being buffered into the host, so the client bounds it too: base64 is + // 4/3 of the payload and each chunk carries a few bytes of JSON, so a + // body past this is over the limit however it was chunked. + let cap = limit.saturating_mul(8).saturating_add(64 * 1024); + let raw = read_capped(resp, cap) + .await? + .ok_or_else(|| output_limit_error(limit))?; + let parsed: OneshotResponse = serde_json::from_slice(&raw) + .map_err(|e| format!("Exec output error: malformed agent response: {}", e))?; + + let mut out = OneshotOutput::new(); + let b64 = base64::engine::general_purpose::STANDARD; + for chunk in parsed.chunks { + let from_stdout = match chunk.s.as_str() { + "out" => true, + "err" => false, + other => { + return Err(format!( + "Exec output error: unknown stream {:?} in agent response", + other + )) + } + }; + let bytes = b64 + .decode(chunk.d.as_bytes()) + .map_err(|e| format!("Exec output error: bad base64 from agent: {}", e))?; + out.push(from_stdout, &bytes, limit)?; + } + out.exit_code = parsed.exit_code; + Ok(out) +} + +/// Combined output and exit code under `limit` — `exec_oneshot_limited` and, +/// with [`MAX_ONESHOT_OUTPUT`], every other combined-output variant. +pub async fn exec_oneshot_limited( + ep: &AgentEndpoint, + user: &str, + cmd: Vec, + env: Vec, + limit: usize, +) -> Result<(String, i64), String> { + let out = exec_oneshot_raw(ep, user, cmd, env, limit).await?; + Ok((out.text(), out.exit_code)) +} + +/// `exec_oneshot_as`: combined output under [`MAX_ONESHOT_OUTPUT`]. +pub async fn exec_oneshot_as( + ep: &AgentEndpoint, + user: &str, + cmd: Vec, + env: Vec, +) -> Result<(String, i64), String> { + exec_oneshot_limited(ep, user, cmd, env, MAX_ONESHOT_OUTPUT).await +} + +/// `exec_oneshot_streams_as`: `(stdout, stderr, exit_code)`. +pub async fn exec_oneshot_streams_as( + ep: &AgentEndpoint, + user: &str, + cmd: Vec, + env: Vec, +) -> Result<(String, String, i64), String> { + let out = exec_oneshot_raw(ep, user, cmd, env, MAX_ONESHOT_OUTPUT).await?; + Ok((out.stdout(), out.stderr(), out.exit_code)) +} + +// ── detached execs, status, resize ─────────────────────────────────────────── + +#[derive(Deserialize)] +struct ExecIdResponse { + exec_id: String, +} + +pub async fn exec_detached(ep: &AgentEndpoint, spec: &ExecSpec) -> Result { + const CONTEXT: &str = "Failed to start exec"; + let resp = send( + CONTEXT, + http() + .post(ep.url("v1/exec/detached")) + .timeout(SMALL_REQUEST_TIMEOUT) + .header(reqwest::header::AUTHORIZATION, ep.auth()) + .json(&spec_json(spec)), + ) + .await?; + if !resp.status().is_success() { + return Err(status_error(CONTEXT, resp).await); + } + small_json::(resp) + .await + .map(|r| r.exec_id) + .ok_or_else(|| format!("{}: malformed agent response", CONTEXT)) +} + +#[derive(Deserialize)] +struct ExecStatus { + running: bool, + exit_code: Option, +} + +/// Exit codes attach streams saw in their `exit` frame, so `wait_exec_exit` +/// can answer without a round trip. Bounded: a long-running app opens many +/// sessions, and the agent keeps the same answer for ten minutes anyway. +struct ExitBook { + codes: HashMap<(String, String), i64>, + order: VecDeque<(String, String)>, +} + +const EXIT_BOOK_CAP: usize = 256; + +fn exit_book() -> &'static Mutex { + static BOOK: OnceLock> = OnceLock::new(); + BOOK.get_or_init(|| { + Mutex::new(ExitBook { + codes: HashMap::new(), + order: VecDeque::new(), + }) + }) +} + +fn record_exit(base_url: &str, exec_id: &str, code: i64) { + let key = (base_url.to_string(), exec_id.to_string()); + let mut book = exit_book().lock().unwrap_or_else(|p| p.into_inner()); + if book.codes.insert(key.clone(), code).is_none() { + book.order.push_back(key); + while book.order.len() > EXIT_BOOK_CAP { + if let Some(old) = book.order.pop_front() { + book.codes.remove(&old); + } + } + } +} + +fn recorded_exit(base_url: &str, exec_id: &str) -> Option { + let book = exit_book().lock().unwrap_or_else(|p| p.into_inner()); + book.codes + .get(&(base_url.to_string(), exec_id.to_string())) + .copied() +} + +/// Poll `GET /v1/exec/{id}` with Docker's budget. `None` on any error, an +/// unknown id, a finished exec with no code, or the budget running out — +/// exactly where `docker::exec::wait_for_exec_exit` says `None`. +pub async fn wait_exec_exit(ep: &AgentEndpoint, exec_id: &str) -> Option { + // Docker's budget is a number of polls; bound the wall clock too, so a + // slow-answering agent cannot stretch it (each poll is also bounded). + let budget = EXEC_EXIT_POLL_INTERVAL * EXEC_EXIT_POLLS as u32 + SMALL_REQUEST_TIMEOUT; + tokio::time::timeout(budget, wait_exec_exit_polls(ep, exec_id)).await.ok()? +} + +async fn wait_exec_exit_polls(ep: &AgentEndpoint, exec_id: &str) -> Option { + if let Some(code) = recorded_exit(ep.base_url.as_str(), exec_id) { + return Some(code); + } + let url = ep.url(&format!("v1/exec/{}", urlencode_segment(exec_id))); + for _ in 0..EXEC_EXIT_POLLS { + if let Some(code) = recorded_exit(ep.base_url.as_str(), exec_id) { + return Some(code); + } + let resp = http() + .get(url.clone()) + .timeout(SMALL_REQUEST_TIMEOUT) + .header(reqwest::header::AUTHORIZATION, ep.auth()) + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + let status: ExecStatus = small_json(resp).await?; + if !status.running { + return status.exit_code; + } + tokio::time::sleep(EXEC_EXIT_POLL_INTERVAL).await; + } + None +} + +pub async fn resize_exec(ep: &AgentEndpoint, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> { + const CONTEXT: &str = "Failed to resize exec"; + let resp = send( + CONTEXT, + http() + .post(ep.url(&format!("v1/exec/{}/resize", urlencode_segment(exec_id)))) + .timeout(SMALL_REQUEST_TIMEOUT) + .header(reqwest::header::AUTHORIZATION, ep.auth()) + .json(&serde_json::json!({ "cols": cols, "rows": rows })), + ) + .await?; + if !resp.status().is_success() { + return Err(status_error(CONTEXT, resp).await); + } + Ok(()) +} + +/// Exec ids come from the agent; escape them anyway so one can never turn +/// into a different path. +fn urlencode_segment(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() +} + +// ── attach ─────────────────────────────────────────────────────────────────── + +type Ws = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, +>; + +#[derive(Deserialize)] +struct ControlFrame { + #[serde(rename = "type")] + kind: String, + exec_id: Option, + code: Option, + error: Option, +} + +/// What the stdin writer hands the socket task. +enum StdinCmd { + Data(Bytes), + Close, +} + +/// Open `GET /v1/attach` and authenticate. Shared by attach (and, later, +/// tunnels). +async fn connect_ws(ep: &AgentEndpoint, path: &str, context: &str) -> Result { + let mut req = ep + .ws_url(path) + .as_str() + .into_client_request() + .map_err(|e| format!("{}: {}", context, e))?; + let auth = ep.auth(); + req.headers_mut() + .insert(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, auth); + let config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(WS_MAX_MESSAGE)) + .max_frame_size(Some(WS_MAX_MESSAGE)); + match tokio::time::timeout( + CONNECT_TIMEOUT, + tokio_tungstenite::connect_async_with_config(req, Some(config), false), + ) + .await + { + Err(_) => Err(format!("{}: timed out connecting to the agent", context)), + Ok(Err(tokio_tungstenite::tungstenite::Error::Http(resp))) + if resp.status() == tokio_tungstenite::tungstenite::http::StatusCode::UNAUTHORIZED => + { + Err(format!("{}: the agent rejected the credentials (401)", context)) + } + Ok(Err(e)) => Err(format!("{}: {}", context, e)), + Ok(Ok((ws, _))) => Ok(ws), + } +} + +/// `GET /v1/attach`: the same [`AttachedExec`] Docker hands back. +/// +/// Output: channel 1 → [`ExecChunk::StdOut`], 2 → [`ExecChunk::StdErr`]; the +/// `exit` frame ends the stream and is remembered for [`wait_exec_exit`]. +/// Input: each write is one binary frame; `shutdown` sends `close_stdin`. +/// Dropping both halves closes the socket. +pub async fn attach(ep: &AgentEndpoint, spec: &ExecSpec) -> Result { + const CONTEXT: &str = "Failed to start exec"; + let mut ws = connect_ws(ep, "v1/attach", CONTEXT).await?; + + let mut hello = spec_json(spec); + hello.insert("attach_stdin".into(), serde_json::json!(spec.attach_stdin)); + let hello = serde_json::Value::Object(hello).to_string(); + ws.send(Message::text(hello)) + .await + .map_err(|e| format!("{}: {}", CONTEXT, e))?; + + let exec_id = match tokio::time::timeout(ATTACH_START_TIMEOUT, wait_started(&mut ws)).await { + Err(_) => return Err(format!("{}: the agent did not start the command", CONTEXT)), + Ok(r) => r.map_err(|e| format!("{}: {}", CONTEXT, e))?, + }; + + let (mut sink, stream) = ws.split(); + + // stdin: a bounded queue drained by one task that owns the socket's write + // half. The task ends — dropping the write half — when the writer is + // dropped or the socket fails. + let (tx, mut rx) = tokio::sync::mpsc::channel::(STDIN_QUEUE); + tokio::spawn(async move { + while let Some(cmd) = rx.recv().await { + let msg = match cmd { + StdinCmd::Data(b) => Message::binary(b), + StdinCmd::Close => Message::text(r#"{"type":"close_stdin"}"#), + }; + if sink.send(msg).await.is_err() { + break; + } + } + }); + + let base_url = ep.base_url.as_str().to_string(); + let id_for_stream = exec_id.clone(); + let output = futures_util::stream::unfold(Some(stream), move |state| { + let base_url = base_url.clone(); + let exec_id = id_for_stream.clone(); + async move { + let mut stream = state?; + loop { + match stream.next().await? { + Err(e) => return Some((Err(format!("Exec output error: {}", e)), None)), + Ok(Message::Binary(b)) => { + if b.is_empty() { + continue; + } + let payload = b.slice(1..); + let chunk = match b[0] { + 1 => ExecChunk::StdOut(payload), + 2 => ExecChunk::StdErr(payload), + _ => ExecChunk::Other(payload), + }; + return Some((Ok(chunk), Some(stream))); + } + Ok(Message::Text(t)) => { + let frame: ControlFrame = match serde_json::from_str(t.as_str()) { + Ok(f) => f, + Err(_) => continue, + }; + match frame.kind.as_str() { + "exit" => { + if let Some(code) = frame.code { + record_exit(&base_url, &exec_id, code); + } + return None; + } + "error" => { + let e = frame.error.unwrap_or_else(|| "agent error".into()); + return Some((Err(format!("Exec output error: {}", e)), None)); + } + _ => continue, + } + } + Ok(Message::Close(_)) => return None, + Ok(_) => continue, + } + } + } + }); + + Ok(AttachedExec { + exec_id, + output: Box::pin(output), + input: Box::pin(AttachInput { + tx: PollSender::new(tx), + close_sent: false, + }), + }) +} + +/// Read until the agent says `started` (its exec id) or `error`. +async fn wait_started(ws: &mut Ws) -> Result { + while let Some(msg) = ws.next().await { + match msg.map_err(|e| e.to_string())? { + Message::Text(t) => { + let frame: ControlFrame = serde_json::from_str(t.as_str()) + .map_err(|e| format!("malformed agent frame: {}", e))?; + match frame.kind.as_str() { + "started" => { + return frame + .exec_id + .ok_or_else(|| "agent sent no exec id".to_string()) + } + "error" => { + return Err(frame.error.unwrap_or_else(|| "agent error".into())) + } + _ => continue, + } + } + Message::Close(_) => break, + _ => continue, + } + } + Err("the agent closed the connection before starting the command".to_string()) +} + +/// An attach's stdin. +struct AttachInput { + tx: PollSender, + close_sent: bool, +} + +fn broken_pipe() -> io::Error { + io::Error::new(io::ErrorKind::BrokenPipe, "agent connection closed") +} + +impl AsyncWrite for AttachInput { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + if this.close_sent { + return Poll::Ready(Err(broken_pipe())); + } + match this.tx.poll_reserve(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(_)) => Poll::Ready(Err(broken_pipe())), + Poll::Ready(Ok(())) => { + // One frame per write, at most STDIN_FRAME_MAX; `write_all` + // loops for the rest. Keeps every frame under the agent's + // WebSocket message limit. + let n = buf.len().min(STDIN_FRAME_MAX); + this.tx + .send_item(StdinCmd::Data(Bytes::copy_from_slice(&buf[..n]))) + .map_err(|_| broken_pipe())?; + Poll::Ready(Ok(n)) + } + } + } + + /// Frames are flushed by the socket task as it sends them. + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + /// EOF on the child's stdin: `{"type":"close_stdin"}`, once. + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if this.close_sent { + return Poll::Ready(Ok(())); + } + match this.tx.poll_reserve(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(_)) => Poll::Ready(Err(broken_pipe())), + Poll::Ready(Ok(())) => { + this.tx.send_item(StdinCmd::Close).map_err(|_| broken_pipe())?; + this.close_sent = true; + Poll::Ready(Ok(())) + } + } + } +} + +// ── files ──────────────────────────────────────────────────────────────────── + +/// `(uid, gid)` of `claude`, falling back to `(1000, 1000)` — the same probe +/// and parse as Docker's `container_user_ids`. +pub async fn user_ids(ep: &AgentEndpoint) -> (u64, u64) { + let (out, _) = exec_oneshot_limited(ep, "claude", user_ids_cmd(), Vec::new(), USER_IDS_OUTPUT_LIMIT) + .await + .unwrap_or_default(); + parse_user_ids(&out) +} + +/// Split Docker's tar-entry `name` (which may carry a relative directory, as +/// the terminal drop's `triple-c-drops/` does) into the `dir` and +/// single-component `name` `PUT /v1/files` takes. Docker's extractor would +/// put the file at the same place. +fn split_upload_target(dest_dir: &str, file_name: &str) -> Result<(String, String), String> { + let parts: Vec<&str> = file_name.split('/').filter(|p| !p.is_empty()).collect(); + let Some((name, parents)) = parts.split_last() else { + return Err(format!("Failed to upload file to container: empty file name {:?}", file_name)); + }; + if parts.iter().any(|p| *p == "." || *p == "..") { + return Err(format!( + "Failed to upload file to container: {:?} is not a plain relative name", + file_name + )); + } + let mut dir = dest_dir.trim_end_matches('/').to_string(); + for p in parents { + dir.push('/'); + dir.push_str(p); + } + if dir.is_empty() { + dir.push('/'); + } + Ok((dir, name.to_string())) +} + +async fn put_file( + ep: &AgentEndpoint, + dest_dir: &str, + file_name: &str, + data: Vec, + mode: u32, + (uid, gid): (u64, u64), +) -> Result<(), String> { + const CONTEXT: &str = "Failed to upload file to container"; + let (dir, name) = split_upload_target(dest_dir, file_name)?; + let mut url = ep.url("v1/files"); + url.query_pairs_mut() + .append_pair("dir", &dir) + .append_pair("name", &name) + .append_pair("mode", &format!("{:o}", mode)) + .append_pair("uid", &uid.to_string()) + .append_pair("gid", &gid.to_string()); + // Generous, but finite: a minute plus 256 KiB/s for the payload. + let timeout = Duration::from_secs(60 + (data.len() as u64) / (256 * 1024)); + let resp = send( + CONTEXT, + http() + .put(url) + .timeout(timeout) + .header(reqwest::header::AUTHORIZATION, ep.auth()) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(data), + ) + .await?; + if !resp.status().is_success() { + return Err(status_error(CONTEXT, resp).await); + } + Ok(()) +} + +/// Docker's `upload_bytes_with_ids`: same reported path. +pub async fn upload_bytes_with_ids( + ep: &AgentEndpoint, + dest_dir: &str, + file_name: &str, + data: &[u8], + mode: u32, + ids: (u64, u64), +) -> Result { + put_file(ep, dest_dir, file_name, data.to_vec(), mode, ids).await?; + Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name)) +} + +/// Docker's `upload_host_file_with_ids`: the host file is read (and checked) +/// by the same function, mode 0644, same reported path. +pub async fn upload_host_file_with_ids( + ep: &AgentEndpoint, + host_path: &str, + dest_dir: &str, + dest_name: &str, + ids: (u64, u64), +) -> Result { + let path = host_path.to_string(); + let data = tokio::task::spawn_blocking(move || read_host_file_for_upload(&path)) + .await + .map_err(|e| format!("Upload task panicked: {}", e))??; + put_file(ep, dest_dir, dest_name, data, HOST_FILE_UPLOAD_MODE, ids).await?; + Ok(container_join(dest_dir, dest_name)) +} + +/// `GET /v1/archive`, streamed. Dropping the stream drops the response, +/// which closes the connection and cancels the read on the agent. +pub fn download_archive(ep: &AgentEndpoint, path: &str) -> ArchiveStream { + const CONTEXT: &str = "Failed to download archive"; + let mut url = ep.url("v1/archive"); + url.query_pairs_mut().append_pair("path", path); + let req = http() + .get(url) + .header(reqwest::header::AUTHORIZATION, ep.auth()); + + enum State { + Start(reqwest::RequestBuilder), + Body(reqwest::Response), + } + Box::pin(futures_util::stream::unfold( + Some(State::Start(req)), + |state| async move { + let mut resp = match state? { + State::Start(req) => match req.send().await { + Err(e) => return Some((Err(format!("{}: {}", CONTEXT, e)), None)), + Ok(r) if !r.status().is_success() => { + return Some((Err(status_error(CONTEXT, r).await), None)) + } + Ok(r) => r, + }, + State::Body(r) => r, + }; + loop { + match resp.chunk().await { + Err(e) => return Some((Err(format!("{}: {}", CONTEXT, e)), None)), + Ok(None) => return None, + Ok(Some(b)) if b.is_empty() => continue, + Ok(Some(b)) => return Some((Ok(b), Some(State::Body(resp)))), + } + } + }, + )) +} + +// ── state ──────────────────────────────────────────────────────────────────── + +/// `GET /v1/health` answers → `true`. Can't connect (or times out) → `false`, +/// the same "not running" an inspect failure gives on Docker. An agent that +/// answers with an error status is reachable but unusable, which is an +/// `Err` rather than a quiet `false`. +pub async fn is_running(ep: &AgentEndpoint) -> Result { + let resp = match http() + .get(ep.url("v1/health")) + .header(reqwest::header::AUTHORIZATION, ep.auth()) + .timeout(HEALTH_TIMEOUT) + .send() + .await + { + Ok(r) => r, + Err(_) => return Ok(false), + }; + if !resp.status().is_success() { + return Err(status_error("Agent health check failed", resp).await); + } + Ok(true) +} + +#[cfg(test)] +mod tests; +#[cfg(test)] +mod live_tests; diff --git a/app/src-tauri/src/host/agent/live_tests.rs b/app/src-tauri/src/host/agent/live_tests.rs new file mode 100644 index 0000000..7816cc1 --- /dev/null +++ b/app/src-tauri/src/host/agent/live_tests.rs @@ -0,0 +1,265 @@ +//! The real agent against `docker exec`, in one real container: every +//! operation goes through `ContainerTarget::Docker` and +//! `ContainerTarget::Agent` on the *same* container and must come back the +//! same. That is the agent protocol's whole requirement ("Docker parity is the +//! requirement"), checked end to end rather than against a fake. +//! +//! Needs a Docker daemon and an image whose entrypoint starts the agent: +//! `TRIPLEC_AGENT_E2E_IMAGE`, default `tc-agent-e2e:latest` (the sandbox image +//! plus this branch's `entrypoint.sh` and `triple-c-agent`). + +use super::*; +use crate::host::{ContainerTarget, ExecChunk, ExecSpec, OUTPUT_LIMIT_MARKER}; +use futures_util::StreamExt; +use std::process::Command; +use std::time::Duration; +use tokio::io::AsyncWriteExt; + +fn docker_cli(args: &[&str]) -> String { + let out = Command::new("docker").args(args).output().expect("docker cli"); + assert!(out.status.success(), "docker {:?}: {}", args, String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +fn sh(script: &str) -> Vec { + vec!["sh".into(), "-c".into(), script.into()] +} + +/// Drain an attached exec: (stdout, stderr). +async fn drain(exec: crate::host::AttachedExec) -> (String, String) { + let mut out = exec.output; + let (mut o, mut e) = (Vec::new(), Vec::new()); + let collect = async { + while let Some(chunk) = out.next().await { + match chunk { + Ok(ExecChunk::StdOut(b)) => o.extend_from_slice(&b), + Ok(ExecChunk::StdErr(b)) => e.extend_from_slice(&b), + Ok(ExecChunk::Other(_)) => {} + Err(_) => break, + } + } + }; + tokio::time::timeout(Duration::from_secs(20), collect).await.expect("attach output ended"); + (String::from_utf8_lossy(&o).into_owned(), String::from_utf8_lossy(&e).into_owned()) +} + +/// Fail with both answers side by side. +fn same(what: &str, docker: T, agent: T) -> Result<(), String> { + if docker == agent { + Ok(()) + } else { + Err(format!("{what} differs\n docker: {docker:?}\n agent: {agent:?}")) + } +} + +async fn parity(d: &ContainerTarget, a: &ContainerTarget) -> Result<(), String> { + // ── one-shot ───────────────────────────────────────────────────────────── + let script = sh("echo out1; echo err1 >&2; echo out2; exit 3"); + same( + "streams + exit code", + d.exec_oneshot_streams_as("claude", script.clone(), vec![]).await?, + a.exec_oneshot_streams_as("claude", script, vec![]).await?, + )?; + + for user in ["claude", "root"] { + let env = sh("env | sort"); + let strip = |s: String| -> String { + // `docker exec` inherits the container's configured env, which in + // this harness carries the agent's settings (the token included — + // the local-harness trade-off entrypoint.sh documents). The agent + // never passes its own settings on. + s.lines().filter(|l| !l.starts_with("TRIPLEC_AGENT_")).collect::>().join("\n") + }; + let (de, dc) = d.exec_oneshot_as(user, env.clone(), vec![]).await?; + let (ae, ac) = a.exec_oneshot_as(user, env, vec![]).await?; + same(&format!("environment as {user}"), (strip(de), dc), (strip(ae), ac))?; + } + + same("default working dir", d.exec_oneshot(vec!["pwd".into()]).await?, a.exec_oneshot(vec!["pwd".into()]).await?)?; + + let probe = sh("echo \"$FOO|$HOME|$USER|$(id -un)\""); + let env = vec!["FOO=bar".to_string(), "HOME=/tmp".to_string()]; + same( + "env overrides", + d.exec_oneshot_env_status(probe.clone(), env.clone()).await?, + a.exec_oneshot_env_status(probe, env).await?, + )?; + + same("user ids", d.user_ids().await, a.user_ids().await)?; + + // Signal state: an exec starts with nothing ignored or blocked, whatever + // the process that launched it had (the entrypoint's supervisor ignores + // SIGHUP; that must not turn every exec into `nohup`). + let sigs = sh("grep -E '^Sig(Ign|Blk):' /proc/self/status"); + same("signal dispositions/mask", d.exec_oneshot(sigs.clone()).await?, a.exec_oneshot(sigs).await?)?; + + // Stdio belongs to the exec user, as runc arranges: re-opening it by + // name works for a non-root user. + let reopen = sh("echo out > /dev/stdout; echo err > /dev/stderr"); + same( + "re-opening stdio by name as claude", + d.exec_oneshot_streams_as("claude", reopen.clone(), vec![]).await?, + a.exec_oneshot_streams_as("claude", reopen, vec![]).await?, + )?; + + let flood = sh("head -c 100000 /dev/zero"); + let de = d.exec_oneshot_limited("claude", flood.clone(), 1000).await.unwrap_err(); + let ae = a.exec_oneshot_limited("claude", flood, 1000).await.unwrap_err(); + if !de.starts_with(OUTPUT_LIMIT_MARKER) { + return Err(format!("docker limit error: {de}")); + } + same("output limit error", de, ae)?; + + let (_, dcode) = d.exec_oneshot_env_status(vec!["no-such-binary-tc".into()], vec![]).await?; + let (_, acode) = a.exec_oneshot_env_status(vec!["no-such-binary-tc".into()], vec![]).await?; + same("missing binary exit code", dcode, acode)?; + + // ── files ──────────────────────────────────────────────────────────────── + let ids = d.user_ids().await; + d.upload_bytes_with_ids("/tmp", "via-docker.txt", b"hello\n", 0o640, ids).await?; + a.upload_bytes_with_ids("/tmp", "via-agent.txt", b"hello\n", 0o640, ids).await?; + let stat = |n: &str| sh(&format!("stat -c '%a %u %g %s' /tmp/{n} && cat /tmp/{n}")); + same( + "uploaded file (mode, owner, size, content)", + d.exec_oneshot(stat("via-docker.txt")).await?, + a.exec_oneshot(stat("via-agent.txt")).await?, + )?; + + d.exec_oneshot(sh("rm -rf /tmp/arch && mkdir -p /tmp/arch/sub && echo x > /tmp/arch/x && echo y > /tmp/arch/sub/y")).await?; + let list = |t: &ContainerTarget| { + let mut s = t.download_archive("/tmp/arch"); + async move { + let mut bytes = Vec::new(); + while let Some(c) = s.next().await { + bytes.extend_from_slice(&c?); + } + let mut names: Vec<(String, Vec)> = tar::Archive::new(&bytes[..]) + .entries() + .map_err(|e| e.to_string())? + .map(|e| { + let mut e = e.unwrap(); + let name = e.path().unwrap().to_string_lossy().trim_end_matches('/').to_string(); + let mut data = Vec::new(); + std::io::Read::read_to_end(&mut e, &mut data).unwrap(); + (name, data) + }) + .collect(); + names.sort(); + Ok::<_, String>(names) + } + }; + same("archive members and contents", list(d).await?, list(a).await?)?; + + // ── attached ───────────────────────────────────────────────────────────── + for (label, t) in [("docker", d), ("agent", a)] { + let mut exec = t.attach_exec(vec!["head".into(), "-n1".into()], false).await?; + exec.input.write_all(b"ping\n").await.map_err(|e| e.to_string())?; + exec.input.flush().await.map_err(|e| e.to_string())?; + let id = exec.exec_id.clone(); + let (o, e) = drain(exec).await; + same(&format!("{label}: attach echo"), ("ping\n".to_string(), String::new()), (o, e))?; + same(&format!("{label}: attach exit"), Some(0), t.wait_exec_exit(&id).await)?; + } + + let mut sizes = Vec::new(); + for t in [d, a] { + let exec = t + .attach(ExecSpec::new(sh("sleep 1; stty size")).tty(true)) + .await?; + t.resize_exec(&exec.exec_id, 100, 40).await?; + let (o, _) = drain(exec).await; + sizes.push(o.trim().to_string()); + } + same("tty size after resize", sizes[0].clone(), sizes[1].clone())?; + same("tty size value", "40 100".to_string(), sizes[1].clone())?; + + // A tty session whose client goes away ends (SIGHUP), as a dropped + // `docker exec -t` does. Only checked through the agent: it is the agent's + // disconnect handling under test. Nothing else in this fresh container + // runs `sleep 600`, so counting exact matches is the probe. + let exec = a.attach(ExecSpec::new(vec!["sleep".into(), "600".into()]).tty(true)).await?; + tokio::time::sleep(Duration::from_millis(500)).await; + let count = sh("pgrep -xfc 'sleep 600' || true"); + let before = a.exec_oneshot(count.clone()).await?; + drop(exec); + let mut after = String::new(); + for _ in 0..40 { + tokio::time::sleep(Duration::from_millis(250)).await; + after = a.exec_oneshot(count.clone()).await?; + if after.trim() == "0" { + break; + } + } + same("tty session ends when its client goes away (sleep count before → after)", ("1".to_string(), "0".to_string()), (before.trim().to_string(), after.trim().to_string()))?; + + // ── detached ───────────────────────────────────────────────────────────── + let mut codes = Vec::new(); + for t in [d, a] { + let id = t.exec_detached(ExecSpec::new(sh("sleep 0.3; exit 5"))).await?; + codes.push(t.wait_exec_exit(&id).await); + } + same("detached exit code", codes[0], codes[1])?; + + same("is_running", d.is_running().await?, a.is_running().await?)?; + Ok(()) +} + +#[tokio::test] +#[ignore = "needs a Docker daemon and an image whose entrypoint starts triple-c-agent (TRIPLEC_AGENT_E2E_IMAGE)"] +async fn the_agent_answers_exactly_as_docker_exec_does() { + let image = std::env::var("TRIPLEC_AGENT_E2E_IMAGE").unwrap_or_else(|_| "tc-agent-e2e:latest".into()); + let token = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()); + let name = format!("tc-agent-e2e-{}", uuid::Uuid::new_v4().simple()); + docker_cli(&[ + "run", "-d", "--name", &name, "-w", "/workspace", + "-e", &format!("TRIPLEC_AGENT_TOKEN={token}"), + // Docker forwards a published port to the container's own address, + // not its loopback, so the harness binds the agent wide *inside* the + // container and publishes it to the host's loopback only. + "-e", "TRIPLEC_AGENT_ADDR=0.0.0.0:47900", + "-p", "127.0.0.1::47900", + &image, + ]); + let outcome = async { + let mapped = docker_cli(&["port", &name, "47900/tcp"]); + let port: u16 = mapped.lines().next().and_then(|l| l.rsplit(':').next()).and_then(|p| p.parse().ok()) + .ok_or_else(|| format!("no published port in {mapped:?}"))?; + // Two ways in, because where *this test* runs decides which one routes: + // on the host (where the app runs) the published loopback port; from + // inside another container on the same daemon, the published port is + // on the host's loopback, not ours, but the container's bridge address + // is reachable. + let ip = docker_cli(&["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", &name]); + let mut candidates = vec![AgentEndpoint::loopback(port, token.clone())]; + for addr in ip.split_whitespace() { + let url = url::Url::parse(&format!("http://{addr}:47900/")).map_err(|e| e.to_string())?; + candidates.push(AgentEndpoint::new(url, token.clone())?); + } + let docker = ContainerTarget::docker(name.clone()); + + // The entrypoint starts the agent last, after setup (including a + // bounded `claude update`), so health is the readiness signal. + let mut agent = None; + 'wait: for _ in 0..240 { + for ep in &candidates { + let t = ContainerTarget::Agent(ep.clone()); + if t.is_running().await.unwrap_or(false) { + agent = Some(t); + break 'wait; + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + let Some(agent) = agent else { + return Err(format!("agent never answered on {candidates:?}; logs:\n{}", docker_cli(&["logs", "--tail", "40", &name]))); + }; + eprintln!("agent reached at {}", agent.id()); + parity(&docker, &agent).await + } + .await; + let logs = docker_cli(&["logs", "--tail", "20", &name]); + let _ = Command::new("docker").args(["rm", "-f", &name]).output(); + if let Err(e) = outcome { + panic!("{e}\n--- container log tail ---\n{logs}"); + } +} diff --git a/app/src-tauri/src/host/agent/tests.rs b/app/src-tauri/src/host/agent/tests.rs new file mode 100644 index 0000000..067a52d --- /dev/null +++ b/app/src-tauri/src/host/agent/tests.rs @@ -0,0 +1,673 @@ +//! The agent client against a **fake** agent: a few axum handlers that speak +//! just enough of the v1 protocol to exercise each client path, and record +//! what they were sent. It is not the real server (that lives in `agent/`) +//! and does not try to behave like one beyond the wire shapes. + +use super::*; +use crate::host::{ContainerTarget, OUTPUT_LIMIT_MARKER}; +use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tokio::io::AsyncWriteExt; + +/// Obviously fake; long enough to be unmistakable in an error string. +const TOKEN: &str = "fake-agent-token-for-unit-tests-only-0000"; + +#[derive(Default)] +struct Seen { + auth: Vec, + exec_bodies: Vec, + detached_bodies: Vec, + status_polls: u32, + resizes: Vec<(String, Value)>, + uploads: Vec<(HashMap, Vec)>, + attach_hello: Option, + stdin: Vec, + close_stdin: bool, +} + +struct Fake { + seen: std::sync::Mutex, +} + +type St = State>; + +impl Fake { + fn authed(&self, h: &HeaderMap) -> bool { + let got = h + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let ok = got == format!("Bearer {}", TOKEN); + self.seen.lock().unwrap().auth.push(got); + ok + } +} + +fn unauthorized() -> Response { + StatusCode::UNAUTHORIZED.into_response() +} + +fn b64(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +async fn exec(State(f): St, h: HeaderMap, Json(body): Json) -> Response { + if !f.authed(&h) { + return unauthorized(); + } + f.seen.lock().unwrap().exec_bodies.push(body.clone()); + match body["cmd"][0].as_str().unwrap_or("") { + "sh" => Json(json!({"chunks": [{"s": "out", "d": b64(b"1000\n1001\n")}], "exit_code": 0})) + .into_response(), + // stdout and stderr interleaved; `ü` split across two stdout chunks; + // one byte that is not UTF-8 at all on stderr. + "mixed" => Json(json!({ + "chunks": [ + {"s": "out", "d": b64(b"first ")}, + {"s": "err", "d": b64(b"find: /x: denied\n")}, + {"s": "out", "d": b64(&[0xc3])}, + {"s": "out", "d": b64(&[0xbc, b'!'])}, + {"s": "err", "d": b64(&[0xff])}, + ], + "exit_code": 3 + })) + .into_response(), + "big" => ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({"error": "OUTPUT_LIMIT: Command output exceeded 5 bytes and was abandoned"})), + ) + .into_response(), + _ => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "boom"}))).into_response(), + } +} + +async fn detached(State(f): St, h: HeaderMap, Json(body): Json) -> Response { + if !f.authed(&h) { + return unauthorized(); + } + f.seen.lock().unwrap().detached_bodies.push(body); + Json(json!({"exec_id": "d1"})).into_response() +} + +/// `d1` reports running for two polls, then exit 7. Everything else is 404 — +/// including the attach's `a1`, so an attach's exit can only have come from +/// the client's own record of the `exit` frame. +async fn status(State(f): St, h: HeaderMap, Path(id): Path) -> Response { + if !f.authed(&h) { + return unauthorized(); + } + if id != "d1" { + return (StatusCode::NOT_FOUND, Json(json!({"error": "no such exec"}))).into_response(); + } + let mut s = f.seen.lock().unwrap(); + s.status_polls += 1; + if s.status_polls < 3 { + Json(json!({"running": true, "exit_code": null})).into_response() + } else { + Json(json!({"running": false, "exit_code": 7})).into_response() + } +} + +async fn resize(State(f): St, h: HeaderMap, Path(id): Path, Json(body): Json) -> Response { + if !f.authed(&h) { + return unauthorized(); + } + f.seen.lock().unwrap().resizes.push((id, body)); + StatusCode::NO_CONTENT.into_response() +} + +async fn files( + State(f): St, + h: HeaderMap, + Query(q): Query>, + body: Bytes, +) -> Response { + if !f.authed(&h) { + return unauthorized(); + } + let path = format!("{}/{}", q["dir"], q["name"]); + f.seen.lock().unwrap().uploads.push((q, body.to_vec())); + Json(json!({"path": path})).into_response() +} + +async fn archive(State(f): St, h: HeaderMap, Query(q): Query>) -> Response { + if !f.authed(&h) { + return unauthorized(); + } + if q["path"] == "/missing" { + return (StatusCode::NOT_FOUND, Json(json!({"error": "no such path"}))).into_response(); + } + let parts: Vec> = vec![ + Ok(Bytes::from_static(b"tar-part-1;")), + Ok(Bytes::from_static(b"tar-part-2;")), + Ok(Bytes::from(q["path"].clone().into_bytes())), + ]; + axum::body::Body::from_stream(futures_util::stream::iter(parts)).into_response() +} + +async fn health(State(f): St, h: HeaderMap) -> Response { + if !f.authed(&h) { + return unauthorized(); + } + Json(json!({"agent": "triple-c-agent", "version": "0.0.0-fake", "protocol": 1})).into_response() +} + +async fn attach_ws(State(f): St, h: HeaderMap, ws: WebSocketUpgrade) -> Response { + if !f.authed(&h) { + return unauthorized(); + } + ws.on_upgrade(move |socket| fake_attach(f, socket)) +} + +/// started → stdout, stderr, an unknown channel → collect stdin until +/// `close_stdin` → echo it on stdout → exit 5 → close. +async fn fake_attach(f: Arc, mut ws: WebSocket) { + let Some(Ok(WsMessage::Text(hello))) = ws.recv().await else { return }; + let hello: Value = serde_json::from_str(hello.as_str()).unwrap(); + let refuse = hello["cmd"][0] == "nope"; + f.seen.lock().unwrap().attach_hello = Some(hello); + if refuse { + let _ = ws + .send(WsMessage::Text(r#"{"type":"error","error":"no such user"}"#.into())) + .await; + return; + } + let _ = ws + .send(WsMessage::Text(r#"{"type":"started","exec_id":"a1"}"#.into())) + .await; + for frame in [&b"\x01out"[..], b"\x02err", b"\x09odd"] { + let _ = ws.send(WsMessage::Binary(Bytes::copy_from_slice(frame))).await; + } + while let Some(Ok(msg)) = ws.recv().await { + match msg { + WsMessage::Binary(b) => f.seen.lock().unwrap().stdin.extend_from_slice(&b), + WsMessage::Text(t) if t.as_str().contains("close_stdin") => { + f.seen.lock().unwrap().close_stdin = true; + break; + } + _ => {} + } + } + let mut echo = vec![1u8]; + echo.extend_from_slice(&f.seen.lock().unwrap().stdin); + let _ = ws.send(WsMessage::Binary(echo.into())).await; + let _ = ws.send(WsMessage::Text(r#"{"type":"exit","code":5}"#.into())).await; + let _ = ws.close().await; +} + +async fn fake_agent() -> (AgentEndpoint, Arc) { + let fake = Arc::new(Fake { + seen: std::sync::Mutex::new(Seen::default()), + }); + let app = Router::new() + .route("/v1/health", get(health)) + .route("/v1/exec", post(exec)) + .route("/v1/exec/detached", post(detached)) + .route("/v1/exec/{id}", get(status)) + .route("/v1/exec/{id}/resize", post(resize)) + .route("/v1/files", put(files)) + .route("/v1/archive", get(archive)) + .route("/v1/attach", get(attach_ws)) + .with_state(fake.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (AgentEndpoint::loopback(port, TOKEN), fake) +} + +fn target(ep: &AgentEndpoint) -> ContainerTarget { + ContainerTarget::Agent(ep.clone()) +} + +fn cmd(c: &str) -> Vec { + vec![c.to_string()] +} + +// ── endpoint ───────────────────────────────────────────────────────────────── + +#[test] +fn the_token_never_appears_in_debug_output() { + let ep = AgentEndpoint::loopback(47900, TOKEN); + let dbg = format!("{:?} {:?}", ep, ContainerTarget::Agent(ep.clone())); + assert!(!dbg.contains(TOKEN), "{}", dbg); + assert!(dbg.contains("127.0.0.1:47900")); + assert!(dbg.contains("redacted")); +} + +#[test] +fn an_agent_target_is_identified_by_its_base_url() { + let a = ContainerTarget::Agent(AgentEndpoint::loopback(47900, TOKEN)); + assert_eq!(a.id(), "http://127.0.0.1:47900/"); + assert_eq!(a, ContainerTarget::Agent(AgentEndpoint::loopback(47900, TOKEN))); + assert_ne!(a, ContainerTarget::Agent(AgentEndpoint::loopback(47901, TOKEN))); + let mut set = std::collections::HashSet::new(); + set.insert(a.clone()); + assert!(set.contains(&a)); +} + +#[test] +fn only_plain_http_endpoints_are_accepted() { + let ok = AgentEndpoint::new(url::Url::parse("http://127.0.0.1:1/x").unwrap(), TOKEN).unwrap(); + assert_eq!(ok.base_url.as_str(), "http://127.0.0.1:1/x/"); + assert_eq!(ok.url("v1/health").as_str(), "http://127.0.0.1:1/x/v1/health"); + assert_eq!(ok.ws_url("v1/attach").as_str(), "ws://127.0.0.1:1/x/v1/attach"); + assert!(AgentEndpoint::new(url::Url::parse("https://h/").unwrap(), TOKEN).is_err()); + assert!(AgentEndpoint::new(url::Url::parse("file:///tmp").unwrap(), TOKEN).is_err()); +} + +// ── auth ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn every_request_carries_the_bearer_token() { + let (ep, fake) = fake_agent().await; + let t = target(&ep); + assert!(t.is_running().await.unwrap()); + t.exec_oneshot(cmd("sh")).await.unwrap(); + let auth = fake.seen.lock().unwrap().auth.clone(); + assert_eq!(auth.len(), 2); + assert!(auth.iter().all(|a| a == &format!("Bearer {}", TOKEN))); +} + +#[tokio::test] +async fn a_wrong_token_is_an_error_that_does_not_carry_any_token() { + let (ep, _) = fake_agent().await; + let wrong_token = "wrong-fake-token-for-unit-tests-only-1111"; + let bad = ContainerTarget::Agent(AgentEndpoint::new(ep.base_url.clone(), wrong_token).unwrap()); + + let e = bad.exec_oneshot(cmd("sh")).await.unwrap_err(); + assert!(e.contains("401"), "{}", e); + assert!(e.starts_with("Failed to start exec"), "{}", e); + let Err(attach_err) = bad.attach(ExecSpec::new(cmd("sh"))).await else { + panic!("attach with a wrong token succeeded") + }; + assert!(attach_err.contains("401"), "{}", attach_err); + let health_err = bad.is_running().await.unwrap_err(); + let up_err = bad.upload_bytes("/tmp", "x", b"x", 0o644).await.unwrap_err(); + for e in [&e, &attach_err, &health_err, &up_err] { + assert!(!e.contains(wrong_token) && !e.contains(TOKEN), "{}", e); + } +} + +#[tokio::test] +async fn other_errors_carry_the_agents_message_and_no_token() { + let (ep, _) = fake_agent().await; + let e = target(&ep).exec_oneshot(cmd("fail")).await.unwrap_err(); + assert_eq!(e, "Failed to start exec: boom"); +} + +// ── one-shot execs ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn every_oneshot_variant_reassembles_what_docker_would_return() { + let (ep, fake) = fake_agent().await; + let t = target(&ep); + // Arrival order, `ü` rejoined across chunks, the stray 0xff lossy. + let combined = "first find: /x: denied\nü!\u{fffd}"; + let env = vec!["K=V".to_string()]; + + assert_eq!(t.exec_oneshot(cmd("mixed")).await.unwrap(), combined); + assert_eq!(t.exec_oneshot_env(cmd("mixed"), env.clone()).await.unwrap(), combined); + assert_eq!( + t.exec_oneshot_env_status(cmd("mixed"), env.clone()).await.unwrap(), + (combined.to_string(), 3) + ); + assert_eq!( + t.exec_oneshot_as("root", cmd("mixed"), env.clone()).await.unwrap(), + (combined.to_string(), 3) + ); + assert_eq!( + t.exec_oneshot_limited("root", cmd("mixed"), 4096).await.unwrap(), + (combined.to_string(), 3) + ); + assert_eq!( + t.exec_oneshot_streams_as("root", cmd("mixed"), env.clone()).await.unwrap(), + ("first ü!".to_string(), "find: /x: denied\n\u{fffd}".to_string(), 3) + ); + + let bodies = fake.seen.lock().unwrap().exec_bodies.clone(); + let max = MAX_ONESHOT_OUTPUT as u64; + let expect = [ + ("claude", json!([]), max), + ("claude", json!(["K=V"]), max), + ("claude", json!(["K=V"]), max), + ("root", json!(["K=V"]), max), + ("root", json!([]), 4096), + ("root", json!(["K=V"]), max), + ]; + assert_eq!(bodies.len(), expect.len()); + for (b, (user, env, limit)) in bodies.iter().zip(expect) { + assert_eq!(b["user"], user); + assert_eq!(b["env"], env); + assert_eq!(b["limit"].as_u64(), Some(limit)); + assert_eq!(b["cmd"], json!(["mixed"])); + // Docker's one-shot names no working dir and no tty. + assert_eq!(b["working_dir"], Value::Null); + assert_eq!(b["tty"], false); + } +} + +#[tokio::test] +async fn an_output_limit_refusal_passes_through_verbatim() { + let (ep, _) = fake_agent().await; + let e = target(&ep).exec_oneshot_limited("claude", cmd("big"), 5).await.unwrap_err(); + assert_eq!(e, "OUTPUT_LIMIT: Command output exceeded 5 bytes and was abandoned"); + assert!(e.starts_with(OUTPUT_LIMIT_MARKER)); +} + +#[tokio::test] +async fn the_client_also_enforces_the_limit_on_what_it_buffers() { + // An agent that ignored `limit` still cannot make the client buffer past it. + let (ep, _) = fake_agent().await; + let e = target(&ep).exec_oneshot_limited("claude", cmd("mixed"), 8).await.unwrap_err(); + assert!(e.starts_with(OUTPUT_LIMIT_MARKER), "{}", e); +} + +#[tokio::test] +async fn user_ids_come_from_the_same_probe_as_docker() { + let (ep, fake) = fake_agent().await; + assert_eq!(target(&ep).user_ids().await, (1000, 1001)); + let b = fake.seen.lock().unwrap().exec_bodies[0].clone(); + assert_eq!(b["cmd"], json!(["sh", "-c", "id -u; id -g"])); + assert_eq!(b["user"], "claude"); + assert_eq!(b["limit"], 256); +} + +// ── detached, status, resize ───────────────────────────────────────────────── + +#[tokio::test] +async fn a_detached_exec_is_polled_until_it_reports_its_exit() { + let (ep, fake) = fake_agent().await; + let t = target(&ep); + let spec = ExecSpec::new(cmd("sleep")).user("root").working_dir(None).no_stdin(); + let id = t.exec_detached(spec).await.unwrap(); + assert_eq!(id, "d1"); + assert_eq!(t.wait_exec_exit(&id).await, Some(7)); + let s = fake.seen.lock().unwrap(); + assert_eq!(s.status_polls, 3); + assert_eq!(s.detached_bodies[0]["user"], "root"); + assert_eq!(s.detached_bodies[0]["working_dir"], Value::Null); + drop(s); + assert_eq!(t.wait_exec_exit("unknown").await, None); +} + +#[tokio::test] +async fn resize_posts_cols_and_rows() { + let (ep, fake) = fake_agent().await; + target(&ep).resize_exec("a1", 120, 40).await.unwrap(); + let r = fake.seen.lock().unwrap().resizes.clone(); + assert_eq!(r, vec![("a1".to_string(), json!({"cols": 120, "rows": 40}))]); +} + +// ── attach ─────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn attach_maps_channels_forwards_stdin_and_ends_on_exit() { + let (ep, fake) = fake_agent().await; + let t = target(&ep); + let spec = ExecSpec::new(cmd("cat")).env(vec!["A=1".into()]).tty(true); + let AttachedExec { + exec_id, + mut output, + mut input, + } = t.attach(spec).await.unwrap(); + assert_eq!(exec_id, "a1"); + + assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::StdOut(Bytes::from_static(b"out"))); + assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::StdErr(Bytes::from_static(b"err"))); + assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::Other(Bytes::from_static(b"odd"))); + + input.write_all(b"hello ").await.unwrap(); + input.write_all(b"agent").await.unwrap(); + input.shutdown().await.unwrap(); + + assert_eq!( + output.next().await.unwrap().unwrap(), + ExecChunk::StdOut(Bytes::from_static(b"hello agent")) + ); + // The exit frame ends the stream… + assert!(output.next().await.is_none()); + // …and is what answers wait_exec_exit (the fake 404s `a1`). + assert_eq!(t.wait_exec_exit(&exec_id).await, Some(5)); + + let s = fake.seen.lock().unwrap(); + assert!(s.close_stdin); + assert_eq!(s.stdin, b"hello agent"); + let hello = s.attach_hello.clone().unwrap(); + assert_eq!(hello["cmd"], json!(["cat"])); + assert_eq!(hello["user"], "claude"); + assert_eq!(hello["working_dir"], "/workspace"); + assert_eq!(hello["env"], json!(["A=1"])); + assert_eq!(hello["tty"], true); + assert_eq!(hello["attach_stdin"], true); +} + +#[tokio::test] +async fn an_attach_the_agent_refuses_is_an_error() { + let (ep, _) = fake_agent().await; + let Err(e) = target(&ep).attach(ExecSpec::new(cmd("nope"))).await else { + panic!("a refused attach succeeded") + }; + assert_eq!(e, "Failed to start exec: no such user"); +} + +// ── files ──────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn uploads_put_the_bytes_with_dir_name_mode_and_owner() { + let (ep, fake) = fake_agent().await; + let t = target(&ep); + assert_eq!( + t.upload_bytes_with_ids("/tmp/", "x.txt", b"hi", 0o755, (1000, 1001)) + .await + .unwrap(), + "/tmp/x.txt" + ); + assert_eq!( + t.upload_bytes("/home/claude/in", "p.tar", b"tar", 0o644).await.unwrap(), + "/home/claude/in/p.tar" + ); + // The terminal drop's nested name: Docker's tar entry would land it in the + // subdirectory, so the agent is asked for that directory. + assert_eq!( + t.upload_bytes_with_ids("/tmp", "triple-c-drops/a.txt", b"a", 0o644, (1, 2)) + .await + .unwrap(), + "/tmp/triple-c-drops/a.txt" + ); + assert!(t.upload_bytes("/tmp", "../etc/x", b"", 0o644).await.is_err()); + + let up = fake.seen.lock().unwrap().uploads.clone(); + let q = |i: usize, k: &str| up[i].0[k].clone(); + assert_eq!((q(0, "dir"), q(0, "name"), q(0, "mode")), ("/tmp".into(), "x.txt".into(), "755".into())); + assert_eq!((q(0, "uid"), q(0, "gid")), ("1000".into(), "1001".into())); + assert_eq!(up[0].1, b"hi"); + assert_eq!((q(1, "uid"), q(1, "gid"), q(1, "mode")), ("0".into(), "0".into(), "644".into())); + assert_eq!((q(2, "dir"), q(2, "name")), ("/tmp/triple-c-drops".into(), "a.txt".into())); + assert_eq!(up.len(), 3); +} + +#[tokio::test] +async fn a_host_file_upload_reads_through_the_shared_checked_reader() { + let (ep, fake) = fake_agent().await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("notes.txt"); + std::fs::write(&path, b"from the host").unwrap(); + let path = path.canonicalize().unwrap(); + let t = target(&ep); + let got = t + .upload_host_file_with_ids(path.to_str().unwrap(), "/workspace/", "notes.txt", (7, 8)) + .await + .unwrap(); + assert_eq!(got, "/workspace/notes.txt"); + // Without ids: resolved through the uid probe first. + t.upload_host_file(path.to_str().unwrap(), "/tmp", "n2.txt").await.unwrap(); + + let up = fake.seen.lock().unwrap().uploads.clone(); + assert_eq!(up[0].1, b"from the host"); + assert_eq!(up[0].0["mode"], "644"); + assert_eq!((up[0].0["uid"].as_str(), up[0].0["gid"].as_str()), ("7", "8")); + assert_eq!((up[1].0["uid"].as_str(), up[1].0["gid"].as_str()), ("1000", "1001")); + + let missing = dir.path().join("gone.txt"); + let e = t + .upload_host_file_with_ids(missing.to_str().unwrap(), "/tmp", "g", (1, 1)) + .await + .unwrap_err(); + assert!(e.starts_with("Failed to read"), "{}", e); +} + +#[tokio::test] +async fn an_archive_is_streamed_and_a_missing_path_is_an_error() { + let (ep, _) = fake_agent().await; + let t = target(&ep); + let mut s = t.download_archive("/workspace/a dir"); + let mut got = Vec::new(); + while let Some(chunk) = s.next().await { + got.extend_from_slice(&chunk.unwrap()); + } + assert_eq!(got, b"tar-part-1;tar-part-2;/workspace/a dir"); + + let mut s = t.download_archive("/missing"); + let e = s.next().await.unwrap().unwrap_err(); + assert_eq!(e, "Failed to download archive: no such path"); + assert!(s.next().await.is_none()); +} + +// ── state ──────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn is_running_follows_whether_the_agent_answers() { + let (ep, _) = fake_agent().await; + assert!(target(&ep).is_running().await.unwrap()); + assert!(!target(&ep).has_exited().await); + + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + drop(l); + let gone = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN)); + assert!(!gone.is_running().await.unwrap()); + // Silence is not a positive exit. + assert!(!gone.has_exited().await); +} + +// ── a hostile agent ────────────────────────────────────────────────────────── +// The agent runs inside the container, which is not trusted with the host. + +/// Serve `app` on an ephemeral loopback port; return the port. +async fn serve(app: Router) -> u16 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + port +} + +#[tokio::test] +async fn a_redirect_from_the_agent_is_not_followed() { + let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let elsewhere = { + let hits = hits.clone(); + serve(Router::new().fallback(move || { + let hits = hits.clone(); + async move { + hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + "followed" + } + })) + .await + }; + let to = format!("http://127.0.0.1:{}/anything", elsewhere); + let agent = serve(Router::new().fallback(move || { + let to = to.clone(); + async move { (StatusCode::TEMPORARY_REDIRECT, [(axum::http::header::LOCATION, to)]) } + })) + .await; + let t = ContainerTarget::Agent(AgentEndpoint::loopback(agent, TOKEN)); + + assert!(t.exec_detached(ExecSpec::new(cmd("true"))).await.is_err()); + assert!(t.exec_oneshot(cmd("true")).await.is_err()); + assert!(t.is_running().await.is_err(), "a 3xx health answer is not 'running'"); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 0, "the client followed a redirect"); +} + +#[tokio::test] +async fn an_oversized_error_body_is_neither_buffered_nor_passed_through_whole() { + // 8 MiB of error text: past the small-body cap, so it is not even parsed. + let huge = serve(Router::new().fallback(|| async { + let body = json!({ "error": "x".repeat(8 * 1024 * 1024) }); + (StatusCode::INTERNAL_SERVER_ERROR, Json(body)) + })) + .await; + let e = ContainerTarget::Agent(AgentEndpoint::loopback(huge, TOKEN)) + .exec_detached(ExecSpec::new(cmd("true"))) + .await + .unwrap_err(); + assert!(e.len() < 1024, "error was {} bytes", e.len()); + assert!(e.contains("500"), "{e}"); + + // Under the body cap but long: parsed, then cut to the display cap. + let long = serve(Router::new().fallback(|| async { + let body = json!({ "error": "é".repeat(20 * 1024) }); + (StatusCode::INTERNAL_SERVER_ERROR, Json(body)) + })) + .await; + let e = ContainerTarget::Agent(AgentEndpoint::loopback(long, TOKEN)) + .exec_detached(ExecSpec::new(cmd("true"))) + .await + .unwrap_err(); + assert!(e.len() <= 4 * 1024 + 64, "error was {} bytes", e.len()); + assert!(e.ends_with('…'), "{e}"); +} + +#[tokio::test] +async fn an_agent_that_accepts_and_then_says_nothing_cannot_hang_the_small_calls() { + // Accepts every connection, reads nothing, answers nothing, holds it open. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((sock, _)) = listener.accept().await { + held.push(sock); + } + }); + let t = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN)); + let started = std::time::Instant::now(); + assert!(t.exec_detached(ExecSpec::new(cmd("true"))).await.is_err()); + assert!(t.resize_exec("abc", 80, 24).await.is_err()); + assert_eq!(t.wait_exec_exit("abc").await, None); + // Three calls, each bounded by SMALL_REQUEST_TIMEOUT (1 s under test). + assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed()); +} + +#[test] +fn a_token_that_cannot_be_a_header_value_is_refused_up_front() { + let url = url::Url::parse("http://127.0.0.1:1/").unwrap(); + for bad in ["", "has space", "new\nline", "tab\there", "ünïcode"] { + assert!(AgentEndpoint::new(url.clone(), bad).is_err(), "{bad:?} accepted"); + } + assert!(AgentEndpoint::new(url, TOKEN).is_ok()); +} + +#[tokio::test] +async fn a_413_that_is_not_the_limit_message_is_not_passed_through() { + let port = serve(Router::new().fallback(|| async { + (StatusCode::PAYLOAD_TOO_LARGE, Json(json!({ "error": "click here: http://evil.example" }))) + })) + .await; + let e = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN)) + .exec_oneshot_limited("claude", cmd("true"), 1000) + .await + .unwrap_err(); + assert!(e.starts_with(OUTPUT_LIMIT_MARKER), "{e}"); + assert!(!e.contains("evil"), "{e}"); +} diff --git a/app/src-tauri/src/host/lifecycle.rs b/app/src-tauri/src/host/lifecycle.rs index 1196d57..0e63b0a 100644 --- a/app/src-tauri/src/host/lifecycle.rs +++ b/app/src-tauri/src/host/lifecycle.rs @@ -25,6 +25,13 @@ pub enum ProjectHost { Docker, } +/// An agent target is a way *into* a container, not a handle on its +/// lifecycle: whatever runs the container (ECS, the local harness) stops and +/// removes it. Reaching here means a caller paired the Docker host with an +/// agent target, which is a bug to surface, not a panic. +const AGENT_LIFECYCLE: &str = + "{op} a container is not supported for an agent target; the host that runs it owns its lifecycle"; + /// What a host could not clean up when asked to discard a project's state. /// Docker: the snapshot image and the named volumes. #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -71,6 +78,7 @@ impl ProjectHost { pub async fn stop(&self, target: &ContainerTarget) -> Result<(), String> { match target { ContainerTarget::Docker { container_id } => docker::stop_container(container_id).await, + ContainerTarget::Agent(_) => Err(AGENT_LIFECYCLE.replace("{op}", "Stopping")), } } @@ -81,6 +89,7 @@ impl ProjectHost { let _ = docker::stop_container(container_id).await; docker::remove_container(container_id).await } + ContainerTarget::Agent(_) => Err(AGENT_LIFECYCLE.replace("{op}", "Removing")), } } diff --git a/app/src-tauri/src/host/mod.rs b/app/src-tauri/src/host/mod.rs index 86359d4..b41ba19 100644 --- a/app/src-tauri/src/host/mod.rs +++ b/app/src-tauri/src/host/mod.rs @@ -7,8 +7,9 @@ //! "is it running" — goes through [`ContainerTarget`] instead of naming //! `docker::exec` or bollard directly. Today the only variant is //! [`ContainerTarget::Docker`], which delegates to the existing code in -//! `docker/exec.rs` and `docker/container.rs` with no change in behaviour. An -//! AWS variant (reached through the in-container agent) slots in beside it. +//! `docker/exec.rs` and `docker/container.rs` with no change in behaviour. +//! [`ContainerTarget::Agent`] reaches a container through the in-container +//! `triple-c-agent` instead (`agent.rs`), which is how the AWS host will get in. //! //! ## Why an enum and not a trait object //! @@ -26,6 +27,7 @@ //! `no_container_interior_bollard_outside_the_host_layer` test pins which files //! may still do so. +pub mod agent; pub mod lifecycle; pub mod sessions; pub mod watch; @@ -33,6 +35,7 @@ pub mod watch; pub use crate::docker::exec::{ MAX_DROP_BYTES, OUTPUT_LIMIT_MARKER, PROC_NET_OUTPUT_LIMIT, }; +pub use agent::AgentEndpoint; pub use sessions::ExecSessionManager; use bytes::Bytes; @@ -132,6 +135,12 @@ pub type ArchiveStream = Pin> + Send pub enum ContainerTarget { /// A container on the local Docker daemon, by id or name. Docker { container_id: String }, + /// A container reached through the in-container `triple-c-agent` + /// (`host/agent.rs`; protocol in + /// `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`). Used + /// by the AWS host (later) and the local agent harness. The host that + /// runs the container, not this target, owns its lifecycle. + Agent(AgentEndpoint), } impl ContainerTarget { @@ -151,6 +160,7 @@ impl ContainerTarget { pub fn id(&self) -> &str { match self { ContainerTarget::Docker { container_id } => container_id, + ContainerTarget::Agent(ep) => ep.base_url.as_str(), } } @@ -163,6 +173,7 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::container::is_container_running(container_id).await } + ContainerTarget::Agent(ep) => agent::is_running(ep).await, } } @@ -175,6 +186,9 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::exec_oneshot(container_id, cmd).await } + ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, Vec::new()) + .await + .map(|(out, _)| out), } } @@ -187,6 +201,9 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::exec_oneshot_env(container_id, cmd, env).await } + ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, env) + .await + .map(|(out, _)| out), } } @@ -199,6 +216,7 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::exec_oneshot_env_status(container_id, cmd, env).await } + ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, env).await, } } @@ -212,6 +230,7 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::exec_oneshot_as(container_id, user, cmd, env).await } + ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, user, cmd, env).await, } } @@ -225,6 +244,9 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::exec_oneshot_limited(container_id, user, cmd, limit).await } + ContainerTarget::Agent(ep) => { + agent::exec_oneshot_limited(ep, user, cmd, Vec::new(), limit).await + } } } @@ -239,6 +261,7 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::exec_oneshot_streams_as(container_id, user, cmd, env).await } + ContainerTarget::Agent(ep) => agent::exec_oneshot_streams_as(ep, user, cmd, env).await, } } @@ -249,6 +272,7 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::exec_detached(container_id, &spec).await } + ContainerTarget::Agent(ep) => agent::exec_detached(ep, &spec).await, } } @@ -264,6 +288,7 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::create_attached_exec_spec(container_id, &spec).await } + ContainerTarget::Agent(ep) => agent::attach(ep, &spec).await, } } @@ -272,12 +297,14 @@ impl ContainerTarget { pub async fn wait_exec_exit(&self, exec_id: &str) -> Option { match self { ContainerTarget::Docker { .. } => docker::exec::wait_for_exec_exit(exec_id).await, + ContainerTarget::Agent(ep) => agent::wait_exec_exit(ep, exec_id).await, } } pub async fn resize_exec(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> { match self { ContainerTarget::Docker { .. } => docker::exec::resize_exec(exec_id, cols, rows).await, + ContainerTarget::Agent(ep) => agent::resize_exec(ep, exec_id, cols, rows).await, } } @@ -289,6 +316,7 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::container_user_ids(container_id).await } + ContainerTarget::Agent(ep) => agent::user_ids(ep).await, } } @@ -311,6 +339,9 @@ impl ContainerTarget { ) .await } + ContainerTarget::Agent(ep) => { + agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, (0, 0)).await + } } } @@ -335,6 +366,9 @@ impl ContainerTarget { ) .await } + ContainerTarget::Agent(ep) => { + agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, ids).await + } } } @@ -355,6 +389,10 @@ impl ContainerTarget { ) .await } + ContainerTarget::Agent(ep) => { + let ids = agent::user_ids(ep).await; + agent::upload_host_file_with_ids(ep, host_path, dest_dir, dest_name, ids).await + } } } @@ -377,6 +415,9 @@ impl ContainerTarget { ) .await } + ContainerTarget::Agent(ep) => { + agent::upload_host_file_with_ids(ep, host_path, dest_dir, dest_name, ids).await + } } } @@ -387,6 +428,7 @@ impl ContainerTarget { ContainerTarget::Docker { container_id } => { docker::exec::download_archive(container_id, path) } + ContainerTarget::Agent(ep) => agent::download_archive(ep, path), } } } diff --git a/app/src-tauri/src/host/watch.rs b/app/src-tauri/src/host/watch.rs index 7ba6ea5..2c60254 100644 --- a/app/src-tauri/src/host/watch.rs +++ b/app/src-tauri/src/host/watch.rs @@ -50,6 +50,11 @@ impl ContainerTarget { pub async fn has_exited(&self) -> bool { match self { ContainerTarget::Docker { container_id } => docker::events::has_exited(container_id).await, + // The agent can only say "I answer" or "I don't"; silence is not a + // positive exit (an SSM blip looks the same). Only the host that + // runs the container can confirm a death — an AWS host answers + // from ECS — so through the agent alone this never confirms one. + ContainerTarget::Agent(_) => false, } } } diff --git a/container/.dockerignore b/container/.dockerignore new file mode 100644 index 0000000..a9ea654 --- /dev/null +++ b/container/.dockerignore @@ -0,0 +1,2 @@ +# Build output of the agent crate must never enter the image build context. +triple-c-agent/target diff --git a/container/Dockerfile b/container/Dockerfile index 0a16ad5..84af9c7 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,3 +1,31 @@ +# ── triple-c-agent ───────────────────────────────────────────────────────── +# The in-container agent the AWS host reaches the container through +# (docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md). A static +# musl binary, so it needs nothing from the image below. +# +# The stage runs on the *build* machine's platform and cross-compiles for the +# target: CI builds amd64 and arm64 together, and compiling Rust for arm64 +# under QEMU is many times slower. BuildKit predefines BUILDPLATFORM and +# TARGETARCH; the app's local build (the classic builder) passes them itself +# (docker/image.rs, `local_build_args`), because there an empty `--platform=` +# fails the build. Pure-Rust dependencies only, so rust-lld links aarch64 +# with no C cross toolchain (see container/triple-c-agent/README.md). +ARG BUILDPLATFORM +FROM --platform=$BUILDPLATFORM rust:1.98-slim-bookworm AS agent-build +ARG TARGETARCH +RUN rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl +WORKDIR /src +COPY triple-c-agent/Cargo.toml triple-c-agent/Cargo.lock ./ +COPY triple-c-agent/src ./src +RUN case "$TARGETARCH" in \ + amd64) target=x86_64-unknown-linux-musl ;; \ + arm64) target=aarch64-unknown-linux-musl ;; \ + *) echo "triple-c-agent: unsupported TARGETARCH '$TARGETARCH'" >&2; exit 1 ;; \ + esac \ + && CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld \ + cargo build --release --locked --target "$target" \ + && install -D -m 0755 "target/$target/release/triple-c-agent" /out/triple-c-agent + FROM ubuntu:24.04 # ── Provenance labels ──────────────────────────────────────────────────────── @@ -460,5 +488,9 @@ COPY triple-c-playwright-heal /usr/local/bin/triple-c-playwright-heal RUN chmod +x /usr/local/bin/triple-c-playwright-heal COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner RUN chmod +x /usr/local/bin/triple-c-task-runner +# Root-owned in /usr/local/bin: entrypoint.sh starts it as root (only when a +# token is configured), and it rides the snapshot and migration like the other +# shims here. +COPY --from=agent-build /out/triple-c-agent /usr/local/bin/triple-c-agent ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 1da6d4a..7cbe10c 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -13,6 +13,50 @@ # upgradable content in /usr/local/bin or /opt, or seed it from here. # See "Container Lifecycle" in the repo's CLAUDE.md. +# ── triple-c-agent token: off the environment before anything else runs ─── +# The agent (started at the very end, see start_agent) authenticates every +# request with this token, and it runs as root, so the token is effectively +# root in this container. Given as an env var it would be inherited by every +# process this script starts (cron, the scheduler, Claude sessions spawned by +# the agent). So it moves to a root-only file *first* and leaves the +# environment; the agent reads the file. A `docker exec` session still sees +# the container's configured env — the local test harness passes it that way +# and accepts that; on AWS nothing is started by `docker exec`. +# +# The agent also takes its exec environment and default working directory +# from how *it* was started, and execs must see what `docker exec` would: the +# container's configured env and WorkingDir, not whatever this script has +# exported or cd'd into by the time it gets to start_agent. So both are +# snapshotted here, before anything else runs, and the agent is started from +# the snapshot (`env -i`). The agent drops its own TRIPLEC_AGENT_* settings. +# >>> agent_token +agent_cwd=$PWD +# The token goes on a tmpfs, never the container's writable layer: a file +# there would persist after stop and ride `docker commit` into the project's +# snapshot image (the commit's SECRET_ENV_KEYS scrub covers env, not files). +# /dev/shm is a tmpfs in Docker and on Fargate; anything else is refused. +if [ -n "${TRIPLEC_AGENT_TOKEN:-}" ]; then + if [ "$(stat -f -c %T /dev/shm 2>/dev/null)" = tmpfs ]; then + mkdir -p /dev/shm/triple-c + chown root:root /dev/shm/triple-c + chmod 700 /dev/shm/triple-c + ( umask 077; printf '%s' "$TRIPLEC_AGENT_TOKEN" > /dev/shm/triple-c/agent-token ) + export TRIPLEC_AGENT_TOKEN_FILE=/dev/shm/triple-c/agent-token + else + echo "triple-c-agent: /dev/shm is not a tmpfs; not writing the token to disk — agent disabled" >&2 + fi + unset TRIPLEC_AGENT_TOKEN +fi +# The exec environment snapshot stays in this script's memory — it holds the +# container's whole configured env, API keys included, so it must not touch +# disk either. Minus what bash itself exports (PWD, OLDPWD, SHLVL, `_`): +# those are this script's, not the container's, and `docker exec` has none. +agent_env=() +if [ -n "${TRIPLEC_AGENT_TOKEN_FILE:-}" ]; then + mapfile -d '' agent_env < <(env -u PWD -u OLDPWD -u SHLVL -u _ -0) +fi +# <<< agent_token + # ── UID/GID remapping ────────────────────────────────────────────────────── # Match the container's claude user to the host user's UID/GID so that # bind-mounted files (project dir, docker socket) have correct ownership. @@ -766,6 +810,41 @@ timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/ho # already in trouble. The pause is `read -t` on a FIFO nobody writes — # a builtin, so it still works when fork does not — with `sleep 1` as the # fallback should the FIFO be unavailable. +# ── triple-c-agent ────────────────────────────────────────────────────────── +# The app's way into the container on hosts without a Docker API (AWS, over +# an SSM port-forward). Started only when a token was provided, and only now, +# after every setup step above, so "the agent answers /v1/health" also means +# "the container is set up" — the AWS host's readiness signal. +# +# Same survival rules as the keepalive below, for the same reason: it runs as +# root under a root supervisor loop, so a claude-user `pkill`/`kill -9 -1` +# gets EPERM, and a crash is restarted (throttled: a binary that exits at +# once must not spin a core). +# >>> start_agent +start_agent() { + [ -n "${TRIPLEC_AGENT_TOKEN_FILE:-}" ] || return 0 + if [ ! -x /usr/local/bin/triple-c-agent ]; then + echo "triple-c-agent: token given but no agent binary in this image — not starting it" >&2 + return 0 + fi + ( + # Keeps the supervisor alive through a stray HUP. The agent resets + # every signal disposition and the mask in each exec it spawns, so this + # does not leak into commands (an ignored SIGHUP otherwise survives + # exec and turns every exec into `nohup`). + trap '' HUP + cd "$agent_cwd" || cd / + while :; do + agent_started=$SECONDS + env -i "${agent_env[@]}" /usr/local/bin/triple-c-agent + echo "triple-c-agent exited ($?) — restarting" >&2 + [ $((SECONDS - agent_started)) -ge 5 ] || sleep 5 + done + ) & +} +# <<< start_agent +start_agent + echo "Triple-C container ready." keepalive_pid="" stop_keepalive() { diff --git a/container/triple-c-agent/Cargo.lock b/container/triple-c-agent/Cargo.lock new file mode 100644 index 0000000..5c15bcd --- /dev/null +++ b/container/triple-c-agent/Cargo.lock @@ -0,0 +1,1301 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a0cf66fd1eb2fb7b822cb78370d82303750809bc6ba855f3f58e517d3238db" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "errno" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1" + +[[package]] +name = "mio" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20521a903f02b0a573cd6840e9d0a5a0cc043d361fe924092cd70f8556cc9c4a" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "triple-c-agent" +version = "0.1.0" +dependencies = [ + "axum", + "base64 0.22.1", + "bytes", + "futures-util", + "hyper", + "hyper-util", + "libc", + "reqwest", + "serde", + "serde_json", + "tar", + "tempfile", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/container/triple-c-agent/Cargo.toml b/container/triple-c-agent/Cargo.toml new file mode 100644 index 0000000..73a9ac7 --- /dev/null +++ b/container/triple-c-agent/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "triple-c-agent" +version = "0.1.0" +edition = "2021" +description = "In-container agent: docker-exec parity over authenticated HTTP/WebSocket" +publish = false + +# Deliberately not part of any workspace: this binary is built for +# *-unknown-linux-musl and baked into the container image, on its own cadence. +# The empty table stops cargo from walking up and adopting it into one. +[workspace] + +[lib] +name = "triple_c_agent" +path = "src/lib.rs" + +[[bin]] +name = "triple-c-agent" +path = "src/main.rs" + +# Pure-Rust only — no TLS, no openssl, nothing with a C build step — so the +# binary links statically for both musl targets without a C cross toolchain. +[dependencies] +axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query", "ws"] } +base64 = "0.22" +bytes = "1" +futures-util = { version = "0.3", default-features = false, features = ["std", "sink"] } +# Already in the tree through axum, with these features; named directly for +# the accept loop in `lib.rs::serve` (header-read timeout needs a hyper timer). +hyper = { version = "1", default-features = false, features = ["http1", "server"] } +hyper-util = { version = "0.1", default-features = false, features = ["tokio", "server", "service"] } +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tar = { version = "0.4", default-features = false } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "process", "io-util", "sync", "time", "fs"] } + +[dev-dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json"] } +tempfile = "3" +tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect"] } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/container/triple-c-agent/README.md b/container/triple-c-agent/README.md new file mode 100644 index 0000000..2c58351 --- /dev/null +++ b/container/triple-c-agent/README.md @@ -0,0 +1,112 @@ +# triple-c-agent + +The in-container agent: a small static binary that does over authenticated HTTP/WebSocket what +`host::ContainerTarget` does through the Docker API. It provides one-shot, attached and detached +execs, resize, exit status, file upload, archive download and TCP tunnels. The wire protocol is +`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`, and that spec is the contract. +This crate implements it and does not extend it. + +It is a standalone crate: not part of any workspace, with its own `Cargo.lock`. + +## Running + +| Variable | Meaning | +|---|---| +| `TRIPLEC_AGENT_TOKEN` | Bearer token, at least 32 bytes. Removed from the agent's env before anything is spawned, so no exec inherits it — but see below. | +| `TRIPLEC_AGENT_TOKEN_FILE` | Read when the env var is unset or empty. Must be owned by the agent's uid and not readable by others. Surrounding whitespace is trimmed. **Preferred.** | +| `TRIPLEC_AGENT_ADDR` | Listen address. Default `127.0.0.1:47900`. | + +Removing `TRIPLEC_AGENT_TOKEN` only edits the process's libc environment. The kernel keeps the +environment the agent was started with, so `/proc//environ` still shows the token to root +and to any process running as the agent's uid. Use `TRIPLEC_AGENT_TOKEN_FILE`; the entrypoint does. + +Connections are HTTP/1.1. A client gets 10 seconds to send a complete request head, and at most 256 +connections are served at once; past that, new connections wait in the listen backlog for a slot. +WebSocket sessions (attach, tunnel) stop counting once upgraded, and accept messages of at most +1 MiB. + +Execs start with every signal at its default disposition and an empty signal mask, whatever the +agent itself inherited (the entrypoint's supervisor ignores `SIGHUP`), and their stdin/stdout/stderr +are owned by the exec's user unless they are `/dev/null`, as runc arranges for `docker exec`. + +Without a token of at least 32 bytes the agent exits with status 2. There is no unauthenticated mode. On +startup it logs a single line, `triple-c-agent listening on `, to stderr. The token +is never logged. + +It must run **as root** (see CLAUDE.md, "Never hand the container's lifetime to a process the +`claude` user owns"). Execs then switch to the requested user. A non-root agent can only run execs +as itself and answers `403` for any other user. The tests rely on that. + +## Tests + +```bash +cd container/triple-c-agent +cargo test # everything, as the current user +cargo clippy --all-targets +# The root-only tests (user switch, upload chown, stdio ownership), as root: +sudo -E env "PATH=$PATH" CARGO_HOME="$HOME/.cargo" RUSTUP_HOME="$HOME/.rustup" \ + cargo test -- --include-ignored +sudo chown -R "$(id -u):$(id -g)" target # sudo leaves root-owned build files behind +``` + +The integration tests (`tests/protocol.rs`) start the real server in-process on `127.0.0.1:0` and +drive every endpoint over real HTTP and WebSocket. + +## Static release builds + +Both targets build from an x86_64 Linux host with **no C cross toolchain**: every dependency is +pure Rust, and Rust's musl targets ship a self-contained CRT. aarch64 links with the `rust-lld` +that comes with the toolchain. Verified with rustc 1.98.0: + +```bash +rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl + +cd container/triple-c-agent +cargo build --release --target x86_64-unknown-linux-musl +CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld \ + cargo build --release --target aarch64-unknown-linux-musl +``` + +Output: + +- `target/x86_64-unknown-linux-musl/release/triple-c-agent`: `static-pie linked`, about 1.3 MB. +- `target/aarch64-unknown-linux-musl/release/triple-c-agent`: `statically linked`, about 1.2 MB. + +Check a build with `file `. It must say `statically linked` or `static-pie linked`, and never +`dynamically linked` or `interpreter`. + +Do not add a dependency that needs a C compiler (`ring`, `openssl-sys`, anything using `cc`). The +aarch64 build would then need a cross C toolchain or `cargo-zigbuild`. TLS is out of scope for v1 +anyway, because the transport is the SSM channel or loopback. + +In the image, `container/Dockerfile`'s `agent-build` stage does this on the build platform for +`$TARGETARCH`; the app's local build passes those build args itself (`docker/image.rs`). + +## Docker parity test + +The requirement is that every operation through the agent comes back exactly as it does through +`docker exec`. `app/src-tauri/src/host/agent/live_tests.rs` checks that in one real container, +running each operation through both `ContainerTarget::Docker` and `ContainerTarget::Agent` and +comparing. It needs an image whose entrypoint starts the agent. To build one from the published +sandbox image plus this branch's agent and entrypoint: + +```bash +cargo build --release --target x86_64-unknown-linux-musl # or aarch64, on an arm64 host +ctx=$(mktemp -d) +cp target/x86_64-unknown-linux-musl/release/triple-c-agent ../entrypoint.sh "$ctx"/ +cat > "$ctx/Dockerfile" <<'DOCKERFILE' +FROM ghcr.io/shadowdao/triple-c-sandbox:latest +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +COPY triple-c-agent /usr/local/bin/triple-c-agent +RUN chmod 0755 /usr/local/bin/entrypoint.sh /usr/local/bin/triple-c-agent +DOCKERFILE +docker build -t tc-agent-e2e:latest "$ctx" + +cd ../../app/src-tauri +cargo test --lib the_agent_answers_exactly_as_docker_exec_does -- --ignored --nocapture +``` + +The test starts and removes its own container. It reaches the agent on the published loopback port +when run on the host, or on the container's bridge address when run from inside another container +on the same daemon. It has already caught one real divergence: `docker exec` sets only `HOME` for +the exec's user, never `USER`/`LOGNAME`. diff --git a/container/triple-c-agent/src/attach.rs b/container/triple-c-agent/src/attach.rs new file mode 100644 index 0000000..ffc2597 --- /dev/null +++ b/container/triple-c-agent/src/attach.rs @@ -0,0 +1,340 @@ +//! `GET /v1/attach`: an exec with its stdio carried over a WebSocket. + +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::State; +use axum::response::Response; +use bytes::Bytes; +use serde::Deserialize; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{mpsc, oneshot}; + +use crate::pty; +use crate::spawn::{self, ExecSpec, Io, PrepareError}; +use crate::{AppState, CountGuard, MAX_WS_MESSAGE}; + +/// How long the client has to send its spec after the upgrade. +const SPEC_TIMEOUT: Duration = Duration::from_secs(30); + +/// After a tty exec's own process exits, how long to keep draining the pty +/// for output still in flight. Waiting for EOF alone is not enough: a +/// background job that kept the terminal open would hold the attach (and its +/// exit frame) forever, while Docker ends the stream when the exec exits. +const TTY_DRAIN_IDLE: Duration = Duration::from_millis(100); + +#[derive(Deserialize)] +struct AttachRequest { + #[serde(flatten)] + spec: ExecSpec, + #[serde(default)] + attach_stdin: bool, + #[serde(default)] + cols: Option, + #[serde(default)] + rows: Option, +} + +/// What the pump sends to the socket. +enum Event { + Out(u8, Bytes), + Exit(i32), +} + +/// What the socket sends to the process. +enum Input { + Data(Bytes), +} + +pub async fn upgrade(State(st): State>, ws: WebSocketUpgrade) -> Response { + ws.max_message_size(MAX_WS_MESSAGE) + .max_frame_size(MAX_WS_MESSAGE) + .on_upgrade(move |socket| session(socket, st)) +} + +fn text(v: serde_json::Value) -> Message { + Message::Text(v.to_string().into()) +} + +async fn fail(mut socket: WebSocket, msg: String) { + let _ = socket.send(text(serde_json::json!({ "type": "error", "error": msg }))).await; + let _ = socket.send(Message::Close(None)).await; +} + +fn frame(channel: u8, data: &[u8]) -> Message { + let mut v = Vec::with_capacity(data.len() + 1); + v.push(channel); + v.extend_from_slice(data); + Message::Binary(v.into()) +} + +async fn session(mut socket: WebSocket, st: Arc) { + // 1. The spec, as one text frame. + let first = match tokio::time::timeout(SPEC_TIMEOUT, socket.recv()).await { + Ok(Some(Ok(Message::Text(t)))) => t, + Ok(Some(Ok(_))) => return fail(socket, "the first frame must be a text ExecSpec".into()).await, + _ => return, + }; + let req: AttachRequest = match serde_json::from_str(first.as_str()) { + Ok(r) => r, + Err(e) => return fail(socket, format!("invalid attach request: {e}")).await, + }; + let tty = req.spec.tty; + + let prepared = match spawn::prepare(&st.config, &req.spec, tty) { + Ok(p) => p, + Err(PrepareError::Refused(_, m)) => return fail(socket, m).await, + Err(PrepareError::CannotStart { code, message }) => { + return not_started(socket, &st, code, message).await + } + }; + + // 2. Start it. A tty exec gets the pty slave on all three fds; stdin + // stays the pty even with `attach_stdin: false` (the client's bytes are + // just ignored), because `stty`, `read -s` and friends need a terminal on + // fd 0 — the same as a Docker tty exec without `-i`. + let (child, master) = if tty { + let (master, slave) = match pty::open(req.cols.unwrap_or(80), req.rows.unwrap_or(24)) { + Ok(p) => p, + Err(e) => return fail(socket, format!("cannot open a pty: {e}")).await, + }; + let fds = (|| Ok::<_, std::io::Error>((slave.try_clone()?, slave.try_clone()?, slave)))(); + let (a, b, c) = match fds { + Ok(f) => f, + Err(e) => return fail(socket, format!("cannot open a pty: {e}")).await, + }; + // The slave copies are moved into the Command and closed when this + // returns, so the master sees EOF once the *child's* copies close. + match prepared.spawn(Io::Fd(a), Io::Fd(b), Io::Fd(c), true) { + Ok(child) => (child, Some(Arc::new(master))), + Err(PrepareError::CannotStart { code, message }) => { + return not_started(socket, &st, code, message).await + } + Err(PrepareError::Refused(_, m)) => return fail(socket, m).await, + } + } else { + let stdin = if req.attach_stdin { Io::Piped } else { Io::Null }; + match prepared.spawn(stdin, Io::Piped, Io::Piped, false) { + Ok(child) => (child, None), + Err(PrepareError::CannotStart { code, message }) => { + return not_started(socket, &st, code, message).await + } + Err(PrepareError::Refused(_, m)) => return fail(socket, m).await, + } + }; + let exec_id = st.execs.insert_running(master.clone()); + if socket + .send(text(serde_json::json!({ "type": "started", "exec_id": exec_id }))) + .await + .is_err() + { + // Gone before it began; still let the process run to an exit code. + } + + // 3. The pump owns the child and its output for the process's whole life, + // independent of the socket: if the client leaves, it keeps draining (and + // discarding) output so the process is never wedged on a full pipe, and + // it records the exit code either way. + let (ev_tx, mut ev_rx) = mpsc::channel::(64); + let (in_tx, in_rx) = mpsc::unbounded_channel::(); + let (hangup_tx, hangup_rx) = oneshot::channel::<()>(); + let guard = CountGuard::execs(&st); + { + let st = st.clone(); + let exec_id = exec_id.clone(); + let master = master.clone(); + tokio::spawn(async move { + let _active = guard; + let code = match master { + Some(m) => pump_tty(child, m, in_rx, hangup_rx, req.attach_stdin, &ev_tx).await, + None => pump_pipes(child, in_rx, &ev_tx).await, + }; + st.execs.finish(&exec_id, code); + let _ = ev_tx.send(Event::Exit(code)).await; + }); + } + + // 4. Shuttle between socket and pump. Unbounded stdin on purpose: a child + // that is not reading stdin must not stop us forwarding its output, or a + // heavy two-way stream deadlocks. The client is authenticated; how much it + // queues is its own business, as with Docker's hijacked connection. + // The loop only `break`s when the client has gone; the normal end (exit + // frame sent) `return`s from inside it. + let mut in_tx = Some(in_tx); + loop { + tokio::select! { + ev = ev_rx.recv() => match ev { + Some(Event::Out(ch, data)) => { + st.activity.touch(); + if socket.send(frame(ch, &data)).await.is_err() { + break; + } + } + Some(Event::Exit(code)) => { + let _ = socket.send(text(serde_json::json!({ "type": "exit", "code": code }))).await; + let _ = socket.send(Message::Close(None)).await; + return; + } + None => return, + }, + msg = socket.recv() => match msg { + Some(Ok(Message::Binary(data))) => { + st.activity.touch(); + if req.attach_stdin { + if let Some(tx) = &in_tx { + let _ = tx.send(Input::Data(data)); + } + } + } + Some(Ok(Message::Text(t))) => { + let is_close = serde_json::from_str::(t.as_str()) + .ok() + .is_some_and(|v| v["type"] == "close_stdin"); + if is_close { + // Dropping the sender ends the writer, which drops the + // child's stdin: EOF, like Docker's CloseWrite. + in_tx = None; + } + } + Some(Ok(Message::Ping(_) | Message::Pong(_))) => {} + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + }, + } + } + + // 5. The client went away, as a dropped `docker exec` does. A tty exec + // gets SIGHUP on its process group — what a terminal hangup delivers. A + // non-tty exec only loses stdin and is NOT killed: Docker does not kill + // it either, and `gh_login`/`setup-token` clean up after themselves with + // their own `pkill`. The pump keeps running and records the exit code. + // + // The pump sends the SIGHUP, not this task: only the pump knows whether it + // has already reaped the process, and once it has, the pid — which is the + // process-group id — may belong to someone else. If the pump is already + // gone the send just fails. + drop(in_tx); + if tty { + let _ = hangup_tx.send(()); + } +} + +/// The command could not start: register it as finished, and tell the client +/// the way a Docker attach would show it — started, the reason on stderr, +/// then the exit code. +async fn not_started(mut socket: WebSocket, st: &Arc, code: i32, message: String) { + let id = st.execs.insert_finished(code); + let _ = socket.send(text(serde_json::json!({ "type": "started", "exec_id": id }))).await; + let _ = socket.send(frame(2, format!("{message}\n").as_bytes())).await; + let _ = socket.send(text(serde_json::json!({ "type": "exit", "code": code }))).await; + let _ = socket.send(Message::Close(None)).await; +} + +/// Send output unless the socket side has gone; output after that is drained +/// and discarded so the process never blocks on a full pipe. +async fn emit(tx: &mpsc::Sender, ch: u8, data: &[u8]) { + let _ = tx.send(Event::Out(ch, Bytes::copy_from_slice(data))).await; +} + +async fn pump_pipes( + mut child: tokio::process::Child, + mut input: mpsc::UnboundedReceiver, + tx: &mpsc::Sender, +) -> i32 { + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut stderr = child.stderr.take().expect("piped stderr"); + if let Some(mut stdin) = child.stdin.take() { + tokio::spawn(async move { + while let Some(Input::Data(d)) = input.recv().await { + if stdin.write_all(&d).await.is_err() { + break; + } + let _ = stdin.flush().await; + } + // `stdin` drops here: EOF for the child. + }); + } + let (mut ob, mut eb) = (vec![0u8; 16 * 1024], vec![0u8; 16 * 1024]); + let (mut out_open, mut err_open) = (true, true); + let mut code: Option = None; + // The exit code is taken as soon as the process exits (so `GET + // /v1/exec/{id}` is not held up by a grandchild still holding a pipe), but + // the exit *frame* waits for both streams to end: "all output has been + // sent" first, as the spec requires. + while out_open || err_open || code.is_none() { + tokio::select! { + r = stdout.read(&mut ob), if out_open => match r { + Ok(n) if n > 0 => emit(tx, 1, &ob[..n]).await, + _ => out_open = false, + }, + r = stderr.read(&mut eb), if err_open => match r { + Ok(n) if n > 0 => emit(tx, 2, &eb[..n]).await, + _ => err_open = false, + }, + s = child.wait(), if code.is_none() => { + code = Some(s.map(spawn::exit_code).unwrap_or(255)); + } + } + } + code.unwrap_or(255) +} + +async fn pump_tty( + mut child: tokio::process::Child, + master: Arc, + mut input: mpsc::UnboundedReceiver, + hangup: oneshot::Receiver<()>, + attach_stdin: bool, + tx: &mpsc::Sender, +) -> i32 { + // `Some` until the client has gone (or the session ended without saying + // so), so a completed receiver is never polled again. + let mut hangup = Some(hangup); + if attach_stdin { + let master = master.clone(); + tokio::spawn(async move { + while let Some(Input::Data(d)) = input.recv().await { + if master.write_all(&d).await.is_err() { + break; + } + } + // A tty has no EOF to give: closing stdin on a terminal is not + // a thing, and the pty must stay open for output. Docker's + // CloseWrite on a tty exec is likewise not an EOF to the process. + }); + } + let mut buf = vec![0u8; 16 * 1024]; + let mut code: Option = None; + loop { + if let Some(c) = code { + match tokio::time::timeout(TTY_DRAIN_IDLE, master.read(&mut buf)).await { + Ok(Ok(n)) if n > 0 => emit(tx, 1, &buf[..n]).await, + _ => return c, + } + continue; + } + tokio::select! { + r = master.read(&mut buf) => match r { + // Everything is channel 1 on a tty: Docker's `Console` stream + // has no stderr, and the app maps it to stdout. + Ok(n) if n > 0 => emit(tx, 1, &buf[..n]).await, + _ => { + let s = child.wait().await; + return s.map(spawn::exit_code).unwrap_or(255); + } + }, + s = child.wait() => { + code = Some(s.map(spawn::exit_code).unwrap_or(255)); + } + // Only reachable while `code` is `None`: `child.wait()` has not + // returned, so the process is unreaped (alive or a zombie) and its + // pid cannot have been reused for another process group. + r = async { hangup.as_mut().expect("guarded by is_some").await }, if hangup.is_some() => { + hangup = None; + if r.is_ok() { + spawn::signal_group(child.id(), libc::SIGHUP); + } + } + } + } +} diff --git a/container/triple-c-agent/src/exec.rs b/container/triple-c-agent/src/exec.rs new file mode 100644 index 0000000..1bb4bc2 --- /dev/null +++ b/container/triple-c-agent/src/exec.rs @@ -0,0 +1,280 @@ +//! One-shot and detached execs, exec status, and resize. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use bytes::Bytes; +use base64::Engine; +use serde::Deserialize; +use tokio::io::AsyncReadExt; + +use crate::pty::PtyMaster; +use crate::spawn::{self, ExecSpec, Io, PrepareError}; +use crate::{error, AppState, CountGuard, MAX_ONESHOT_OUTPUT}; + +/// How long a finished exec stays answerable. The spec promises "at least 10 +/// minutes": the app polls `GET /v1/exec/{id}` after an attach ends, and a +/// slow poll (a laptop waking from sleep) must not turn a real exit code into +/// an unknown id. +const KEEP_FINISHED: Duration = Duration::from_secs(10 * 60); + +pub struct ExecEntry { + pub running: bool, + pub exit_code: Option, + finished_at: Option, + /// Present only for tty execs that are still running — what `resize` + /// needs. Dropped on exit so the terminal is released. + pub pty: Option>, +} + +/// Exec ids → status, for `detached` and `attach` execs. +#[derive(Default)] +pub struct Registry { + map: Mutex>, +} + +impl Registry { + /// Register a running exec and return its id. Expired entries are pruned + /// on the way in, which bounds the map by the insert rate × 10 minutes. + pub fn insert_running(&self, pty: Option>) -> String { + self.insert(ExecEntry { + running: true, + exit_code: None, + finished_at: None, + pty, + }) + } + + /// Register an exec that never started (126/127), so its id answers like + /// any finished exec — which is how Docker presents it. + pub fn insert_finished(&self, code: i32) -> String { + self.insert(ExecEntry { + running: false, + exit_code: Some(code), + finished_at: Some(Instant::now()), + pty: None, + }) + } + + fn insert(&self, entry: ExecEntry) -> String { + let id = new_id(); + let mut map = self.map.lock().unwrap(); + map.retain(|_, e| e.finished_at.is_none_or(|t| t.elapsed() < KEEP_FINISHED)); + map.insert(id.clone(), entry); + id + } + + pub fn finish(&self, id: &str, code: i32) { + if let Some(e) = self.map.lock().unwrap().get_mut(id) { + e.running = false; + e.exit_code = Some(code); + e.finished_at = Some(Instant::now()); + e.pty = None; + } + } + + fn with(&self, id: &str, f: impl FnOnce(&ExecEntry) -> R) -> Option { + self.map.lock().unwrap().get(id).map(f) + } +} + +/// 64 hex characters from the kernel's CSPRNG — Docker's exec id shape. Ids +/// are not secrets (the token is the boundary), but guessable ones would let +/// one client poke at another's execs for no reason. +fn new_id() -> String { + let mut bytes = [0u8; 32]; + let mut filled = 0; + while filled < bytes.len() { + let n = unsafe { + libc::getrandom(bytes[filled..].as_mut_ptr().cast(), bytes.len() - filled, 0) + }; + if n > 0 { + filled += n as usize; + } else if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted { + // getrandom(2) cannot fail on a supported kernel once the pool is + // initialised; panicking beats handing out predictable ids. + panic!("getrandom failed: {}", std::io::Error::last_os_error()); + } + } + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Map a spec refusal onto an HTTP error response. +pub fn refusal(status: StatusCode, msg: String) -> Response { + error(status, msg) +} + +/// Parse a JSON body without insisting on `Content-Type: application/json`. +/// The spec says bodies are JSON and says nothing about the header, so a +/// client that omits it (curl `-d`, a hand-rolled hyper request) must not get +/// a 415 for a body that is perfectly good JSON. +fn parse_json(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|e| format!("invalid request body: {e}")) +} + +#[derive(Deserialize)] +pub struct OneshotRequest { + #[serde(flatten)] + spec: ExecSpec, + #[serde(default)] + limit: Option, +} + +/// `POST /v1/exec`. +pub async fn oneshot( + State(st): State>, + body: Bytes, +) -> Response { + let req: OneshotRequest = match parse_json(&body) { + Ok(r) => r, + Err(e) => return error(StatusCode::BAD_REQUEST, e), + }; + let limit = req.limit.unwrap_or(MAX_ONESHOT_OUTPUT); + // A one-shot never has a tty (spec), whatever the spec's `tty` says. + let prepared = spawn::prepare(&st.config, &req.spec, false); + let child = prepared.and_then(|p| p.spawn(Io::Null, Io::Piped, Io::Piped, false)); + let mut child = match child { + Ok(c) => c, + Err(PrepareError::Refused(s, m)) => return refusal(s, m), + Err(PrepareError::CannotStart { code, message }) => { + // Docker's shape for an exec that could not start: a finished exec + // with 126/127 and the reason on stderr — not an API error. + return oneshot_response(&[(false, format!("{message}\n").into_bytes())], code); + } + }; + let _active = CountGuard::execs(&st); + let pid = child.id(); + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut stderr = child.stderr.take().expect("piped stderr"); + + // (is_stdout, bytes) in arrival order; adjacent reads from the same + // stream are merged, which changes nothing a client can rebuild. + let mut chunks: Vec<(bool, Vec)> = Vec::new(); + let mut total = 0usize; + let (mut out_open, mut err_open) = (true, true); + let mut ob = vec![0u8; 16 * 1024]; + let mut eb = vec![0u8; 16 * 1024]; + while out_open || err_open { + let (is_out, n) = tokio::select! { + r = stdout.read(&mut ob), if out_open => (true, r.unwrap_or(0)), + r = stderr.read(&mut eb), if err_open => (false, r.unwrap_or(0)), + }; + if n == 0 { + if is_out { out_open = false } else { err_open = false } + continue; + } + // The same test as `docker/exec.rs`'s `push_capped`: refuse the chunk + // that would take the total past `limit`, so exactly `limit` bytes is + // still a success on both transports. + if total + n > limit { + // Kill the whole process group, not just the pid: a pipeline or a + // backgrounded writer would otherwise keep running with nobody + // reading. Reaped in the background so the response is not held + // hostage by a process stuck in uninterruptible sleep. + spawn::signal_group(pid, libc::SIGKILL); + tokio::spawn(async move { + let _ = child.wait().await; + }); + // Byte-for-byte the Docker path's message, so the client can pass + // it through and `OUTPUT_LIMIT_MARKER` checks keep matching. + return error( + StatusCode::PAYLOAD_TOO_LARGE, + format!("OUTPUT_LIMIT: Command output exceeded {limit} bytes and was abandoned"), + ); + } + total += n; + let data = if is_out { &ob[..n] } else { &eb[..n] }; + match chunks.last_mut() { + Some((s, buf)) if *s == is_out => buf.extend_from_slice(data), + _ => chunks.push((is_out, data.to_vec())), + } + } + let code = match child.wait().await { + Ok(status) => spawn::exit_code(status), + Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("wait failed: {e}")), + }; + oneshot_response(&chunks, code) +} + +fn oneshot_response(chunks: &[(bool, Vec)], code: i32) -> Response { + let b64 = base64::engine::general_purpose::STANDARD; + let chunks: Vec<_> = chunks + .iter() + .map(|(is_out, d)| serde_json::json!({ "s": if *is_out { "out" } else { "err" }, "d": b64.encode(d) })) + .collect(); + Json(serde_json::json!({ "chunks": chunks, "exit_code": code })).into_response() +} + +/// `POST /v1/exec/detached`: stdin, stdout and stderr all `/dev/null`. +pub async fn detached( + State(st): State>, + body: Bytes, +) -> Response { + let spec: ExecSpec = match parse_json(&body) { + Ok(r) => r, + Err(e) => return error(StatusCode::BAD_REQUEST, e), + }; + let child = spawn::prepare(&st.config, &spec, false) + .and_then(|p| p.spawn(Io::Null, Io::Null, Io::Null, false)); + let id = match child { + Ok(mut child) => { + let id = st.execs.insert_running(None); + let guard = CountGuard::execs(&st); + let (st2, id2) = (st.clone(), id.clone()); + tokio::spawn(async move { + let _active = guard; + let code = child.wait().await.map(spawn::exit_code).unwrap_or(255); + st2.execs.finish(&id2, code); + }); + id + } + Err(PrepareError::Refused(s, m)) => return refusal(s, m), + // Docker accepts a detached exec of a missing binary and reports the + // failure as its exit code; the output has nowhere to go. + Err(PrepareError::CannotStart { code, .. }) => st.execs.insert_finished(code), + }; + Json(serde_json::json!({ "exec_id": id })).into_response() +} + +/// `GET /v1/exec/{id}`. +pub async fn status(State(st): State>, Path(id): Path) -> Response { + match st.execs.with(&id, |e| (e.running, e.exit_code)) { + Some((running, code)) => { + Json(serde_json::json!({ "running": running, "exit_code": code })).into_response() + } + None => error(StatusCode::NOT_FOUND, format!("no such exec: {id}")), + } +} + +#[derive(Deserialize)] +pub struct ResizeRequest { + cols: u16, + rows: u16, +} + +/// `POST /v1/exec/{id}/resize`: `204`, or `409` for anything that is not a +/// running tty exec. +pub async fn resize( + State(st): State>, + Path(id): Path, + body: Bytes, +) -> Response { + let req: ResizeRequest = match parse_json(&body) { + Ok(r) => r, + Err(e) => return error(StatusCode::BAD_REQUEST, e), + }; + let pty = match st.execs.with(&id, |e| e.pty.clone()) { + None => return error(StatusCode::NOT_FOUND, format!("no such exec: {id}")), + Some(None) => return error(StatusCode::CONFLICT, "exec is not a running tty exec"), + Some(Some(p)) => p, + }; + match pty.resize(req.cols, req.rows) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => error(StatusCode::INTERNAL_SERVER_ERROR, format!("resize failed: {e}")), + } +} diff --git a/container/triple-c-agent/src/files.rs b/container/triple-c-agent/src/files.rs new file mode 100644 index 0000000..440639f --- /dev/null +++ b/container/triple-c-agent/src/files.rs @@ -0,0 +1,265 @@ +//! `PUT /v1/files` and `GET /v1/archive`: the agent's side of the app's file +//! upload and Docker's archive download. + +use std::io::{self, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::OpenOptionsExt; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::rejection::QueryRejection; +use axum::extract::{Query, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use bytes::Bytes; +use futures_util::StreamExt; +use serde::Deserialize; +use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc; + +use crate::{error, AppState}; + +fn query_rejection(r: QueryRejection) -> Response { + error(StatusCode::BAD_REQUEST, format!("invalid query: {}", r.body_text())) +} + +#[derive(Deserialize)] +pub struct PutQuery { + dir: String, + name: String, + /// Octal, e.g. `644`. Defaults to 0644, the mode the app's uploads use. + #[serde(default)] + mode: Option, + #[serde(default)] + uid: Option, + #[serde(default)] + gid: Option, +} + +/// A name the spec allows: one path component, nothing that walks. +fn valid_name(name: &str) -> bool { + !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\0') +} + +/// `PUT /v1/files?dir=&name=&mode=&uid=&gid=`. +/// +/// Mirrors what Docker's archive extractor does with the app's single-file +/// tar (`build_single_file_tar`): an existing file is unlinked and a new one +/// created — never written through, so a hard link or a file another process +/// holds open keeps its old content — then given `mode` and `uid:gid`, mtime +/// now. `dir` is not created: the app has already checked it, and silently +/// making directories is how a typo becomes a stray tree. +pub async fn put_file( + State(st): State>, + q: Result, QueryRejection>, + body: Body, +) -> Response { + let Query(q) = match q { + Ok(q) => q, + Err(r) => return query_rejection(r), + }; + if !q.dir.starts_with('/') { + return error(StatusCode::BAD_REQUEST, "dir must be an absolute path"); + } + if !valid_name(&q.name) { + return error(StatusCode::BAD_REQUEST, "name must be a single path component"); + } + let mode = match q.mode.as_deref() { + None => 0o644, + Some(m) => match u32::from_str_radix(m, 8) { + Ok(m) if m <= 0o7777 => m, + _ => return error(StatusCode::BAD_REQUEST, format!("invalid octal mode: {m}")), + }, + }; + let (euid, egid) = unsafe { (libc::geteuid(), libc::getegid()) }; + let uid = q.uid.unwrap_or(euid); + let gid = q.gid.unwrap_or(egid); + // Same rule as execs: a non-root agent (tests) can only act as itself. + // Checked before touching the filesystem, so a refusal changes nothing. + if euid != 0 && (uid != euid || gid != egid) { + return error(StatusCode::FORBIDDEN, "the agent is not root and cannot chown to another user"); + } + // `dir` is resolved following symlinks, and the write runs as root. That + // is deliberate parity, not an oversight: Docker's archive upload — what + // this replaces — also resolves symlinks inside the container. It is not + // a privilege boundary either: the `claude` user who could plant a + // symlink has passwordless sudo in this image (Dockerfile, sudoers.d), so + // redirecting a root write gains nothing `sudo` doesn't. The final + // component is still opened O_NOFOLLOW|O_CREAT|O_EXCL below, so an + // existing symlink *at* the name is replaced, never written through. + // Revisit with openat2(RESOLVE_NO_SYMLINKS) if that sudo ever goes. + match std::fs::metadata(&q.dir) { + Ok(m) if m.is_dir() => {} + Ok(_) => return error(StatusCode::BAD_REQUEST, format!("{} is not a directory", q.dir)), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + return error(StatusCode::NOT_FOUND, format!("{} does not exist", q.dir)) + } + Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("{}: {e}", q.dir)), + } + let path = PathBuf::from(&q.dir).join(&q.name); + let shown = format!("{}/{}", q.dir.trim_end_matches('/'), q.name); + + // Unlink, then create — tar extraction's replace. A directory in the way + // is refused rather than removed recursively. + match std::fs::symlink_metadata(&path) { + Ok(m) if m.is_dir() => { + return error(StatusCode::CONFLICT, format!("{shown} is a directory")); + } + Ok(_) => { + if let Err(e) = std::fs::remove_file(&path) { + return error(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot replace {shown}: {e}")); + } + } + Err(_) => {} + } + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path); + let file = match file { + Ok(f) => f, + Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot create {shown}: {e}")), + }; + // Owner before mode: chown clears setuid/setgid bits, so the mode has to + // land last to survive. fchmod rather than the open mode so the umask + // does not get a say — tar extraction as root restores modes exactly. + let fd = file.as_raw_fd(); + let set = unsafe { + (euid != 0 || libc::fchown(fd, uid, gid) == 0) && libc::fchmod(fd, mode as libc::mode_t) == 0 + }; + if !set { + let e = io::Error::last_os_error(); + let _ = std::fs::remove_file(&path); + return error(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot set owner/mode on {shown}: {e}")); + } + + let mut file = tokio::fs::File::from_std(file); + let mut stream = body.into_data_stream(); + let mut written: u64 = 0; + let fail = |status, msg: String| { + let _ = std::fs::remove_file(&path); + error(status, msg) + }; + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => return fail(StatusCode::BAD_REQUEST, format!("upload interrupted: {e}")), + }; + written += chunk.len() as u64; + if written > st.config.max_file_bytes { + return fail( + StatusCode::PAYLOAD_TOO_LARGE, + format!("File too large to upload (limit {} MB)", st.config.max_file_bytes / (1024 * 1024)), + ); + } + if let Err(e) = file.write_all(&chunk).await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot write {shown}: {e}")); + } + } + if let Err(e) = file.flush().await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot write {shown}: {e}")); + } + Json(serde_json::json!({ "path": shown })).into_response() +} + +#[derive(Deserialize)] +pub struct ArchiveQuery { + path: String, +} + +/// `GET /v1/archive?path=`: a streamed tar whose root member is +/// `basename(path)`, recursing into directories — the shape of Docker's +/// `GET /containers/{id}/archive`, which is what the app's download parses. +/// +/// Symlinks are archived as links, not followed, at the root as well as +/// inside: Docker does the same, and following them would let a link in the +/// tree pull in files from anywhere in the container. +pub async fn archive(q: Result, QueryRejection>) -> Response { + let Query(q) = match q { + Ok(q) => q, + Err(r) => return query_rejection(r), + }; + if !q.path.starts_with('/') { + return error(StatusCode::BAD_REQUEST, "path must be absolute"); + } + let path = PathBuf::from(&q.path); + let meta = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + return error(StatusCode::NOT_FOUND, format!("{} does not exist", q.path)) + } + Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("{}: {e}", q.path)), + }; + let root_name = path + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + + // tar is a synchronous writer, so it runs on a blocking thread feeding a + // small channel. When the client disconnects, the body (and receiver) is + // dropped, the next send fails, and the walk stops — "dropping the + // connection cancels the read". + let (tx, mut rx) = mpsc::channel::>(8); + tokio::task::spawn_blocking(move || { + let mut w = ChannelWriter { tx: tx.clone(), buf: Vec::with_capacity(CHUNK) }; + let res = (|| { + let mut b = tar::Builder::new(&mut w); + b.follow_symlinks(false); + b.mode(tar::HeaderMode::Complete); + if meta.is_dir() { + b.append_dir_all(&root_name, &path)?; + } else { + b.append_path_with_name(&path, &root_name)?; + } + b.finish() + })(); + let res = res.and_then(|()| w.flush()); + if let Err(e) = res { + // Surface as a body error: the client sees a truncated, failed + // download rather than a short archive that looks complete. + let _ = tx.blocking_send(Err(e)); + } + }); + let stream = futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)); + Response::builder() + .header(header::CONTENT_TYPE, "application/x-tar") + .body(Body::from_stream(stream)) + .unwrap_or_else(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "response build failed")) +} + +const CHUNK: usize = 64 * 1024; + +struct ChannelWriter { + tx: mpsc::Sender>, + buf: Vec, +} + +impl ChannelWriter { + fn send(&mut self) -> io::Result<()> { + if self.buf.is_empty() { + return Ok(()); + } + let data = Bytes::from(std::mem::replace(&mut self.buf, Vec::with_capacity(CHUNK))); + self.tx + .blocking_send(Ok(data)) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client went away")) + } +} + +impl Write for ChannelWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + self.buf.extend_from_slice(data); + if self.buf.len() >= CHUNK { + self.send()?; + } + Ok(data.len()) + } + fn flush(&mut self) -> io::Result<()> { + self.send() + } +} + diff --git a/container/triple-c-agent/src/lib.rs b/container/triple-c-agent/src/lib.rs new file mode 100644 index 0000000..8ac0728 --- /dev/null +++ b/container/triple-c-agent/src/lib.rs @@ -0,0 +1,440 @@ +//! `triple-c-agent`: everything `host::ContainerTarget` does through the Docker +//! API, done from inside the container over authenticated HTTP/WebSocket. +//! +//! The contract is `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`. +//! **Docker parity is the requirement**: the same `ExecSpec` must behave the +//! same here as through `docker exec`, because the app's call sites were +//! written against Docker and are not going to grow a second set of rules. +//! Each place this code reproduces a Docker behaviour says so where it does it. +//! +//! The library exists so the integration tests can run the real server +//! in-process on an ephemeral port; `main.rs` only reads the environment. + +mod attach; +mod exec; +mod files; +mod pty; +mod spawn; +mod tunnel; +mod users; + +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::extract::{Request, State}; +use axum::http::{header, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; + +/// The wire protocol version reported by `/v1/health`. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Upload ceiling for `PUT /v1/files`: the app's `MAX_DROP_BYTES` +/// (`docker/exec.rs`). The app checks it before sending, so this only has to +/// stop a body that lies about itself. +pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; + +/// The default one-shot budget when a request omits `limit` — the app's +/// `MAX_ONESHOT_OUTPUT`. Every real client call sends its own. +pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024; + +/// The shortest token the agent will accept. There is no unauthenticated mode, +/// and a short token is close enough to none that it is refused the same way. +pub const MIN_TOKEN_BYTES: usize = 32; + +/// Name of the env var carrying the token. `main` removes it from the +/// process's environment after reading it, so no exec inherits it — but that +/// only edits libc's copy: the kernel's record of the environment the process +/// was started with (`/proc//environ`, readable by root and by the +/// agent's own uid) still holds it. Prefer [`TOKEN_FILE_ENV`], which is what +/// the entrypoint uses. +pub const TOKEN_ENV: &str = "TRIPLEC_AGENT_TOKEN"; +pub const TOKEN_FILE_ENV: &str = "TRIPLEC_AGENT_TOKEN_FILE"; +pub const ADDR_ENV: &str = "TRIPLEC_AGENT_ADDR"; +pub const DEFAULT_ADDR: &str = "127.0.0.1:47900"; + +/// How long a client gets to send a complete request head. Until its token +/// has been checked a connection is anonymous, so this is what stops a +/// slowloris from holding sockets open forever. +pub const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10); + +/// Concurrent HTTP connections. Far above what the app opens; a bound on +/// what an unauthenticated caller can make the agent hold. +pub const MAX_CONNECTIONS: usize = 256; + +/// The largest WebSocket message (and frame) the agent accepts on attach and +/// tunnel sockets. The app sends stdin in frames of at most 64 KiB. +pub(crate) const MAX_WS_MESSAGE: usize = 1024 * 1024; + +/// Everything the server needs, captured once at startup. +#[derive(Clone)] +pub struct Config { + /// The bearer token every request must carry. + pub token: String, + /// Where an exec with `working_dir: null` runs: the agent's own cwd at + /// startup, which is the container's `WorkingDir` — what `docker exec` + /// without `-w` uses. + pub default_cwd: PathBuf, + /// The env every exec starts from: the agent's own at startup, minus the + /// token. That is the container's configured env, which is what a + /// `docker exec` inherits. + pub base_env: Vec<(OsString, OsString)>, + /// `PUT /v1/files` ceiling. [`MAX_DROP_BYTES`] in production; a field only + /// so tests can exercise the refusal without a 256 MiB body. + pub max_file_bytes: u64, + /// [`HEADER_READ_TIMEOUT`] in production; a field so tests need not wait. + pub header_read_timeout: Duration, + /// [`MAX_CONNECTIONS`] in production. + pub max_connections: usize, +} + +impl Config { + /// Capture the process's own cwd and env. Call after the token has been + /// removed from the environment; the token is filtered here as well, so a + /// caller that forgets cannot leak it into execs. + pub fn from_process(token: String) -> std::io::Result { + Ok(Config { + token, + default_cwd: std::env::current_dir()?, + // None of the agent's own settings (`TRIPLEC_AGENT_*`) reach an + // exec: the token is a secret, and the token-file path and listen + // address are the agent's business — `docker exec` sees neither. + base_env: std::env::vars_os() + .filter(|(k, _)| !k.to_string_lossy().starts_with("TRIPLEC_AGENT_")) + .collect(), + max_file_bytes: MAX_DROP_BYTES, + header_read_timeout: HEADER_READ_TIMEOUT, + max_connections: MAX_CONNECTIONS, + }) + } +} + +/// Read the token as the spec says: the env var first, then the file. Refuses +/// anything empty or shorter than [`MIN_TOKEN_BYTES`] — the agent has no +/// unauthenticated mode to fall back to. +/// +/// Surrounding whitespace is trimmed: a token file written by `echo` or a +/// secrets manager usually ends in a newline, and a trailing `\n` the client +/// never sends would be a token nobody can present. +pub fn load_token(env_token: Option, file: Option) -> Result { + let (raw, source) = match env_token.filter(|t| !t.is_empty()) { + Some(t) => ( + t.into_string() + .map_err(|_| format!("{TOKEN_ENV} is not valid UTF-8"))?, + TOKEN_ENV.to_string(), + ), + None => match file { + Some(path) => ( + read_private_file(&path) + .map_err(|e| format!("cannot use {TOKEN_FILE_ENV} ({}): {e}", path.display()))?, + format!("{TOKEN_FILE_ENV} ({})", path.display()), + ), + None => { + return Err(format!( + "no token: set {TOKEN_ENV} or {TOKEN_FILE_ENV}; refusing to start unauthenticated" + )) + } + }, + }; + let token = raw.trim().to_string(); + if token.len() < MIN_TOKEN_BYTES { + return Err(format!( + "token from {source} is {} bytes; at least {MIN_TOKEN_BYTES} are required", + token.len() + )); + } + Ok(token) +} + +/// Read a secret file, refusing one that anyone but its owner could read or +/// that this process does not own — as `ssh` does for key files. The token +/// is effectively root in the container; a world-readable copy of it would be +/// a way in for any process, not just root's. Checked on the opened file, so +/// the file judged is the file read. +fn read_private_file(path: &std::path::Path) -> Result { + use std::io::Read; + use std::os::unix::fs::MetadataExt; + let mut f = std::fs::File::open(path).map_err(|e| e.to_string())?; + let meta = f.metadata().map_err(|e| e.to_string())?; + let euid = unsafe { libc::geteuid() }; + if meta.uid() != euid { + return Err(format!("owned by uid {}, not this process's uid {euid}", meta.uid())); + } + if meta.mode() & 0o077 != 0 { + return Err(format!( + "mode {:o} lets others read it; it must be 0600 or stricter", + meta.mode() & 0o777 + )); + } + let mut s = String::new(); + f.read_to_string(&mut s).map_err(|e| e.to_string())?; + Ok(s) +} + +/// Shared server state. +pub(crate) struct AppState { + pub config: Config, + pub execs: exec::Registry, + pub activity: Activity, +} + +/// What `/v1/activity` reports; the input to idle auto-stop. +pub(crate) struct Activity { + last_unix: AtomicI64, + pub execs: AtomicI64, + pub tunnels: AtomicI64, +} + +impl Activity { + pub fn touch(&self) { + self.last_unix.store(now_unix(), Ordering::Relaxed); + } +} + +/// Increments a counter for as long as it lives, so an exec or tunnel that +/// ends by any path — including a panic or a dropped task — stops counting. +pub(crate) struct CountGuard(Arc, fn(&Activity) -> &AtomicI64); + +impl CountGuard { + pub fn execs(st: &Arc) -> CountGuard { + st.activity.execs.fetch_add(1, Ordering::Relaxed); + CountGuard(st.clone(), |a| &a.execs) + } + pub fn tunnels(st: &Arc) -> CountGuard { + st.activity.tunnels.fetch_add(1, Ordering::Relaxed); + CountGuard(st.clone(), |a| &a.tunnels) + } +} + +impl Drop for CountGuard { + fn drop(&mut self) { + (self.1)(&self.0.activity).fetch_sub(1, Ordering::Relaxed); + } +} + +pub(crate) fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// The spec's error shape: `{"error": ""}` with a JSON content type. +pub(crate) fn error(status: StatusCode, msg: impl Into) -> Response { + (status, Json(serde_json::json!({ "error": msg.into() }))).into_response() +} + +/// Every route, behind the token gate. +fn router(state: Arc) -> Router { + Router::new() + .route("/v1/health", get(health)) + .route("/v1/activity", get(activity)) + .route("/v1/exec", post(exec::oneshot)) + .route("/v1/exec/detached", post(exec::detached)) + .route("/v1/exec/{id}", get(exec::status)) + .route("/v1/exec/{id}/resize", post(exec::resize)) + .route("/v1/attach", get(attach::upgrade)) + .route( + "/v1/files", + // The handler streams the body and enforces its own ceiling, so + // axum's 2 MB default must not cut it off first. + put(files::put_file).layer(axum::extract::DefaultBodyLimit::disable()), + ) + .route("/v1/archive", get(files::archive)) + .route("/v1/tunnel", get(tunnel::upgrade)) + .fallback(|| async { error(StatusCode::NOT_FOUND, "no such endpoint") }) + // `layer`, not `route_layer`: the fallback must be behind the token + // too, or an unauthenticated caller could map the API by 404 vs 401. + .layer(middleware::from_fn_with_state(state.clone(), auth)) + .with_state(state) +} + +/// Serve the protocol on `listener`. Runs until the task is dropped: accept +/// errors (a full fd table, a connection reset before accept) are transient +/// and retried, as `axum::serve` does. +/// +/// Our own accept loop rather than `axum::serve`, because that gives hyper no +/// timer, so hyper's header-read timeout never fires and an anonymous +/// connection that trickles (or never sends) its request head is held +/// forever. Here every connection gets [`Config::header_read_timeout`] to +/// deliver a complete head, and at most [`Config::max_connections`] are +/// served at once. HTTP/1.1 only — all the protocol needs — with upgrades. +/// +/// At the cap the loop **waits** for a slot before accepting, rather than +/// accepting and dropping: excess clients queue in the kernel's backlog and +/// are served as soon as a slot frees, which the header timeout guarantees +/// for any slot an idle or slow client holds. Dropping would instead fail the +/// app's own requests outright whenever the cap is reached. +/// +/// A connection's slot is released when hyper is done with it. A WebSocket +/// upgrade ends hyper's part (the socket then belongs to the session task), +/// so attach and tunnel sessions do not count against the cap — and they only +/// exist past the token check. +pub async fn serve(listener: tokio::net::TcpListener, config: Config) -> std::io::Result<()> { + use hyper_util::rt::{TokioIo, TokioTimer}; + use hyper_util::service::TowerToHyperService; + + let header_timeout = config.header_read_timeout; + let slots = Arc::new(tokio::sync::Semaphore::new(config.max_connections.max(1))); + let state = Arc::new(AppState { + config, + execs: exec::Registry::default(), + activity: Activity { + last_unix: AtomicI64::new(now_unix()), + execs: AtomicI64::new(0), + tunnels: AtomicI64::new(0), + }, + }); + let app = router(state); + loop { + let permit = slots.clone().acquire_owned().await.expect("the semaphore is never closed"); + let stream = match listener.accept().await { + Ok((stream, _)) => stream, + Err(e) => { + // ECONNABORTED and friends are per-connection; EMFILE/ENFILE + // clear as connections close. Either way, back off and retry. + if !matches!( + e.kind(), + std::io::ErrorKind::ConnectionAborted | std::io::ErrorKind::ConnectionReset + ) { + eprintln!("triple-c-agent: accept failed: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + } + continue; + } + }; + let service = TowerToHyperService::new(app.clone()); + tokio::spawn(async move { + let _slot = permit; + let conn = hyper::server::conn::http1::Builder::new() + .timer(TokioTimer::new()) + .header_read_timeout(header_timeout) + .serve_connection(TokioIo::new(stream), service) + .with_upgrades(); + // An error here is the client's (reset, malformed request, header + // timeout); there is nobody to report it to. + let _ = conn.await; + }); + } +} + +/// Bearer-token gate on every request. A failure is `401` with an **empty** +/// body (spec): nothing about the API is disclosed to a caller without the +/// token. +async fn auth(State(st): State>, req: Request, next: Next) -> Response { + let presented = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.as_bytes().strip_prefix(b"Bearer ")); + let ok = presented.is_some_and(|p| constant_time_eq(p, st.config.token.as_bytes())); + if !ok { + return StatusCode::UNAUTHORIZED.into_response(); + } + // `health` and `activity` are the idle monitor's own probes; counting them + // would mean an idle container could never look idle. + let path = req.uri().path(); + if path != "/v1/health" && path != "/v1/activity" { + st.activity.touch(); + } + next.run(req).await +} + +/// Compare without an early exit, so response timing says nothing about how +/// many leading bytes of a guess were right. The length is not secret (the +/// minimum is public and tokens are generated at a fixed size), but it is +/// folded in rather than short-circuited anyway. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + let mut diff: usize = a.len() ^ b.len(); + for i in 0..a.len().max(b.len()) { + let x = a.get(i).copied().unwrap_or(0); + let y = b.get(i).copied().unwrap_or(0); + diff |= (x ^ y) as usize; + } + diff == 0 +} + +async fn health() -> Response { + Json(serde_json::json!({ + "agent": "triple-c-agent", + "version": env!("CARGO_PKG_VERSION"), + "protocol": PROTOCOL_VERSION, + })) + .into_response() +} + +async fn activity(State(st): State>) -> Response { + Json(serde_json::json!({ + "last_activity_unix": st.activity.last_unix.load(Ordering::Relaxed), + "active_execs": st.activity.execs.load(Ordering::Relaxed), + "active_tunnels": st.activity.tunnels.load(Ordering::Relaxed), + })) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_time_eq_matches_plain_equality() { + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"abcd")); + assert!(!constant_time_eq(b"", b"a")); + // A length difference of exactly 256 must not wrap to "equal length". + assert!(!constant_time_eq(&[0u8; 256], b"")); + } + + #[test] + fn a_token_file_others_can_read_is_refused() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + std::fs::write(&path, "y".repeat(40)).unwrap(); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let e = load_token(None, Some(path.clone())).unwrap_err(); + assert!(e.contains("0600"), "{e}"); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!(load_token(None, Some(path.clone())).unwrap(), "y".repeat(40)); + } + + #[test] + fn the_token_never_reaches_the_exec_base_env() { + // `main` removes the var before capturing; this is the second guard, + // for a caller that forgets. + std::env::set_var(TOKEN_ENV, "x".repeat(40)); + std::env::set_var(TOKEN_FILE_ENV, "/run/triple-c/agent-token"); + let cfg = Config::from_process("t".into()).unwrap(); + std::env::remove_var(TOKEN_ENV); + std::env::remove_var(TOKEN_FILE_ENV); + assert!(cfg.base_env.iter().all(|(k, _)| !k.to_string_lossy().starts_with("TRIPLEC_AGENT_"))); + assert!(cfg.base_env.iter().any(|(k, _)| k == "PATH")); + } + + #[test] + fn token_rules() { + let long = "x".repeat(32); + assert_eq!(load_token(Some(long.clone().into()), None).unwrap(), long); + assert!(load_token(Some("short".into()), None).is_err()); + assert!(load_token(None, None).is_err()); + // An empty env var falls through to the file. + let dir = tempfile::tempdir().unwrap(); + let f = dir.path().join("tok"); + std::fs::write(&f, format!("{long}\n")).unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + assert_eq!(load_token(Some("".into()), Some(f.clone())).unwrap(), long); + std::fs::write(&f, "\n").unwrap(); + assert!(load_token(None, Some(f)).is_err()); + assert!(load_token(None, Some(dir.path().join("missing"))).is_err()); + } +} diff --git a/container/triple-c-agent/src/main.rs b/container/triple-c-agent/src/main.rs new file mode 100644 index 0000000..a08df0b --- /dev/null +++ b/container/triple-c-agent/src/main.rs @@ -0,0 +1,68 @@ +//! `triple-c-agent` entry point: read the environment, drop the token from +//! it, bind, serve. Everything else is in the library. + +use std::path::PathBuf; +use std::process::ExitCode; + +use triple_c_agent::{load_token, serve, Config, ADDR_ENV, DEFAULT_ADDR, TOKEN_ENV, TOKEN_FILE_ENV}; + +fn main() -> ExitCode { + let token = match load_token( + std::env::var_os(TOKEN_ENV), + std::env::var_os(TOKEN_FILE_ENV).map(PathBuf::from), + ) { + Ok(t) => t, + Err(e) => { + eprintln!("triple-c-agent: {e}"); + return ExitCode::from(2); + } + }; + // Before the runtime starts any thread and before anything is spawned: + // removing an env var is only sound while the process is single-threaded, + // and every exec's base env is captured from this process's env below. + // + // This keeps the token out of every exec's env, and that is all it does: + // it edits libc's copy of the environment, not the block the kernel + // recorded at exec time, so `/proc//environ` still shows the + // token to root and to anything running as the agent's uid. Use + // TRIPLEC_AGENT_TOKEN_FILE (what the entrypoint does) to avoid that. + std::env::remove_var(TOKEN_ENV); + + // Loopback by default. A non-loopback address is only ever bound when + // someone wrote it into TRIPLEC_AGENT_ADDR on purpose. + let addr = std::env::var(ADDR_ENV).unwrap_or_else(|_| DEFAULT_ADDR.to_string()); + let config = match Config::from_process(token) { + Ok(c) => c, + Err(e) => { + eprintln!("triple-c-agent: cannot read startup state: {e}"); + return ExitCode::from(2); + } + }; + + let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() { + Ok(rt) => rt, + Err(e) => { + eprintln!("triple-c-agent: cannot start runtime: {e}"); + return ExitCode::from(2); + } + }; + rt.block_on(async move { + let listener = match tokio::net::TcpListener::bind(&addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("triple-c-agent: cannot bind {addr}: {e}"); + return ExitCode::from(2); + } + }; + let shown = listener.local_addr().map(|a| a.to_string()).unwrap_or(addr); + // One line, never the token. + eprintln!("triple-c-agent {} listening on {shown}", env!("CARGO_PKG_VERSION")); + match serve(listener, config).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("triple-c-agent: server failed: {e}"); + ExitCode::FAILURE + } + } + }) +} diff --git a/container/triple-c-agent/src/pty.rs b/container/triple-c-agent/src/pty.rs new file mode 100644 index 0000000..c53a943 --- /dev/null +++ b/container/triple-c-agent/src/pty.rs @@ -0,0 +1,124 @@ +//! Pseudo-terminals for `tty: true` execs. + +use std::ffi::CStr; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + +use tokio::io::unix::AsyncFd; + +/// The agent's end of a pty. Shared (via `Arc`) by the output pump, the stdin +/// writer and the exec registry, which needs it for `resize`. +pub struct PtyMaster { + fd: AsyncFd, +} + +/// Open a pty sized `cols`×`rows`, returning the master and the slave the +/// child gets as stdin/stdout/stderr. +/// +/// Both ends are opened `O_CLOEXEC` *atomically* (`posix_openpt`, then `open` +/// on the slave path) rather than with `openpty` and a later `FD_CLOEXEC`: +/// execs are spawned concurrently from other threads, and a master fd leaked +/// into an unrelated child would keep this terminal open after its own process +/// exits — the attach would never see EOF. +pub fn open(cols: u16, rows: u16) -> io::Result<(PtyMaster, OwnedFd)> { + unsafe { + let m = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC); + if m < 0 { + return Err(io::Error::last_os_error()); + } + let master = OwnedFd::from_raw_fd(m); + if libc::grantpt(m) != 0 || libc::unlockpt(m) != 0 { + return Err(io::Error::last_os_error()); + } + let mut name = [0 as libc::c_char; 128]; + let rc = libc::ptsname_r(m, name.as_mut_ptr(), name.len()); + if rc != 0 { + return Err(io::Error::from_raw_os_error(rc)); + } + let s = libc::open( + CStr::from_ptr(name.as_ptr()).as_ptr(), + libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC, + ); + if s < 0 { + return Err(io::Error::last_os_error()); + } + let slave = OwnedFd::from_raw_fd(s); + set_size(m, cols, rows)?; + let flags = libc::fcntl(m, libc::F_GETFL); + if flags < 0 || libc::fcntl(m, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { + return Err(io::Error::last_os_error()); + } + // Sound: `master` is an `OwnedFd`, open for as long as the AsyncFd + // owns it, and `as_raw_fd` always returns that same descriptor. + Ok((PtyMaster { fd: AsyncFd::register(master)? }, slave)) + } +} + +fn set_size(fd: libc::c_int, cols: u16, rows: u16) -> io::Result<()> { + let ws = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + if unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &ws) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +impl PtyMaster { + /// `TIOCSWINSZ` on the master; the kernel delivers `SIGWINCH` to the + /// terminal's foreground process group, as Docker's resize does. + pub fn resize(&self, cols: u16, rows: u16) -> io::Result<()> { + set_size(self.fd.get_ref().as_raw_fd(), cols, rows) + } + + /// Read output. `Ok(0)` is end of stream: Linux reports "every slave fd is + /// closed" on the master as `EIO`, which is folded into EOF here. + pub async fn read(&self, buf: &mut [u8]) -> io::Result { + loop { + let mut guard = self.fd.readable().await?; + let res = guard.try_io(|inner| { + let n = unsafe { + libc::read(inner.get_ref().as_raw_fd(), buf.as_mut_ptr().cast(), buf.len()) + }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } + }); + match res { + Ok(Ok(n)) => return Ok(n), + Ok(Err(e)) if e.raw_os_error() == Some(libc::EIO) => return Ok(0), + Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue, + Ok(Err(e)) => return Err(e), + Err(_would_block) => continue, + } + } + } + + pub async fn write_all(&self, mut data: &[u8]) -> io::Result<()> { + while !data.is_empty() { + let mut guard = self.fd.writable().await?; + let res = guard.try_io(|inner| { + let n = unsafe { + libc::write(inner.get_ref().as_raw_fd(), data.as_ptr().cast(), data.len()) + }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } + }); + match res { + Ok(Ok(n)) => data = &data[n..], + Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue, + Ok(Err(e)) => return Err(e), + Err(_would_block) => continue, + } + } + Ok(()) + } +} diff --git a/container/triple-c-agent/src/spawn.rs b/container/triple-c-agent/src/spawn.rs new file mode 100644 index 0000000..b835c7d --- /dev/null +++ b/container/triple-c-agent/src/spawn.rs @@ -0,0 +1,424 @@ +//! Turning an `ExecSpec` into a running process the way `docker exec` would. + +use std::ffi::{CString, OsStr, OsString}; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::{CommandExt, ExitStatusExt}; +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Stdio}; + +use axum::http::StatusCode; +use serde::Deserialize; + +use crate::users::{self, UserInfo}; +use crate::Config; + +/// The spec's ExecSpec. `user` may be omitted, meaning the agent's own user — +/// as an exec with no `User` runs as the container's configured user (root). +#[derive(Debug, Clone, Deserialize)] +pub struct ExecSpec { + pub cmd: Vec, + #[serde(default)] + pub user: Option, + #[serde(default)] + pub working_dir: Option, + #[serde(default)] + pub env: Vec, + #[serde(default)] + pub tty: bool, +} + +/// Docker's default `PATH` when the container env has none +/// (`CreateDaemonEnvironment` → `oci.DefaultPathEnv`). +const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +/// An exec ready to start. +pub struct Prepared { + program: PathBuf, + argv: Vec, + env: Vec<(OsString, OsString)>, + cwd: CString, + user: UserInfo, + /// Only when running as root. A non-root agent can only ever run as + /// itself (anything else is refused with 403 before we get here), and + /// calling `setgroups` unprivileged would fail the exec. + switch_ids: bool, +} + +/// Why a spec could not become a process. +pub enum PrepareError { + /// An HTTP-level refusal (bad request, wrong user for a non-root agent). + Refused(StatusCode, String), + /// The command cannot start. Docker reports this as a *finished* exec with + /// 126/127 and the reason on stderr, not as an API error, so callers turn + /// it into exactly that. + CannotStart { code: i32, message: String }, +} + +impl PrepareError { + fn cannot_start(code: i32, message: String) -> Self { + PrepareError::CannotStart { code, message } + } +} + +/// Validate a spec and resolve user, env, working directory and program. +pub fn prepare(cfg: &Config, spec: &ExecSpec, tty: bool) -> Result { + if spec.cmd.is_empty() || spec.cmd[0].is_empty() { + return Err(PrepareError::Refused(StatusCode::BAD_REQUEST, "cmd must be non-empty".into())); + } + if spec.cmd.iter().any(|a| a.contains('\0')) { + return Err(PrepareError::Refused(StatusCode::BAD_REQUEST, "cmd contains a NUL byte".into())); + } + + let me = users::current(); + let is_root = unsafe { libc::geteuid() } == 0; + let user = match spec.user.as_deref().filter(|u| !u.is_empty()) { + Some(name) => users::lookup(name).ok_or_else(|| { + // runc's wording, which is what `docker exec -u nobody-here` prints. + PrepareError::cannot_start( + 126, + format!("unable to find user {name}: no matching entries in passwd file"), + ) + })?, + None => me.clone().ok_or_else(|| { + PrepareError::Refused( + StatusCode::INTERNAL_SERVER_ERROR, + "the agent's own user has no passwd entry".into(), + ) + })?, + }; + if !is_root && user.uid != unsafe { libc::geteuid() } { + return Err(PrepareError::Refused( + StatusCode::FORBIDDEN, + format!("the agent is not root and cannot run as {}", user.name), + )); + } + + let env = merge_env(cfg, spec, &user, tty); + + // `working_dir: null` is `docker exec` without `-w`: the container's + // WorkingDir, which is the agent's cwd at startup. Checked here so a bad + // directory is a clean 126 with runc's message rather than an errno that + // happens to collide with "no such binary". + let cwd = match &spec.working_dir { + Some(d) if !d.is_empty() => PathBuf::from(d), + _ => cfg.default_cwd.clone(), + }; + if !cwd.is_dir() { + return Err(PrepareError::cannot_start( + 126, + format!( + "chdir to cwd (\"{}\") set in config.json failed: no such file or directory", + cwd.display() + ), + )); + } + + let program = resolve_program(&spec.cmd[0], &env, &cwd).ok_or_else(|| { + // runc's text for a missing binary; Docker reports it with 127 the + // way a shell does, and the app's callers check that code. + PrepareError::cannot_start( + 127, + format!( + "OCI runtime exec failed: exec failed: unable to start container process: exec: \"{}\": executable file not found in $PATH: unknown", + spec.cmd[0] + ), + ) + })?; + + Ok(Prepared { + program, + argv: spec.cmd.clone(), + env, + cwd: CString::new(cwd.as_os_str().as_bytes()).map_err(|_| { + PrepareError::Refused(StatusCode::BAD_REQUEST, "working_dir contains a NUL byte".into()) + })?, + switch_ids: is_root, + user, + }) +} + +/// Build the exec's environment. +/// +/// Order, matching Docker's `ReplaceOrAppendEnvValues(CreateDaemonEnvironment(…), spec.Env)`: +/// the base env; `PATH` defaulted if absent and `TERM=xterm` for a tty if +/// absent (both Docker daemon defaults); then the spec's entries, later ones +/// winning. An entry with no `=` *removes* that variable, as Docker does. +/// Finally `HOME` comes from passwd unless the **spec** set it — the agent's +/// own `HOME=/root` must not leak into a `claude` exec. Only `HOME`: runc sets +/// nothing else for an exec, so `USER`/`LOGNAME` stay unset unless the +/// container's env or the spec has them (verified against `docker exec` by +/// the app's parity test, `host::agent::live_tests`). +fn merge_env(cfg: &Config, spec: &ExecSpec, user: &UserInfo, tty: bool) -> Vec<(OsString, OsString)> { + let mut env: Vec<(OsString, OsString)> = cfg.base_env.clone(); + fn set(env: &mut Vec<(OsString, OsString)>, k: &OsStr, v: &OsStr) { + match env.iter_mut().find(|(ek, _)| ek == k) { + Some(slot) => slot.1 = v.to_os_string(), + None => env.push((k.to_os_string(), v.to_os_string())), + } + } + if !env.iter().any(|(k, _)| k == "PATH") { + set(&mut env, OsStr::new("PATH"), OsStr::new(DEFAULT_PATH)); + } + if tty && !env.iter().any(|(k, _)| k == "TERM") { + set(&mut env, OsStr::new("TERM"), OsStr::new("xterm")); + } + let mut spec_set: Vec<&str> = Vec::new(); + for entry in &spec.env { + match entry.split_once('=') { + Some((k, v)) => { + set(&mut env, OsStr::new(k), OsStr::new(v)); + spec_set.push(k); + } + None => { + env.retain(|(k, _)| k != entry.as_str()); + spec_set.push(entry); + } + } + } + if !spec_set.contains(&"HOME") { + set(&mut env, OsStr::new("HOME"), OsStr::new(&user.home)); + } + env.retain(|(k, v)| { + !k.is_empty() && !k.as_bytes().contains(&b'=') && !k.as_bytes().contains(&0) && !v.as_bytes().contains(&0) + }); + env +} + +/// `cmd[0]` through the **merged** env's `PATH` (spec), like runc's +/// `LookPath`. A name containing `/` is used as given (relative to the +/// working directory, as it will be after `chdir`). +fn resolve_program(cmd0: &str, env: &[(OsString, OsString)], cwd: &Path) -> Option { + if cmd0.contains('/') { + return Some(PathBuf::from(cmd0)); + } + let path = env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone())?; + for dir in std::env::split_paths(&path) { + let dir = if dir.as_os_str().is_empty() { cwd.to_path_buf() } else { cwd.join(dir) }; + let candidate = dir.join(cmd0); + if let Ok(meta) = std::fs::metadata(&candidate) { + if meta.is_file() && meta.permissions().mode() & 0o111 != 0 { + return Some(candidate); + } + } + } + None +} + +/// What the child's stdio should be. +pub enum Io { + Null, + Piped, + Fd(std::os::fd::OwnedFd), +} + +impl Io { + fn stdio(self) -> Stdio { + match self { + Io::Null => Stdio::null(), + Io::Piped => Stdio::piped(), + Io::Fd(fd) => Stdio::from(fd), + } + } +} + +impl Prepared { + /// Spawn. `controlling_tty` makes stdin (which must be a pty slave) the + /// session's controlling terminal. + /// + /// Every exec runs in **its own session, hence its own process group** + /// (`setsid`), so the output-limit kill and the disconnect `SIGHUP` reach + /// everything it started, not just the first pid. + pub fn spawn( + &self, + stdin: Io, + stdout: Io, + stderr: Io, + controlling_tty: bool, + ) -> Result { + let mut cmd = std::process::Command::new(&self.program); + cmd.arg0(&self.argv[0]) + .args(&self.argv[1..]) + .env_clear() + .envs(self.env.iter().map(|(k, v)| (k, v))) + .stdin(stdin.stdio()) + .stdout(stdout.stdio()) + .stderr(stderr.stdio()); + + let switch = self.switch_ids; + let (uid, gid) = (self.user.uid, self.user.gid); + let groups = self.user.groups.clone(); + let cwd = self.cwd.clone(); + let dev_null = dev_null_rdev(); + // Runs in the forked child: only async-signal-safe syscalls, nothing + // that allocates (everything it touches was built above). + // + // Order matters. Signal state first: dispositions and mask back to + // the defaults. `docker exec` children start from runc's clean state, + // but ours would otherwise inherit whatever started the agent — the + // entrypoint's supervisor runs it under `trap '' HUP`, and an ignored + // disposition survives `exec`, so every tty session would ignore the + // hangup a client disconnect sends and outlive its client. (Handled + // signals already reset on exec; std clears the mask too, but that + // is std's detail, so we do not rely on it.) Then `setsid` (new + // session and process group). Then the controlling tty, which needs a + // session leader. Then stdio ownership, while still root. Then gid, + // supplementary groups (what `initgroups` sets — resolved in the + // parent, see `UserInfo::groups`), and uid last, since dropping uid + // first would forfeit the right to change the other two. `chdir` comes + // after the switch so a directory the user cannot enter fails the way + // it does under runc, rather than succeeding as root. + unsafe { + cmd.pre_exec(move || { + reset_signals()?; + if libc::setsid() < 0 { + return Err(io::Error::last_os_error()); + } + if controlling_tty && libc::ioctl(0, libc::TIOCSCTTY, 0) < 0 { + return Err(io::Error::last_os_error()); + } + if switch { + fix_stdio_ownership(uid, dev_null)?; + if libc::setgid(gid) < 0 { + return Err(io::Error::last_os_error()); + } + if libc::setgroups(groups.len() as _, groups.as_ptr()) < 0 { + return Err(io::Error::last_os_error()); + } + if libc::setuid(uid) < 0 { + return Err(io::Error::last_os_error()); + } + } + if libc::chdir(cwd.as_ptr()) < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + // Never `kill_on_drop`: a dropped `docker exec` stream does not kill + // the process, and neither does losing our handle to it. + tokio::process::Command::from(cmd).spawn().map_err(|e| { + // ENOENT here means a relative or explicit path that was not + // there (PATH lookups were checked already): 127 like a shell. + // Anything else — EACCES, ENOEXEC, a failed setuid — is 126, + // "found but could not run". + let code = if e.kind() == io::ErrorKind::NotFound { 127 } else { 126 }; + PrepareError::cannot_start( + code, + format!( + "OCI runtime exec failed: exec failed: unable to start container process: exec: \"{}\": {e}: unknown", + self.argv[0] + ), + ) + }) + } +} + +/// Linux's `_NSIG`: signals are 1..=64. libc does not export it. +const NSIG: libc::c_int = 65; + +/// Every catchable signal to `SIG_DFL` and an empty signal mask — the state a +/// `docker exec` child starts in. For the forked child only. +/// +/// Raw `rt_sigaction`/`rt_sigprocmask` syscalls, not the libc wrappers: libc +/// refuses to touch the real-time signals it reserves for itself (glibc's 32 +/// and 33), and those can arrive ignored too — seen in practice, SigIgn bit +/// 32 set under a test harness. The syscalls also take no libc locks, which +/// matters between fork and exec. An all-zero kernel `struct sigaction` is +/// `SIG_DFL` with no flags, no restorer and an empty mask on every Linux +/// architecture, whatever its field order; its size is 4 words. +/// +/// SIGKILL and SIGSTOP cannot be changed and are skipped. Any failure fails +/// the exec rather than running a child in an unknown signal state. +unsafe fn reset_signals() -> io::Result<()> { + const SIGSET_BYTES: libc::size_t = 8; // the kernel's sigset_t: 64 bits + let dfl = [0u64; 4]; + for sig in 1..NSIG { + if sig == libc::SIGKILL || sig == libc::SIGSTOP { + continue; + } + let r = libc::syscall( + libc::SYS_rt_sigaction, + sig as libc::c_long, + dfl.as_ptr(), + std::ptr::null_mut::(), + SIGSET_BYTES, + ); + if r < 0 { + return Err(io::Error::last_os_error()); + } + } + let empty: u64 = 0; + let r = libc::syscall( + libc::SYS_rt_sigprocmask, + libc::SIG_SETMASK as libc::c_long, + &empty as *const u64, + std::ptr::null_mut::(), + SIGSET_BYTES, + ); + if r < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// The device number of `/dev/null`, read in the parent (`stat` is not +/// something to do between fork and exec when it can be done before). +/// Falls back to Linux's fixed 1:3. +fn dev_null_rdev() -> libc::dev_t { + use std::os::unix::fs::MetadataExt; + std::fs::metadata("/dev/null") + .map(|m| m.rdev() as libc::dev_t) + .unwrap_or_else(|_| libc::makedev(1, 3)) +} + +/// runc's `fixStdioPermissions`: give fds 0–2 to the exec's user, so it can +/// reopen them — `echo x > /dev/stderr`, `tee /dev/stdout`, and on a tty the +/// slave itself (gpg's pinentry, `screen`, `script`). Without it they stay +/// `root`'s: a pipe is `prw------- root` and a pty slave `root tty`. +/// +/// Like runc: only the uid changes (the gid is left to the mount), `/dev/null` +/// is skipped (chowning it would hand the device to the user), so is an fd +/// the user already owns, and `EINVAL`/`EPERM`/`EROFS` are ignored — an owner +/// outside our user namespace or a read-only mount is not worth failing the +/// exec over. For the forked child only: `fstat` and `fchown` are +/// async-signal-safe. +unsafe fn fix_stdio_ownership(uid: libc::uid_t, dev_null: libc::dev_t) -> io::Result<()> { + for fd in 0..=2 { + let mut st: libc::stat = std::mem::zeroed(); + if libc::fstat(fd, &mut st) < 0 { + return Err(io::Error::last_os_error()); + } + let is_dev_null = st.st_mode & libc::S_IFMT == libc::S_IFCHR && st.st_rdev == dev_null; + if st.st_uid == uid || is_dev_null { + continue; + } + if libc::fchown(fd, uid, libc::gid_t::MAX) < 0 { + let e = io::Error::last_os_error(); + if !matches!(e.raw_os_error(), Some(libc::EINVAL | libc::EPERM | libc::EROFS)) { + return Err(e); + } + } + } + Ok(()) +} + +/// Docker's exit code for a process: its status, or 128 + signal when a +/// signal killed it (what `docker inspect` and a shell both report). +pub fn exit_code(status: ExitStatus) -> i32 { + match (status.code(), status.signal()) { + (Some(c), _) => c, + (None, Some(s)) => 128 + s, + (None, None) => 255, + } +} + +/// Signal a whole exec: its pid is its process-group id (it called `setsid`). +pub fn signal_group(pid: Option, sig: libc::c_int) { + if let Some(pid) = pid.filter(|&p| p > 0) { + unsafe { + libc::kill(-(pid as libc::pid_t), sig); + } + } +} diff --git a/container/triple-c-agent/src/tunnel.rs b/container/triple-c-agent/src/tunnel.rs new file mode 100644 index 0000000..7c99ae6 --- /dev/null +++ b/container/triple-c-agent/src/tunnel.rs @@ -0,0 +1,89 @@ +//! `GET /v1/tunnel?port=`: a WebSocket piped to `127.0.0.1:` inside the +//! container — what the auth bridge and browser view do with `socat` through +//! the Docker API today. + +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::rejection::QueryRejection; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::Response; +use serde::Deserialize; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::{error, AppState, CountGuard, MAX_WS_MESSAGE}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Deserialize)] +pub struct TunnelQuery { + port: u16, +} + +pub async fn upgrade( + State(st): State>, + q: Result, QueryRejection>, + ws: WebSocketUpgrade, +) -> Response { + let port = match q { + Ok(Query(q)) if q.port != 0 => q.port, + Ok(_) => return error(StatusCode::BAD_REQUEST, "port must be 1-65535"), + Err(r) => return error(StatusCode::BAD_REQUEST, format!("invalid query: {}", r.body_text())), + }; + ws.max_message_size(MAX_WS_MESSAGE) + .max_frame_size(MAX_WS_MESSAGE) + .on_upgrade(move |socket| session(socket, st, port)) +} + +async fn session(mut socket: WebSocket, st: Arc, port: u16) { + // Loopback only, by construction: the tunnel reaches in-container + // listeners, never the network the container can see. + let tcp = match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(("127.0.0.1", port))).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => return fail(socket, format!("cannot connect to 127.0.0.1:{port}: {e}")).await, + Err(_) => return fail(socket, format!("timed out connecting to 127.0.0.1:{port}")).await, + }; + let _ = tcp.set_nodelay(true); + let _active = CountGuard::tunnels(&st); + let (mut rd, mut wr) = tcp.into_split(); + let mut buf = vec![0u8; 32 * 1024]; + // When either side ends, the other is closed (spec): TCP EOF sends a + // WebSocket close; a WebSocket close drops the TCP connection. + loop { + tokio::select! { + r = rd.read(&mut buf) => match r { + Ok(n) if n > 0 => { + st.activity.touch(); + if socket.send(Message::Binary(buf[..n].to_vec().into())).await.is_err() { + return; + } + } + _ => { + let _ = socket.send(Message::Close(None)).await; + return; + } + }, + m = socket.recv() => match m { + Some(Ok(Message::Binary(d))) => { + st.activity.touch(); + if wr.write_all(&d).await.is_err() { + let _ = socket.send(Message::Close(None)).await; + return; + } + } + Some(Ok(Message::Text(_) | Message::Ping(_) | Message::Pong(_))) => {} + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => return, + }, + } + } +} + +async fn fail(mut socket: WebSocket, msg: String) { + let _ = socket + .send(Message::Text(serde_json::json!({ "type": "error", "error": msg }).to_string().into())) + .await; + let _ = socket.send(Message::Close(None)).await; +} diff --git a/container/triple-c-agent/src/users.rs b/container/triple-c-agent/src/users.rs new file mode 100644 index 0000000..7418a26 --- /dev/null +++ b/container/triple-c-agent/src/users.rs @@ -0,0 +1,131 @@ +//! User resolution through `/etc/passwd` and `/etc/group`. +//! +//! The static musl build has no NSS, so `getpwnam_r` and `getgrouplist` read +//! the container's files directly — which is exactly the database `docker exec +//! --user` resolves against (runc parses `/etc/passwd` itself), so the two +//! cannot disagree about who a name is. + +use std::ffi::{CStr, CString}; + +#[derive(Debug, Clone)] +pub struct UserInfo { + pub name: String, + pub uid: libc::uid_t, + pub gid: libc::gid_t, + pub home: String, + /// Supplementary groups, primary included — what `initgroups` would set. + /// Resolved here, in the parent, because `pre_exec` runs between `fork` + /// and `exec` in a multi-threaded process where reading files and + /// allocating are not safe; the child only calls `setgroups` on this list. + pub groups: Vec, +} + +/// Resolve a user name (or, as Docker also allows, a numeric uid). +/// +/// A numeric uid with no passwd entry still resolves, with gid 0 and HOME `/`, +/// because that is what runc does for `--user 1234` in an image without that +/// user. +pub fn lookup(name: &str) -> Option { + let cname = CString::new(name).ok()?; + if let Some(u) = getpw(|pwd, buf, len, res| unsafe { + libc::getpwnam_r(cname.as_ptr(), pwd, buf, len, res) + }) { + return Some(u); + } + let uid: libc::uid_t = name.parse().ok()?; + if let Some(u) = getpw(|pwd, buf, len, res| unsafe { libc::getpwuid_r(uid, pwd, buf, len, res) }) { + return Some(u); + } + Some(UserInfo { + name: name.to_string(), + uid, + gid: 0, + home: "/".to_string(), + groups: vec![0], + }) +} + +/// The user the agent itself runs as. +pub fn current() -> Option { + let uid = unsafe { libc::geteuid() }; + getpw(|pwd, buf, len, res| unsafe { libc::getpwuid_r(uid, pwd, buf, len, res) }) +} + +fn getpw( + call: impl Fn(*mut libc::passwd, *mut libc::c_char, libc::size_t, *mut *mut libc::passwd) -> libc::c_int, +) -> Option { + let mut size = 1024usize; + loop { + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut buf = vec![0 as libc::c_char; size]; + let mut res: *mut libc::passwd = std::ptr::null_mut(); + let rc = call(&mut pwd, buf.as_mut_ptr(), buf.len(), &mut res); + if rc == libc::ERANGE && size < (1 << 20) { + size *= 4; + continue; + } + if rc != 0 || res.is_null() { + return None; + } + let name = unsafe { CStr::from_ptr(pwd.pw_name) }.to_string_lossy().into_owned(); + let home = unsafe { CStr::from_ptr(pwd.pw_dir) }.to_string_lossy().into_owned(); + let groups = group_list(&name, pwd.pw_gid); + return Some(UserInfo { + name, + uid: pwd.pw_uid, + gid: pwd.pw_gid, + home, + groups, + }); + } +} + +fn group_list(name: &str, gid: libc::gid_t) -> Vec { + let Ok(cname) = CString::new(name) else { + return vec![gid]; + }; + let mut n: libc::c_int = 32; + loop { + let mut groups = vec![0 as libc::gid_t; n as usize]; + let mut count = n; + let rc = unsafe { libc::getgrouplist(cname.as_ptr(), gid, groups.as_mut_ptr(), &mut count) }; + if rc >= 0 { + groups.truncate(count.max(0) as usize); + return groups; + } + // -1 means "too small" and `count` now holds the size needed (glibc + // and musl both). Guard against a libc that does not update it. + if count <= n { + if n >= 65536 { + return vec![gid]; + } + n *= 2; + } else { + n = count; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_the_current_user_by_name_and_uid() { + let me = current().expect("current user has a passwd entry"); + let by_name = lookup(&me.name).unwrap(); + assert_eq!(by_name.uid, me.uid); + assert!(by_name.groups.contains(&me.gid)); + let by_uid = lookup(&me.uid.to_string()).unwrap(); + assert_eq!(by_uid.name, me.name); + } + + #[test] + fn unknown_names_do_not_resolve() { + assert!(lookup("no-such-user-triple-c").is_none()); + assert!(lookup("bad\0name").is_none()); + // A bare uid nobody owns still resolves, as runc does. + let u = lookup("4000123").unwrap(); + assert_eq!((u.uid, u.gid, u.home.as_str()), (4000123, 0, "/")); + } +} diff --git a/container/triple-c-agent/tests/inherited_signals.rs b/container/triple-c-agent/tests/inherited_signals.rs new file mode 100644 index 0000000..e957a09 --- /dev/null +++ b/container/triple-c-agent/tests/inherited_signals.rs @@ -0,0 +1,167 @@ +//! Execs must start with default signal dispositions and an empty mask, no +//! matter what the agent itself inherited — the clean state a `docker exec` +//! child gets from runc. +//! +//! In production the entrypoint's supervisor runs the agent under +//! `trap '' HUP`, and an ignored disposition survives `exec`. These tests +//! reproduce that by ignoring SIGHUP (and SIGUSR1) in *this* test process, +//! which is where the in-process server spawns from. A disposition is +//! process-wide, which is why this is its own test binary rather than part of +//! `protocol.rs`. + +use std::net::SocketAddr; +use std::sync::Once; +use std::time::{Duration, Instant}; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message; +use triple_c_agent::{serve, Config}; + +const TOKEN: &str = "test-token-0123456789abcdef0123456789abcdef"; + +fn ignore_signals_like_the_supervisor() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| unsafe { + libc::signal(libc::SIGHUP, libc::SIG_IGN); + libc::signal(libc::SIGUSR1, libc::SIG_IGN); + }); +} + +/// A `/proc//status` mask field (`SigIgn`, `SigBlk`, …) as a bitmask. +fn mask(status: &str, field: &str) -> u64 { + let line = status + .lines() + .find(|l| l.starts_with(&format!("{field}:"))) + .unwrap_or_else(|| panic!("no {field} in status")); + u64::from_str_radix(line.split_whitespace().nth(1).unwrap(), 16).unwrap() +} + +fn bit(sig: libc::c_int) -> u64 { + 1 << (sig - 1) +} + +struct Agent { + addr: SocketAddr, + http: reqwest::Client, + _cwd: tempfile::TempDir, + user: String, +} + +async fn start() -> Agent { + ignore_signals_like_the_supervisor(); + // The precondition, checked rather than assumed. + let own = std::fs::read_to_string("/proc/self/status").unwrap(); + assert_ne!(mask(&own, "SigIgn") & bit(libc::SIGHUP), 0, "the test process must ignore SIGHUP"); + + let cwd = tempfile::tempdir().unwrap(); + let config = Config { + token: TOKEN.to_string(), + default_cwd: cwd.path().to_path_buf(), + base_env: vec![("PATH".into(), "/usr/local/bin:/usr/bin:/bin".into())], + max_file_bytes: 1024, + header_read_timeout: triple_c_agent::HEADER_READ_TIMEOUT, + max_connections: triple_c_agent::MAX_CONNECTIONS, + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(serve(listener, config)); + let user = String::from_utf8(std::process::Command::new("id").arg("-un").output().unwrap().stdout) + .unwrap() + .trim() + .to_string(); + Agent { addr, http: reqwest::Client::new(), _cwd: cwd, user } +} + +impl Agent { + async fn get(&self, path: &str) -> Value { + let r = self.http.get(format!("http://{}{path}", self.addr)).bearer_auth(TOKEN).send().await.unwrap(); + assert_eq!(r.status().as_u16(), 200); + r.json().await.unwrap() + } +} + +fn process_gone(pid: i32) -> bool { + match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Err(_) => true, + Ok(s) => s.rsplit(')').next().unwrap_or("").trim_start().starts_with('Z'), + } +} + +#[tokio::test] +async fn execs_start_with_no_ignored_or_blocked_signals() { + let a = start().await; + let r = a + .http + .post(format!("http://{}/v1/exec", a.addr)) + .bearer_auth(TOKEN) + .json(&json!({ "cmd": ["cat", "/proc/self/status"], "user": a.user })) + .send() + .await + .unwrap(); + assert_eq!(r.status().as_u16(), 200); + let v: Value = r.json().await.unwrap(); + assert_eq!(v["exit_code"], 0, "{v}"); + use base64::Engine; + let status: String = v["chunks"] + .as_array() + .unwrap() + .iter() + .filter(|c| c["s"] == "out") + .map(|c| { + let b = base64::engine::general_purpose::STANDARD.decode(c["d"].as_str().unwrap()).unwrap(); + String::from_utf8(b).unwrap() + }) + .collect(); + assert_eq!(mask(&status, "SigIgn"), 0, "inherited ignored signals:\n{status}"); + assert_eq!(mask(&status, "SigBlk"), 0, "inherited blocked signals:\n{status}"); +} + +#[tokio::test] +async fn a_dropped_tty_attach_still_hangs_up_when_the_agent_ignores_sighup() { + let a = start().await; + let mut req = format!("ws://{}/v1/attach", a.addr).into_client_request().unwrap(); + req.headers_mut().insert("Authorization", format!("Bearer {TOKEN}").parse().unwrap()); + let mut ws = tokio_tungstenite::connect_async(req).await.unwrap().0; + let spec = json!({ + "cmd": ["sh", "-c", "echo pid=$$; exec sleep 600"], + "user": a.user, "tty": true, "attach_stdin": true, + }); + ws.send(Message::Text(spec.to_string().into())).await.unwrap(); + + let mut out = String::new(); + let pid: i32 = loop { + match tokio::time::timeout(Duration::from_secs(10), ws.next()).await.expect("timed out") { + Some(Ok(Message::Text(_))) => {} // "started" + Some(Ok(Message::Binary(b))) => { + out.push_str(&String::from_utf8_lossy(&b[1..])); + if let Some(rest) = out.split("pid=").nth(1) { + if let Some(line) = rest.split_once(['\r', '\n']) { + break line.0.trim().parse().unwrap(); + } + } + } + other => panic!("unexpected {other:?}"), + } + }; + assert_eq!(a.get("/v1/activity").await["active_execs"], 1); + // Let `exec sleep` happen, so the process we watch is the sleep. + tokio::time::sleep(Duration::from_millis(200)).await; + drop(ws); + + let deadline = Instant::now() + Duration::from_secs(5); + while !process_gone(pid) { + assert!(Instant::now() < deadline, "sleep (pid {pid}) survived the client disconnect"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let v = a.get("/v1/activity").await; + if v["active_execs"] == 0 { + break; + } + assert!(Instant::now() < deadline, "active_execs never returned to 0: {v}"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} diff --git a/container/triple-c-agent/tests/protocol.rs b/container/triple-c-agent/tests/protocol.rs new file mode 100644 index 0000000..c640f5d --- /dev/null +++ b/container/triple-c-agent/tests/protocol.rs @@ -0,0 +1,1007 @@ +//! End-to-end tests of the wire protocol +//! (`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`): the real +//! server, in-process on `127.0.0.1:0`, driven over real HTTP and WebSocket, +//! running as the current (non-root) user. + +use std::ffi::OsString; +use std::net::SocketAddr; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use base64::Engine; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message; +use triple_c_agent::{serve, Config}; + +const TOKEN: &str = "test-token-0123456789abcdef0123456789abcdef"; + +type Ws = tokio_tungstenite::WebSocketStream>; + +struct Agent { + addr: SocketAddr, + http: reqwest::Client, + /// The agent's startup cwd — where `working_dir: null` runs. + cwd: tempfile::TempDir, + /// Scratch space for the test's own files. + tmp: tempfile::TempDir, + user: String, +} + +fn current_user() -> String { + let out = std::process::Command::new("id").arg("-un").output().unwrap(); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + +fn passwd_home(user: &str) -> String { + std::fs::read_to_string("/etc/passwd") + .unwrap() + .lines() + .map(|l| l.split(':').collect::>()) + .find(|f| f[0] == user) + .map(|f| f[5].to_string()) + .expect("current user is in /etc/passwd") +} + +fn is_root() -> bool { + unsafe { libc::geteuid() == 0 } +} + +async fn start() -> Agent { + start_with(|_| {}).await +} + +async fn start_with(tweak: impl FnOnce(&mut Config)) -> Agent { + let cwd = tempfile::tempdir().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let mut config = Config { + token: TOKEN.to_string(), + default_cwd: cwd.path().to_path_buf(), + base_env: vec![ + ("PATH".into(), "/usr/local/bin:/usr/bin:/bin".into()), + ("FOO".into(), "base".into()), + ("BASE_ONLY".into(), "base".into()), + ("GONE".into(), "present".into()), + // The agent runs as root with HOME=/root in production; passwd + // must win over the base env for the exec's user. + (OsString::from("HOME"), OsString::from("/agent-home-must-not-leak")), + ], + max_file_bytes: 1024, + header_read_timeout: triple_c_agent::HEADER_READ_TIMEOUT, + max_connections: triple_c_agent::MAX_CONNECTIONS, + }; + tweak(&mut config); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(serve(listener, config)); + Agent { addr, http: reqwest::Client::new(), cwd, tmp, user: current_user() } +} + +impl Agent { + fn url(&self, path: &str) -> String { + format!("http://{}{}", self.addr, path) + } + + async fn get(&self, path: &str) -> (u16, Value) { + let r = self.http.get(self.url(path)).bearer_auth(TOKEN).send().await.unwrap(); + let status = r.status().as_u16(); + (status, r.json().await.unwrap_or(Value::Null)) + } + + async fn post(&self, path: &str, body: Value) -> (u16, Value) { + let r = self.http.post(self.url(path)).bearer_auth(TOKEN).json(&body).send().await.unwrap(); + let status = r.status().as_u16(); + (status, r.json().await.unwrap_or(Value::Null)) + } + + /// One-shot as the current user, defaulting the rest of the spec. + async fn exec(&self, mut body: Value) -> (u16, Value) { + if body.get("user").is_none() { + body["user"] = json!(self.user); + } + self.post("/v1/exec", body).await + } + + async fn ws(&self, path: &str) -> Ws { + let mut req = format!("ws://{}{}", self.addr, path).into_client_request().unwrap(); + req.headers_mut() + .insert("Authorization", format!("Bearer {TOKEN}").parse().unwrap()); + tokio_tungstenite::connect_async(req).await.unwrap().0 + } + + /// Open an attach and return the socket and the exec id. + async fn attach(&self, mut spec: Value) -> (Ws, String) { + if spec.get("user").is_none() { + spec["user"] = json!(self.user); + } + let mut ws = self.ws("/v1/attach").await; + ws.send(Message::Text(spec.to_string().into())).await.unwrap(); + let started = next_text(&mut ws).await; + assert_eq!(started["type"], "started", "{started}"); + (ws, started["exec_id"].as_str().unwrap().to_string()) + } + + async fn wait_finished(&self, id: &str) -> Value { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let (s, v) = self.get(&format!("/v1/exec/{id}")).await; + assert_eq!(s, 200, "{v}"); + if v["running"] == false { + return v; + } + assert!(Instant::now() < deadline, "exec {id} never finished"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +async fn next_text(ws: &mut Ws) -> Value { + loop { + match tokio::time::timeout(Duration::from_secs(10), ws.next()).await.expect("timed out") { + Some(Ok(Message::Text(t))) => return serde_json::from_str(t.as_str()).unwrap(), + Some(Ok(Message::Ping(_) | Message::Pong(_))) => continue, + other => panic!("expected a text frame, got {other:?}"), + } + } +} + +/// Everything until the exit frame: (stdout bytes, stderr bytes, exit code). +async fn collect_until_exit(ws: &mut Ws) -> (Vec, Vec, i64) { + let (mut out, mut err) = (Vec::new(), Vec::new()); + loop { + match tokio::time::timeout(Duration::from_secs(10), ws.next()).await.expect("timed out") { + Some(Ok(Message::Binary(b))) => match b[0] { + 1 => out.extend_from_slice(&b[1..]), + 2 => err.extend_from_slice(&b[1..]), + c => panic!("unknown channel {c}"), + }, + Some(Ok(Message::Text(t))) => { + let v: Value = serde_json::from_str(t.as_str()).unwrap(); + assert_eq!(v["type"], "exit", "{v}"); + return (out, err, v["code"].as_i64().unwrap()); + } + other => panic!("unexpected {other:?}"), + } + } +} + +/// Read stdout frames until `needle` has appeared. +async fn read_until(ws: &mut Ws, acc: &mut Vec, needle: &str) { + while !String::from_utf8_lossy(acc).contains(needle) { + match tokio::time::timeout(Duration::from_secs(10), ws.next()).await.expect("timed out") { + Some(Ok(Message::Binary(b))) => { + assert_eq!(b[0], 1, "tty output must all be channel 1"); + acc.extend_from_slice(&b[1..]); + } + other => panic!("unexpected {other:?} waiting for {needle:?}"), + } + } +} + +/// (combined, stdout, stderr, [(stream, text)]) — what the client rebuilds. +fn decode(v: &Value) -> (String, String, String, Vec<(String, String)>) { + let b64 = base64::engine::general_purpose::STANDARD; + let (mut all, mut out, mut err, mut list) = (String::new(), String::new(), String::new(), vec![]); + for c in v["chunks"].as_array().unwrap() { + let s = c["s"].as_str().unwrap().to_string(); + let d = String::from_utf8(b64.decode(c["d"].as_str().unwrap()).unwrap()).unwrap(); + all.push_str(&d); + if s == "out" { out.push_str(&d) } else { err.push_str(&d) } + list.push((s, d)); + } + (all, out, err, list) +} + +fn sh(script: &str) -> Value { + json!(["sh", "-c", script]) +} + +fn process_gone(pid: i32) -> bool { + match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Err(_) => true, + // A zombie is dead; it is only waiting for whoever reaps orphans here. + Ok(s) => s.rsplit(')').next().unwrap_or("").trim_start().starts_with('Z'), + } +} + +// ── auth / health / errors ───────────────────────────────────────────────── + +#[tokio::test] +async fn missing_or_wrong_token_is_401_with_an_empty_body() { + let a = start().await; + for auth in [None, Some("Bearer nope"), Some(&*format!("Basic {TOKEN}")), Some(&*format!("Bearer {TOKEN}x"))] { + let mut req = a.http.get(a.url("/v1/health")); + if let Some(h) = auth { + req = req.header("Authorization", h); + } + let r = req.send().await.unwrap(); + assert_eq!(r.status().as_u16(), 401, "{auth:?}"); + assert!(r.bytes().await.unwrap().is_empty()); + } + // Unknown endpoints are behind the token too. + let r = a.http.get(a.url("/v1/nope")).send().await.unwrap(); + assert_eq!(r.status().as_u16(), 401); + // And so is a WebSocket upgrade. + let req = format!("ws://{}/v1/attach", a.addr).into_client_request().unwrap(); + assert!(tokio_tungstenite::connect_async(req).await.is_err()); +} + +#[tokio::test] +async fn health_reports_agent_version_and_protocol() { + let a = start().await; + let (s, v) = a.get("/v1/health").await; + assert_eq!(s, 200); + assert_eq!(v["agent"], "triple-c-agent"); + assert_eq!(v["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(v["protocol"], 1); +} + +#[tokio::test] +async fn bad_requests_are_400_with_a_json_error() { + let a = start().await; + let r = a + .http + .post(a.url("/v1/exec")) + .bearer_auth(TOKEN) + .header("content-type", "application/json") + .body("{not json") + .send() + .await + .unwrap(); + assert_eq!(r.status().as_u16(), 400); + assert_eq!(r.headers()["content-type"], "application/json"); + assert!(r.json::().await.unwrap()["error"].is_string()); + + let (s, v) = a.exec(json!({ "cmd": [] })).await; + assert_eq!(s, 400); + assert!(v["error"].as_str().unwrap().contains("non-empty")); + + // Valid JSON without a Content-Type header is still accepted: the spec + // says the body is JSON and says nothing about the header. + let r = a + .http + .post(a.url("/v1/exec")) + .bearer_auth(TOKEN) + .body(json!({ "cmd": ["true"], "user": a.user }).to_string()) + .send() + .await + .unwrap(); + assert_eq!(r.status().as_u16(), 200); +} + +// ── one-shot ─────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn oneshot_chunks_keep_arrival_order_across_streams() { + let a = start().await; + let (s, v) = a + .exec(json!({ "cmd": sh("printf a; sleep 0.2; printf b >&2; sleep 0.2; printf c; sleep 0.2; printf d >&2") })) + .await; + assert_eq!(s, 200, "{v}"); + let (all, out, err, list) = decode(&v); + assert_eq!(all, "abcd"); + assert_eq!(out, "ac"); + assert_eq!(err, "bd"); + let streams: Vec<_> = list.iter().map(|(s, _)| s.as_str()).collect(); + assert_eq!(streams, ["out", "err", "out", "err"]); + assert_eq!(v["exit_code"], 0); +} + +#[tokio::test] +async fn oneshot_exit_codes_match_docker() { + let a = start().await; + let (_, v) = a.exec(json!({ "cmd": sh("exit 3") })).await; + assert_eq!(v["exit_code"], 3); + // Killed by a signal: 128 + n, as Docker and a shell report it. + let (_, v) = a.exec(json!({ "cmd": sh("kill -KILL $$") })).await; + assert_eq!(v["exit_code"], 137); + let (_, v) = a.exec(json!({ "cmd": sh("kill -TERM $$") })).await; + assert_eq!(v["exit_code"], 143); +} + +#[tokio::test] +async fn a_command_that_cannot_start_is_a_finished_exec_not_an_api_error() { + let a = start().await; + let (s, v) = a.exec(json!({ "cmd": ["definitely-not-a-binary-xyz"] })).await; + assert_eq!(s, 200); + assert_eq!(v["exit_code"], 127); + let (_, _, err, list) = decode(&v); + assert_eq!(list.len(), 1); + assert!(err.contains("executable file not found in $PATH"), "{err}"); + + // Present but not executable: 126. + let f = a.tmp.path().join("not-exec"); + std::fs::write(&f, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).unwrap(); + let (_, v) = a.exec(json!({ "cmd": [f] })).await; + assert_eq!(v["exit_code"], 126); + + // An explicit path that does not exist: 127. + let (_, v) = a.exec(json!({ "cmd": [a.tmp.path().join("missing")] })).await; + assert_eq!(v["exit_code"], 127); + + // No such user: 126 with runc's text. + let (s, v) = a.exec(json!({ "cmd": ["true"], "user": "no-such-user-triple-c" })).await; + assert_eq!(s, 200); + assert_eq!(v["exit_code"], 126); + assert!(decode(&v).2.contains("unable to find user no-such-user-triple-c")); +} + +#[tokio::test] +async fn a_non_root_agent_refuses_other_users_with_403() { + if is_root() { + eprintln!("skipped: running as root"); + return; + } + let a = start().await; + let (s, v) = a.exec(json!({ "cmd": ["true"], "user": "root" })).await; + assert_eq!(s, 403, "{v}"); + assert!(v["error"].is_string()); + // The agent's own user, by name and by uid, works. + let (s, _) = a.exec(json!({ "cmd": ["true"] })).await; + assert_eq!(s, 200); + let uid = unsafe { libc::geteuid() }.to_string(); + let (s, v) = a.exec(json!({ "cmd": ["id", "-u"], "user": uid })).await; + assert_eq!(s, 200); + assert_eq!(decode(&v).1.trim(), uid); + // Omitting the user means the agent's own. + let (s, v) = a.post("/v1/exec", json!({ "cmd": ["id", "-un"] })).await; + assert_eq!(s, 200); + assert_eq!(decode(&v).1.trim(), a.user); +} + +#[tokio::test] +async fn env_merges_over_the_base_and_sets_only_home_from_passwd() { + let a = start().await; + let script = r#"printf '%s|%s|%s|%s|%s|%s|%s' "$FOO" "$BAR" "$BASE_ONLY" "$HOME" "${USER-unset}" "${LOGNAME-unset}" "${GONE-unset}""#; + let (_, v) = a + .exec(json!({ "cmd": sh(script), "env": ["FOO=spec", "BAR=1", "BAR=2", "GONE"] })) + .await; + let home = passwd_home(&a.user); + // Like `docker exec`: HOME from passwd, and no USER/LOGNAME invented. + assert_eq!(decode(&v).1, format!("spec|2|base|{home}|unset|unset|unset")); + + // The spec's own HOME/USER/LOGNAME win over passwd. + let (_, v) = a + .exec(json!({ "cmd": sh(r#"printf '%s|%s|%s' "$HOME" "$USER" "$LOGNAME""#), "env": ["HOME=/custom", "USER=u", "LOGNAME=l"] })) + .await; + assert_eq!(decode(&v).1, "/custom|u|l"); +} + +#[tokio::test] +async fn cmd0_resolves_through_the_merged_path() { + let a = start().await; + let bin = a.tmp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let tool = bin.join("mytool"); + std::fs::write(&tool, "#!/bin/sh\necho \"mytool ran as $0\"\n").unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let (_, v) = a.exec(json!({ "cmd": ["mytool"] })).await; + assert_eq!(v["exit_code"], 127, "not on the base PATH"); + let path = format!("PATH={}:/usr/bin:/bin", bin.display()); + let (_, v) = a.exec(json!({ "cmd": ["mytool"], "env": [path] })).await; + assert_eq!(v["exit_code"], 0, "{v}"); + assert!(decode(&v).1.starts_with("mytool ran as")); +} + +#[tokio::test] +async fn working_dir_null_is_the_agent_cwd_and_a_set_one_is_used() { + let a = start().await; + let (_, v) = a.exec(json!({ "cmd": ["pwd"], "working_dir": null })).await; + assert_eq!(Path::new(decode(&v).1.trim()), a.cwd.path().canonicalize().unwrap()); + let (_, v) = a.exec(json!({ "cmd": ["pwd"], "working_dir": a.tmp.path() })).await; + assert_eq!(Path::new(decode(&v).1.trim()), a.tmp.path().canonicalize().unwrap()); + let (s, v) = a.exec(json!({ "cmd": ["pwd"], "working_dir": "/no/such/dir" })).await; + assert_eq!(s, 200); + assert_eq!(v["exit_code"], 126); +} + +#[tokio::test] +async fn output_over_the_limit_is_413_and_kills_the_process_group() { + let a = start().await; + let pidfile = a.tmp.path().join("bg.pid"); + // A backgrounded sleeper in the same process group, then a flood. + let script = format!("sleep 30 & echo $! > {}; exec head -c 100000 /dev/zero", pidfile.display()); + let (s, v) = a.exec(json!({ "cmd": sh(&script), "limit": 1000 })).await; + assert_eq!(s, 413, "{v}"); + assert_eq!( + v["error"], + "OUTPUT_LIMIT: Command output exceeded 1000 bytes and was abandoned" + ); + assert!(v["error"].as_str().unwrap().starts_with("OUTPUT_LIMIT:")); + let pid: i32 = std::fs::read_to_string(&pidfile).unwrap().trim().parse().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !process_gone(pid) { + assert!(Instant::now() < deadline, "background sleeper {pid} survived the limit kill"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Exactly at the limit is still a success, as with `push_capped`. + let (s, v) = a.exec(json!({ "cmd": sh("printf 12345"), "limit": 5 })).await; + assert_eq!(s, 200); + assert_eq!(decode(&v).0, "12345"); + let (s, _) = a.exec(json!({ "cmd": sh("printf 123456"), "limit": 5 })).await; + assert_eq!(s, 413); +} + +#[tokio::test] +async fn oneshot_stdin_is_dev_null() { + let a = start().await; + let (_, v) = a.exec(json!({ "cmd": ["cat"] })).await; + assert_eq!(v["exit_code"], 0); + assert_eq!(decode(&v).0, ""); +} + +// ── detached / status / resize ───────────────────────────────────────────── + +#[tokio::test] +async fn detached_exec_can_be_polled_to_its_exit_code() { + let a = start().await; + let marker = a.tmp.path().join("ran"); + let script = format!("sleep 0.3; touch {}; exit 7", marker.display()); + let (s, v) = a.post("/v1/exec/detached", json!({ "cmd": sh(&script), "user": a.user })).await; + assert_eq!(s, 200); + let id = v["exec_id"].as_str().unwrap().to_string(); + let (_, v) = a.get(&format!("/v1/exec/{id}")).await; + assert_eq!(v, json!({ "running": true, "exit_code": null })); + let v = a.wait_finished(&id).await; + assert_eq!(v, json!({ "running": false, "exit_code": 7 })); + assert!(marker.exists()); + + // Not a tty: resize is a conflict. + let (s, _) = a.post(&format!("/v1/exec/{id}/resize"), json!({ "cols": 80, "rows": 24 })).await; + assert_eq!(s, 409); + + // A missing binary still yields an id that reports 127. + let (_, v) = a.post("/v1/exec/detached", json!({ "cmd": ["no-such-bin-xyz"], "user": a.user })).await; + let v = a.wait_finished(v["exec_id"].as_str().unwrap()).await; + assert_eq!(v["exit_code"], 127); +} + +#[tokio::test] +async fn unknown_exec_ids_are_404() { + let a = start().await; + let (s, _) = a.get("/v1/exec/0000").await; + assert_eq!(s, 404); + let (s, _) = a.post("/v1/exec/0000/resize", json!({ "cols": 1, "rows": 1 })).await; + assert_eq!(s, 404); +} + +// ── attach ───────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn attach_pipes_stdin_and_close_stdin_gives_eof_and_an_exit_frame() { + let a = start().await; + let (mut ws, id) = a.attach(json!({ "cmd": ["cat"], "attach_stdin": true })).await; + ws.send(Message::Binary(b"hello\n".to_vec().into())).await.unwrap(); + let mut acc = Vec::new(); + match ws.next().await.unwrap().unwrap() { + Message::Binary(b) => { + assert_eq!(b[0], 1); + acc.extend_from_slice(&b[1..]); + } + m => panic!("{m:?}"), + } + assert_eq!(acc, b"hello\n"); + ws.send(Message::Text(json!({ "type": "close_stdin" }).to_string().into())).await.unwrap(); + let (out, err, code) = collect_until_exit(&mut ws).await; + assert!(out.is_empty() && err.is_empty()); + assert_eq!(code, 0); + assert_eq!(a.wait_finished(&id).await, json!({ "running": false, "exit_code": 0 })); +} + +#[tokio::test] +async fn attach_without_tty_keeps_stderr_on_channel_2() { + let a = start().await; + let (mut ws, id) = a + .attach(json!({ "cmd": sh("echo out; echo oops >&2; exit 4"), "attach_stdin": false })) + .await; + let (out, err, code) = collect_until_exit(&mut ws).await; + assert_eq!(out, b"out\n"); + assert_eq!(err, b"oops\n"); + assert_eq!(code, 4); + assert_eq!(a.wait_finished(&id).await["exit_code"], 4); +} + +#[tokio::test] +async fn attach_without_stdin_gives_the_process_dev_null() { + let a = start().await; + let (mut ws, _) = a.attach(json!({ "cmd": ["cat"], "attach_stdin": false })).await; + // Ignored: stdin is /dev/null, so cat exits on its own. + let _ = ws.send(Message::Binary(b"ignored".to_vec().into())).await; + let (out, _, code) = collect_until_exit(&mut ws).await; + assert!(out.is_empty()); + assert_eq!(code, 0); +} + +#[tokio::test] +async fn attach_tty_puts_everything_on_channel_1_and_resize_reaches_stty() { + let a = start().await; + let (mut ws, id) = a + .attach(json!({ + "cmd": sh("stty size; echo to-stderr >&2; read x; stty size; echo \"term=$TERM\""), + "tty": true, "attach_stdin": true, "cols": 80, "rows": 24, + })) + .await; + let mut acc = Vec::new(); + read_until(&mut ws, &mut acc, "24 80").await; + read_until(&mut ws, &mut acc, "to-stderr").await; + let (s, _) = a.post(&format!("/v1/exec/{id}/resize"), json!({ "cols": 100, "rows": 40 })).await; + assert_eq!(s, 204); + ws.send(Message::Binary(b"\n".to_vec().into())).await.unwrap(); + let (out, err, code) = collect_until_exit(&mut ws).await; + acc.extend_from_slice(&out); + let text = String::from_utf8_lossy(&acc); + assert!(err.is_empty(), "a tty has no channel 2"); + assert!(text.contains("40 100"), "{text}"); + // Docker's default TERM for a tty exec when the env has none. + assert!(text.contains("term=xterm"), "{text}"); + assert_eq!(code, 0); + // Finished: no longer resizable. + let (s, _) = a.post(&format!("/v1/exec/{id}/resize"), json!({ "cols": 1, "rows": 1 })).await; + assert_eq!(s, 409); +} + +#[tokio::test] +async fn attach_reports_a_missing_binary_as_started_stderr_and_127() { + let a = start().await; + let (mut ws, id) = a.attach(json!({ "cmd": ["no-such-bin-xyz"], "attach_stdin": false })).await; + let (out, err, code) = collect_until_exit(&mut ws).await; + assert!(out.is_empty()); + assert!(String::from_utf8_lossy(&err).contains("executable file not found")); + assert_eq!(code, 127); + assert_eq!(a.wait_finished(&id).await["exit_code"], 127); +} + +#[tokio::test] +async fn attach_refusals_are_an_error_frame() { + let a = start().await; + let mut ws = a.ws("/v1/attach").await; + ws.send(Message::Text(json!({ "cmd": [] }).to_string().into())).await.unwrap(); + let v = next_text(&mut ws).await; + assert_eq!(v["type"], "error"); + if !is_root() { + let mut ws = a.ws("/v1/attach").await; + ws.send(Message::Text(json!({ "cmd": ["true"], "user": "root" }).to_string().into())).await.unwrap(); + assert_eq!(next_text(&mut ws).await["type"], "error"); + } +} + +#[tokio::test] +async fn a_dropped_non_tty_attach_closes_stdin_but_does_not_kill() { + let a = start().await; + let out = a.tmp.path().join("got"); + // cat sees EOF when the client goes away, then the shell carries on. + let script = format!("cat > {o}; sleep 0.3; echo after >> {o}", o = out.display()); + let (mut ws, id) = a.attach(json!({ "cmd": sh(&script), "attach_stdin": true })).await; + ws.send(Message::Binary(b"abc".to_vec().into())).await.unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + drop(ws); + let v = a.wait_finished(&id).await; + assert_eq!(v["exit_code"], 0); + assert_eq!(std::fs::read_to_string(&out).unwrap(), "abcafter\n"); +} + +#[tokio::test] +async fn a_dropped_tty_attach_hangs_up_the_process_group() { + let a = start().await; + let (mut ws, id) = a + .attach(json!({ "cmd": sh("echo ready; sleep 30"), "tty": true, "attach_stdin": true })) + .await; + let mut acc = Vec::new(); + read_until(&mut ws, &mut acc, "ready").await; + drop(ws); + let v = a.wait_finished(&id).await; + // SIGHUP: 128 + 1. + assert_eq!(v["exit_code"], 129); +} + +// ── files ────────────────────────────────────────────────────────────────── + +async fn put(a: &Agent, query: &[(&str, String)], body: Vec) -> (u16, Value) { + let r = a + .http + .put(a.url("/v1/files")) + .query(query) + .bearer_auth(TOKEN) + .body(body) + .send() + .await + .unwrap(); + let s = r.status().as_u16(); + (s, r.json().await.unwrap_or(Value::Null)) +} + +#[tokio::test] +async fn put_file_writes_content_and_mode_and_replaces_by_unlinking() { + let a = start().await; + let dir = a.tmp.path().to_str().unwrap().to_string(); + let target = a.tmp.path().join("f.txt"); + // An existing read-only file with a second hard link: replacement must + // unlink and create, leaving the other link's content alone. + std::fs::write(&target, "old").unwrap(); + std::fs::hard_link(&target, a.tmp.path().join("other-link")).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o400)).unwrap(); + + let (s, v) = put( + &a, + &[("dir", dir.clone()), ("name", "f.txt".into()), ("mode", "640".into())], + b"new content".to_vec(), + ) + .await; + assert_eq!(s, 200, "{v}"); + assert_eq!(v["path"], format!("{dir}/f.txt")); + assert_eq!(std::fs::read(&target).unwrap(), b"new content"); + assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o7777, 0o640); + assert_eq!(std::fs::read(a.tmp.path().join("other-link")).unwrap(), b"old"); + + // mode is exact, not filtered through the umask. + let (s, _) = put(&a, &[("dir", dir.clone()), ("name", "x".into()), ("mode", "777".into())], vec![]).await; + assert_eq!(s, 200); + assert_eq!(std::fs::metadata(a.tmp.path().join("x")).unwrap().permissions().mode() & 0o7777, 0o777); + + // Own uid/gid are accepted when not root. + let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + let (s, _) = put( + &a, + &[("dir", dir.clone()), ("name", "own".into()), ("uid", uid.to_string()), ("gid", gid.to_string())], + b"x".to_vec(), + ) + .await; + assert_eq!(s, 200); +} + +#[tokio::test] +async fn put_file_refusals() { + let a = start().await; + let dir = a.tmp.path().to_str().unwrap().to_string(); + for name in ["a/b", "..", ".", ""] { + let (s, _) = put(&a, &[("dir", dir.clone()), ("name", name.into())], b"x".to_vec()).await; + assert_eq!(s, 400, "name {name:?}"); + } + let (s, _) = put(&a, &[("dir", "relative".into()), ("name", "n".into())], vec![]).await; + assert_eq!(s, 400); + // The directory is not created. + let missing = a.tmp.path().join("missing"); + let (s, _) = put(&a, &[("dir", missing.to_str().unwrap().into()), ("name", "n".into())], vec![]).await; + assert_eq!(s, 404); + assert!(!missing.exists()); + // Over the ceiling (1 KiB in tests, MAX_DROP_BYTES in production): 413 + // and nothing left behind. + let (s, _) = put(&a, &[("dir", dir.clone()), ("name", "big".into())], vec![0u8; 1025]).await; + assert_eq!(s, 413); + assert!(!a.tmp.path().join("big").exists()); + let (s, _) = put(&a, &[("dir", dir.clone()), ("name", "fits".into())], vec![0u8; 1024]).await; + assert_eq!(s, 200); + if !is_root() { + let (s, _) = put(&a, &[("dir", dir.clone()), ("name", "o".into()), ("uid", "0".into())], vec![]).await; + assert_eq!(s, 403); + assert!(!a.tmp.path().join("o").exists()); + } +} + +// ── archive ──────────────────────────────────────────────────────────────── + +async fn archive(a: &Agent, path: &Path) -> (u16, Vec) { + let r = a + .http + .get(a.url("/v1/archive")) + .query(&[("path", path.to_str().unwrap())]) + .bearer_auth(TOKEN) + .send() + .await + .unwrap(); + let s = r.status().as_u16(); + if s == 200 { + assert_eq!(r.headers()["content-type"], "application/x-tar"); + } + (s, r.bytes().await.unwrap().to_vec()) +} + +fn entries(tar_bytes: &[u8]) -> Vec<(PathBuf, tar::EntryType, Vec)> { + let mut ar = tar::Archive::new(tar_bytes); + ar.entries() + .unwrap() + .map(|e| { + let mut e = e.unwrap(); + let p = e.path().unwrap().into_owned(); + let t = e.header().entry_type(); + let mut d = Vec::new(); + std::io::Read::read_to_end(&mut e, &mut d).unwrap(); + (p, t, d) + }) + .collect() +} + +#[tokio::test] +async fn archive_of_a_file_has_its_basename_as_the_only_member() { + let a = start().await; + let f = a.tmp.path().join("hello.txt"); + std::fs::write(&f, "hi there").unwrap(); + std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o640)).unwrap(); + let (s, body) = archive(&a, &f).await; + assert_eq!(s, 200); + let mut ar = tar::Archive::new(&body[..]); + let mut list = ar.entries().unwrap(); + let mut e = list.next().unwrap().unwrap(); + assert_eq!(e.path().unwrap(), Path::new("hello.txt")); + assert_eq!(e.header().mode().unwrap() & 0o7777, 0o640); + let mut d = String::new(); + std::io::Read::read_to_string(&mut e, &mut d).unwrap(); + assert_eq!(d, "hi there"); + drop(e); + assert!(list.next().is_none()); +} + +#[tokio::test] +async fn archive_of_a_directory_recurses_under_its_basename_and_keeps_links() { + let a = start().await; + let d = a.tmp.path().join("tree"); + std::fs::create_dir_all(d.join("sub")).unwrap(); + std::fs::write(d.join("a.txt"), "A").unwrap(); + std::fs::write(d.join("sub/b.txt"), "B").unwrap(); + std::os::unix::fs::symlink("/etc/passwd", d.join("link")).unwrap(); + // A trailing slash names the same directory. + let (s, body) = archive(&a, &PathBuf::from(format!("{}/", d.display()))).await; + assert_eq!(s, 200); + let list = entries(&body); + let names: Vec<_> = list.iter().map(|(p, _, _)| p.to_str().unwrap().trim_end_matches('/').to_string()).collect(); + for want in ["tree", "tree/a.txt", "tree/sub", "tree/sub/b.txt", "tree/link"] { + assert!(names.contains(&want.to_string()), "{want} missing from {names:?}"); + } + assert_eq!(list[0].0.to_str().unwrap().trim_end_matches('/'), "tree", "root member first"); + let (_, t, _) = list.iter().find(|(p, _, _)| p.ends_with("link")).unwrap(); + assert!(t.is_symlink(), "symlinks are archived, not followed"); + let (_, _, data) = list.iter().find(|(p, _, _)| p.ends_with("sub/b.txt")).unwrap(); + assert_eq!(data, b"B"); +} + +#[tokio::test] +async fn archive_of_a_missing_path_is_404() { + let a = start().await; + let (s, _) = archive(&a, &a.tmp.path().join("nope")).await; + assert_eq!(s, 404); +} + +// ── tunnel ───────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn tunnel_pipes_bytes_both_ways_and_closes_with_the_tcp_side() { + let a = start().await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + // Echo one message with a prefix, then hang up. + let (mut s, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 64]; + let n = s.read(&mut buf).await.unwrap(); + s.write_all(b"echo:").await.unwrap(); + s.write_all(&buf[..n]).await.unwrap(); + }); + let mut ws = a.ws(&format!("/v1/tunnel?port={port}")).await; + ws.send(Message::Binary(b"ping".to_vec().into())).await.unwrap(); + let mut got = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_secs(5), ws.next()).await.unwrap() { + Some(Ok(Message::Binary(b))) => got.extend_from_slice(&b), + Some(Ok(Message::Close(_))) | None => break, + other => panic!("{other:?}"), + } + } + assert_eq!(got, b"echo:ping"); +} + +#[tokio::test] +async fn tunnel_to_a_closed_port_sends_an_error_frame() { + let a = start().await; + // Bind and drop to get a port that is very likely closed. + let port = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + }; + let mut ws = a.ws(&format!("/v1/tunnel?port={port}")).await; + let v = next_text(&mut ws).await; + assert_eq!(v["type"], "error"); + assert!(v["error"].as_str().unwrap().contains(&port.to_string())); + let (s, _) = a.get("/v1/tunnel?port=nope").await; + assert_eq!(s, 400); +} + +// ── activity ─────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn activity_counts_execs_and_tunnels_and_ignores_its_own_probes() { + let a = start().await; + let (_, v0) = a.get("/v1/activity").await; + assert_eq!(v0["active_execs"], 0); + assert_eq!(v0["active_tunnels"], 0); + let t0 = v0["last_activity_unix"].as_i64().unwrap(); + + tokio::time::sleep(Duration::from_millis(1100)).await; + a.get("/v1/health").await; + a.get("/v1/activity").await; + let (_, v) = a.get("/v1/activity").await; + assert_eq!(v["last_activity_unix"].as_i64().unwrap(), t0, "health/activity are not activity"); + + let (_, d) = a.post("/v1/exec/detached", json!({ "cmd": ["sleep", "1"], "user": a.user })).await; + let (_, v) = a.get("/v1/activity").await; + assert!(v["last_activity_unix"].as_i64().unwrap() > t0); + assert_eq!(v["active_execs"], 1); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut s, _) = listener.accept().await.unwrap(); + let mut b = [0u8; 8]; + let _ = s.read(&mut b).await; + }); + let ws = a.ws(&format!("/v1/tunnel?port={port}")).await; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let (_, v) = a.get("/v1/activity").await; + if v["active_tunnels"] == 1 { + break; + } + assert!(Instant::now() < deadline, "tunnel never counted"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + drop(ws); + a.wait_finished(d["exec_id"].as_str().unwrap()).await; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let (_, v) = a.get("/v1/activity").await; + if v["active_tunnels"] == 0 && v["active_execs"] == 0 { + break; + } + assert!(Instant::now() < deadline, "counters never returned to zero: {v}"); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +// ── connection limits ────────────────────────────────────────────────────── + +/// Wait for the server to close `s`: `Ok(elapsed)` on EOF or reset, +/// `Err` if it is still open after `within`. +async fn closed_within(s: &mut tokio::net::TcpStream, within: Duration) -> Result { + let t0 = Instant::now(); + let mut buf = [0u8; 1024]; + loop { + match tokio::time::timeout(within.saturating_sub(t0.elapsed()), s.read(&mut buf)).await { + Err(_) => return Err(()), + Ok(Ok(0)) | Ok(Err(_)) => return Ok(t0.elapsed()), + // hyper may answer a timed-out head with a 408 before closing. + Ok(Ok(_)) => continue, + } + } +} + +#[tokio::test] +async fn a_slow_request_head_is_cut_off_by_the_header_timeout() { + let a = start_with(|c| c.header_read_timeout = Duration::from_secs(1)).await; + + // A partial request line, never finished: slowloris. + let mut partial = tokio::net::TcpStream::connect(a.addr).await.unwrap(); + partial.write_all(b"GET /v1/heal").await.unwrap(); + // A connection that sends nothing at all. + let mut silent = tokio::net::TcpStream::connect(a.addr).await.unwrap(); + // A head that trickles in a byte at a time, each well inside the timeout. + let mut trickle = tokio::net::TcpStream::connect(a.addr).await.unwrap(); + let trickler = tokio::spawn(async move { + for b in b"GET /v1/health HTTP/1.1\r\nHost: x\r\nX-Pad: aaaaaaaaaaaaaaaaaaaaaaaa".iter() { + if trickle.write_all(&[*b]).await.is_err() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + trickle + }); + + let e = closed_within(&mut partial, Duration::from_secs(15)).await.expect("partial head never closed"); + assert!(e < Duration::from_secs(5), "closed only after {e:?}"); + closed_within(&mut silent, Duration::from_secs(15)).await.expect("silent connection never closed"); + let mut trickle = trickler.await.unwrap(); + closed_within(&mut trickle, Duration::from_secs(15)).await.expect("trickling head never closed"); + + // The server is still serving, and a normal request is untouched. + let (s, v) = a.get("/v1/health").await; + assert_eq!(s, 200, "{v}"); +} + +#[tokio::test] +async fn connections_past_the_cap_wait_for_a_slot_rather_than_failing() { + let a = start_with(|c| { + c.max_connections = 2; + c.header_read_timeout = Duration::from_secs(1); + }) + .await; + // Fill both slots with connections that never send anything. + let _hog1 = tokio::net::TcpStream::connect(a.addr).await.unwrap(); + let _hog2 = tokio::net::TcpStream::connect(a.addr).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + // A real request queues until the header timeout frees a slot. + let t0 = Instant::now(); + let (s, _) = a.get("/v1/health").await; + assert_eq!(s, 200); + assert!(t0.elapsed() >= Duration::from_millis(500), "was not held back: {:?}", t0.elapsed()); +} + +// ── root only ────────────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "needs root: runs execs as nobody to check stdio is chowned to the exec user"] +async fn as_root_stdio_belongs_to_the_exec_user() { + assert!(is_root(), "run with sudo -E cargo test -- --ignored"); + let a = start().await; + + // One-shot: stdout/stderr are pipes; reopening them by path needs the + // user to own them, as runc arranges. + let (s, v) = a + .post( + "/v1/exec", + json!({ "cmd": sh("echo x > /dev/stdout && echo y > /dev/stderr && echo ok"), "user": "nobody" }), + ) + .await; + assert_eq!(s, 200, "{v}"); + let (_, out, err, _) = decode(&v); + assert_eq!(v["exit_code"], 0, "stdout={out:?} stderr={err:?}"); + assert_eq!(out, "x\nok\n"); + assert_eq!(err, "y\n"); + + // Non-tty attach: the same, over pipes the attach owns. + let (mut ws, _) = a + .attach(json!({ "cmd": sh("echo x > /dev/stdout && echo y > /dev/stderr"), "user": "nobody" })) + .await; + let (out, err, code) = collect_until_exit(&mut ws).await; + assert_eq!((code, out.as_slice(), err.as_slice()), (0, &b"x\n"[..], &b"y\n"[..])); + + // Tty attach: the slave is the user's, not `root tty`. + let (mut ws, _) = a + .attach(json!({ "cmd": sh("stat -c %U $(tty)"), "user": "nobody", "tty": true })) + .await; + let (out, _, code) = collect_until_exit(&mut ws).await; + assert_eq!(code, 0); + assert_eq!(String::from_utf8_lossy(&out).trim(), "nobody"); + + // /dev/null is never handed over. + let (s, v) = a.post("/v1/exec/detached", json!({ "cmd": ["true"], "user": "nobody" })).await; + assert_eq!(s, 200, "{v}"); + a.wait_finished(v["exec_id"].as_str().unwrap()).await; + use std::os::unix::fs::MetadataExt; + assert_eq!(std::fs::metadata("/dev/null").unwrap().uid(), 0); +} + +#[tokio::test] +#[ignore = "needs root: switches to another user (nobody) and chowns an upload"] +async fn as_root_execs_switch_user_and_uploads_chown() { + assert!(is_root(), "run with sudo -E cargo test -- --ignored"); + let a = start().await; + let (s, v) = a + .post("/v1/exec", json!({ "cmd": sh("id -u; id -g; echo $HOME; echo $USER"), "user": "nobody" })) + .await; + assert_eq!(s, 200, "{v}"); + let out = decode(&v).1; + let lines: Vec<_> = out.lines().collect(); + assert_eq!(lines[0], "65534"); + // runc sets only HOME for the exec's user; USER stays unset unless the + // container env has it (the base env here does not) — see `merge_env`. + assert_eq!(lines[3], ""); + assert_eq!(lines[2], passwd_home("nobody")); + + let dir = a.tmp.path().to_str().unwrap().to_string(); + let (s, _) = put( + &a, + &[("dir", dir), ("name", "owned".into()), ("uid", "65534".into()), ("gid", "65534".into()), ("mode", "4755".into())], + b"x".to_vec(), + ) + .await; + assert_eq!(s, 200); + let m = std::fs::metadata(a.tmp.path().join("owned")).unwrap(); + use std::os::unix::fs::MetadataExt; + assert_eq!((m.uid(), m.gid()), (65534, 65534)); + // Mode applied after chown, so the setuid bit survives. + assert_eq!(m.mode() & 0o7777, 0o4755); +} diff --git a/docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md b/docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md new file mode 100644 index 0000000..451aee8 --- /dev/null +++ b/docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md @@ -0,0 +1,146 @@ +# triple-c-agent — wire protocol (v1) + +Date: 2026-11-13 +Status: draft, phase 2 of `2026-11-13-aws-ecs-host-design.md` ("Connectivity") + +## Purpose + +`triple-c-agent` is a small static binary inside the project container. On AWS, the app reaches +it through one SSM port-forwarding session. Locally it can be reached through a published +loopback port, which is how it is tested. It does, over HTTP, everything `host::ContainerTarget` +does through the Docker API today: one-shot, attached and detached execs, resize, exit status, +file upload, archive download, and TCP tunnels to in-container loopback ports. + +**Docker parity is the requirement.** The same `ExecSpec` must behave the same through the agent +as through `docker exec`: same user, working directory, environment and output split. Where Docker +has a quirk the app relies on, the agent reproduces it, and this document names it. + +## Process + +- **Binary:** `/usr/local/bin/triple-c-agent`, built from `agent/` in the repo, static + (`*-unknown-linux-musl`), for amd64 and arm64. +- **Runs as root**, started by `entrypoint.sh` before the keepalive, so a `claude`-user + `pkill`/`kill -9 -1` can't kill it (the same rule as the keepalive; see CLAUDE.md). +- **Listens on** `TRIPLEC_AGENT_ADDR`, default `127.0.0.1:47900`. It never binds a non-loopback + address unless that address is given explicitly. The local test harness publishes the port to + the host's loopback. +- **Token:** `TRIPLEC_AGENT_TOKEN` (env) or the file at `TRIPLEC_AGENT_TOKEN_FILE`. If neither + gives a non-empty token of at least 32 bytes, **the agent refuses to start** — there is no + unauthenticated mode. After reading the token, the agent removes `TRIPLEC_AGENT_TOKEN` from its + own environment, so execs don't inherit it. +- **Base environment for execs:** the agent's own environment at startup (minus the token), which + is the container's configured env — the same thing `docker exec` inherits. +- **Default working directory:** the agent's cwd at startup (the container's `WorkingDir`), used + when a spec gives `working_dir: null`. This matches `docker exec` without `-w`. + +## Common rules + +- **Every request** needs `Authorization: Bearer `. A missing or wrong token gets `401` + with an empty body. The comparison is constant-time. +- **Errors** are `4xx`/`5xx` with body `{"error": ""}`, Content-Type `application/json`. +- **JSON** is UTF-8. Raw bytes inside JSON are standard base64 with padding. +- **Users** are names resolved through `/etc/passwd` and `/etc/group` in the container. An exec + runs with the user's uid, primary gid and supplementary groups (`initgroups`). `HOME` is set + from passwd **unless the spec's env already sets it**; `USER` and `LOGNAME` are *not* added + (runc sets only `HOME` for an exec, verified by the app's parity test). When the agent is + not root (tests), a spec naming a user other than the agent's own fails with `403`. +- **ExecSpec** (JSON): + ```json + {"cmd": ["sh","-c","…"], "user": "claude", "working_dir": "/workspace" | null, + "env": ["K=V", …], "tty": false} + ``` + `cmd` must be non-empty. `env` entries are `K=V`; later entries win, and they override the base + environment. `cmd[0]` is resolved through `PATH` from the **merged** environment. + +## Endpoints + +### `GET /v1/health` +`200 {"agent": "triple-c-agent", "version": "", "protocol": 1}`. + +### `POST /v1/exec` — one-shot +Body: an ExecSpec plus `"limit": `. stdin is `/dev/null`, there is no tty, and the agent +waits for exit. + +`200 {"chunks": [{"s": "out"|"err", "d": ""}, …], "exit_code": }` + +- `chunks` are in **arrival order**. The client rebuilds the combined text, stdout only, and + stderr only from them, which is exactly what `docker/exec.rs`'s `OneshotOutput` does. +- When the combined size would exceed `limit`, the agent kills the process group and returns + `413 {"error": "OUTPUT_LIMIT: Command output exceeded bytes and was abandoned"}`. The + client passes that message through unchanged, so `OUTPUT_LIMIT_MARKER` checks keep working. +- A process killed by a signal reports `exit_code` 128 + signal, as Docker does. +- If the command can't be started (no such binary or user), the response is `200` with + `exit_code` 126 or 127 and the error text in one `err` chunk. This mirrors what a shell and + Docker report. + +### `POST /v1/exec/detached` +Body: an ExecSpec. Starts the command with stdin, stdout and stderr all on `/dev/null`. +`200 {"exec_id": ""}`. + +### `GET /v1/exec/{id}` +`200 {"running": bool, "exit_code": | null}`. Ids come from `detached` and `attach`. The +agent keeps finished entries for at least 10 minutes. An unknown id gets `404`. + +### `POST /v1/exec/{id}/resize` +Body: `{"cols": u16, "rows": u16}`. `204`. Allowed only for tty execs; anything else gets `409`. + +### `GET /v1/attach` — WebSocket +1. The client sends one **text** frame: an ExecSpec plus `"attach_stdin": bool`, and for tty + execs optionally `"cols"` and `"rows"`. +2. The agent replies with **text** `{"type": "started", "exec_id": ""}`, or with + `{"type": "error", "error": "…"}` followed by a close. +3. **Agent → client binary frames:** byte 0 is the channel (`1` = stdout, `2` = stderr) and the + rest is payload. With `tty: true` the command runs on a pty and everything is channel `1` + (Docker's `Console` maps to stdout). +4. **Client → agent binary frames:** stdin bytes. These are ignored when `attach_stdin` is false; + in that case stdin is `/dev/null`. +5. **Client → agent text** `{"type": "close_stdin"}`: closes the child's stdin (EOF). Without a + tty, closing the WebSocket's write side has the same effect. +6. When the process exits and all output has been sent, the agent sends **text** + `{"type": "exit", "code": }` and closes. The exit code is also kept for + `GET /v1/exec/{id}`. +7. If the client goes away, the agent sends SIGHUP to the process group (tty) or closes stdin + (non-tty), as a dropped `docker exec` does. **It does not kill a non-tty process**: Docker + doesn't either, and `gh_login`/`setup-token` clean up with their own `pkill`. + +### `PUT /v1/files?dir=&name=&mode=&uid=&gid=` +The body is the raw file bytes, at most 256 MiB plus 1 (`MAX_DROP_BYTES`). Writes +`dir/name` with `mode`, owned by `uid:gid`, mtime now, replacing an existing file the way tar +extraction does (unlink, then create). `dir` must exist; it is not created. `name` must be a +single path component. `200 {"path": "/"}`. + +### `GET /v1/archive?path=` +`200 application/x-tar`, streamed: a tar of `path`, whose root member is `basename(path)`, with +recursion for directories. This matches Docker's `GET /containers/{id}/archive`. A missing path +gets `404`. Dropping the connection cancels the read. + +### `GET /v1/tunnel?port=` — WebSocket +The agent connects to `127.0.0.1:` **inside the container** and pipes binary frames both ways. +If the connection is refused, the agent sends text `{"type": "error", "error": "…"}` and closes. +When either side ends, the other is closed. + +### `GET /v1/activity` +`200 {"last_activity_unix": , "active_execs": , "active_tunnels": }`. +"Activity" means any request other than `health` and `activity`, or any byte through an attach or +tunnel. This is used by idle auto-stop later. + +## Client mapping (`host::ContainerTarget::Agent`) + +| ContainerTarget | Agent call | +|---|---| +| `exec_oneshot*` (all variants) | `POST /v1/exec`, with `limit` = the variant's limit (`MAX_ONESHOT_OUTPUT` or explicit) | +| `exec_detached` | `POST /v1/exec/detached` | +| `attach` / `attach_exec` | `GET /v1/attach`; `input` is an `AsyncWrite` whose shutdown sends `close_stdin` | +| `wait_exec_exit` | poll `GET /v1/exec/{id}` (same budget as the Docker version: 200 × 25 ms, shared constants in `docker/exec.rs`) | +| `resize_exec` | `POST /v1/exec/{id}/resize` | +| `user_ids` | one-shot `sh -c 'id -u; id -g'` as `claude`, falling back to `(1000, 1000)` | +| `upload_bytes*`, `upload_host_file*` | `PUT /v1/files`; a multi-component `name` (the terminal drop's `triple-c-drops/`) is split into `dir` + last component, `.`/`..` refused | +| `download_archive` | `GET /v1/archive` | +| `is_running` | `GET /v1/health` succeeds → `true`; a connection error → `false` | +| `has_exited` | `false` (only the host can say positively; an AWS host answers from ECS) | +| (future) tunnels | `GET /v1/tunnel` | + +## Not in v1 + +TLS (the transport is the SSM channel or loopback), multiplexing within one connection (the SSM +port-forward multiplexes TCP connections), filesystem watch, and auth rotation while running.