triple-c-agent: in-container agent, app client, and Docker-parity test (phase 2a) #76

Merged
jknapp merged 13 commits from feat/triple-c-agent into main 2026-11-13 22:29:38 +00:00
31 changed files with 7200 additions and 79 deletions

No files matched your search

+13
View File
@@ -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]
+1
View File
@@ -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/
+18 -1
View File
@@ -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
+2
View File
@@ -6642,6 +6642,8 @@ dependencies = [
"tauri-plugin-opener",
"tempfile",
"tokio",
"tokio-tungstenite",
"tokio-util",
"tower-http",
"url",
"uuid",
+7
View File
@@ -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"
+107 -76
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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<String> {
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::<u64>().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<u8>,
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<u8> = 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<i64>) -> Result<i64, String> {
/// spurious "the rename failed" and a real one.
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
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<i64> {
}
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));
}
+86
View File
@@ -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<bool, String> {
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<Vec<u8>, std::io::Error> {
let mut buf = Vec::new();
{
@@ -203,8 +243,14 @@ fn create_build_context() -> Result<Vec<u8>, 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<Vec<u8>, 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<String> = 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;
+915
View File
@@ -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:<port>/`: 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", &"<redacted>")
.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<String>) -> Result<Self, String> {
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:<port>`.
pub fn loopback(port: u16, token: impl Into<String>) -> 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<reqwest::Client> = 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<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Option<T> {
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<String> {
let mut e = small_json::<ErrorBody>(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)
}
/// `"<context>: <why>"` 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<reqwest::Response, String> {
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<Option<Vec<u8>>, 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<String, serde_json::Value> {
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<OneshotChunk>,
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<String>,
env: Vec<String>,
limit: usize,
) -> Result<OneshotOutput, String> {
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<String>,
env: Vec<String>,
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<String>,
env: Vec<String>,
) -> 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<String>,
env: Vec<String>,
) -> 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<String, String> {
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::<ExecIdResponse>(resp)
.await
.map(|r| r.exec_id)
.ok_or_else(|| format!("{}: malformed agent response", CONTEXT))
}
#[derive(Deserialize)]
struct ExecStatus {
running: bool,
exit_code: Option<i64>,
}
/// 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<ExitBook> {
static BOOK: OnceLock<Mutex<ExitBook>> = 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<i64> {
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<i64> {
// 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<i64> {
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<tokio::net::TcpStream>,
>;
#[derive(Deserialize)]
struct ControlFrame {
#[serde(rename = "type")]
kind: String,
exec_id: Option<String>,
code: Option<i64>,
error: Option<String>,
}
/// 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<Ws, String> {
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<AttachedExec, String> {
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::<StdinCmd>(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<String, String> {
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<StdinCmd>,
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<io::Result<usize>> {
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<io::Result<()>> {
Poll::Ready(Ok(()))
}
/// EOF on the child's stdin: `{"type":"close_stdin"}`, once.
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
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/<file>` 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<u8>,
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<String, String> {
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<String, String> {
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<bool, String> {
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;
+265
View File
@@ -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<String> {
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<T: PartialEq + std::fmt::Debug>(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::<Vec<_>>().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<u8>)> = 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}");
}
}
+673
View File
@@ -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<String>,
exec_bodies: Vec<Value>,
detached_bodies: Vec<Value>,
status_polls: u32,
resizes: Vec<(String, Value)>,
uploads: Vec<(HashMap<String, String>, Vec<u8>)>,
attach_hello: Option<Value>,
stdin: Vec<u8>,
close_stdin: bool,
}
struct Fake {
seen: std::sync::Mutex<Seen>,
}
type St = State<Arc<Fake>>;
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<Value>) -> 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<Value>) -> 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<String>) -> 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<String>, Json(body): Json<Value>) -> 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<HashMap<String, String>>,
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<HashMap<String, String>>) -> 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<Result<Bytes, std::io::Error>> = 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<Fake>, 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<Fake>) {
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<String> {
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}");
}
+9
View File
@@ -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")),
}
}
+44 -2
View File
@@ -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<Box<dyn Stream<Item = Result<Bytes, String>> + 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<i64> {
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),
}
}
}
+5
View File
@@ -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,
}
}
}
+2
View File
@@ -0,0 +1,2 @@
# Build output of the agent crate must never enter the image build context.
triple-c-agent/target
+32
View File
@@ -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"]
+79
View File
@@ -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() {
+1301
View File
File diff suppressed because it is too large. Load diff
+48
View File
@@ -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"
+112
View File
@@ -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/<agent pid>/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 <version> listening on <addr>`, 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 <binary>`. 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`.
+340
View File
@@ -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<u16>,
#[serde(default)]
rows: Option<u16>,
}
/// 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<Arc<AppState>>, 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<AppState>) {
// 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::<Event>(64);
let (in_tx, in_rx) = mpsc::unbounded_channel::<Input>();
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::<serde_json::Value>(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<AppState>, 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<Event>, 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<Input>,
tx: &mpsc::Sender<Event>,
) -> 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<i32> = 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<pty::PtyMaster>,
mut input: mpsc::UnboundedReceiver<Input>,
hangup: oneshot::Receiver<()>,
attach_stdin: bool,
tx: &mpsc::Sender<Event>,
) -> 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<i32> = 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);
}
}
}
}
}
+280
View File
@@ -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<i32>,
finished_at: Option<Instant>,
/// Present only for tty execs that are still running — what `resize`
/// needs. Dropped on exit so the terminal is released.
pub pty: Option<Arc<PtyMaster>>,
}
/// Exec ids → status, for `detached` and `attach` execs.
#[derive(Default)]
pub struct Registry {
map: Mutex<HashMap<String, ExecEntry>>,
}
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<Arc<PtyMaster>>) -> 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<R>(&self, id: &str, f: impl FnOnce(&ExecEntry) -> R) -> Option<R> {
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<T: serde::de::DeserializeOwned>(body: &[u8]) -> Result<T, String> {
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<usize>,
}
/// `POST /v1/exec`.
pub async fn oneshot(
State(st): State<Arc<AppState>>,
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<u8>)> = 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<u8>)], 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<Arc<AppState>>,
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<Arc<AppState>>, Path(id): Path<String>) -> 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<Arc<AppState>>,
Path(id): Path<String>,
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}")),
}
}
+265
View File
@@ -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<String>,
#[serde(default)]
uid: Option<u32>,
#[serde(default)]
gid: Option<u32>,
}
/// 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<Arc<AppState>>,
q: Result<Query<PutQuery>, 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<Query<ArchiveQuery>, 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::<io::Result<Bytes>>(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<io::Result<Bytes>>,
buf: Vec<u8>,
}
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<usize> {
self.buf.extend_from_slice(data);
if self.buf.len() >= CHUNK {
self.send()?;
}
Ok(data.len())
}
fn flush(&mut self) -> io::Result<()> {
self.send()
}
}
+440
View File
@@ -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/<pid>/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<Config> {
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<OsString>, file: Option<PathBuf>) -> Result<String, String> {
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<String, String> {
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<AppState>, fn(&Activity) -> &AtomicI64);
impl CountGuard {
pub fn execs(st: &Arc<AppState>) -> CountGuard {
st.activity.execs.fetch_add(1, Ordering::Relaxed);
CountGuard(st.clone(), |a| &a.execs)
}
pub fn tunnels(st: &Arc<AppState>) -> 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": "<message>"}` with a JSON content type.
pub(crate) fn error(status: StatusCode, msg: impl Into<String>) -> Response {
(status, Json(serde_json::json!({ "error": msg.into() }))).into_response()
}
/// Every route, behind the token gate.
fn router(state: Arc<AppState>) -> 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<Arc<AppState>>, 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<Arc<AppState>>) -> 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());
}
}
+68
View File
@@ -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/<agent pid>/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
}
}
})
}
+124
View File
@@ -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<OwnedFd>,
}
/// 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<usize> {
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(())
}
}
+424
View File
@@ -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<String>,
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default)]
pub env: Vec<String>,
#[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<String>,
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<Prepared, PrepareError> {
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<PathBuf> {
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<tokio::process::Child, PrepareError> {
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::<u64>(),
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::<u64>(),
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<u32>, sig: libc::c_int) {
if let Some(pid) = pid.filter(|&p| p > 0) {
unsafe {
libc::kill(-(pid as libc::pid_t), sig);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
//! `GET /v1/tunnel?port=`: a WebSocket piped to `127.0.0.1:<port>` 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<Arc<AppState>>,
q: Result<Query<TunnelQuery>, 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<AppState>, 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;
}
+131
View File
@@ -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<libc::gid_t>,
}
/// 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<UserInfo> {
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<UserInfo> {
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<UserInfo> {
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<libc::gid_t> {
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, "/"));
}
}
@@ -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/<pid>/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;
}
}
File diff suppressed because it is too large. Load diff
@@ -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 <token>`. A missing or wrong token gets `401`
with an empty body. The comparison is constant-time.
- **Errors** are `4xx`/`5xx` with body `{"error": "<message>"}`, 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": "<semver>", "protocol": 1}`.
### `POST /v1/exec` — one-shot
Body: an ExecSpec plus `"limit": <bytes>`. stdin is `/dev/null`, there is no tty, and the agent
waits for exit.
`200 {"chunks": [{"s": "out"|"err", "d": "<base64>"}, …], "exit_code": <int>}`
- `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 <limit> 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": "<id>"}`.
### `GET /v1/exec/{id}`
`200 {"running": bool, "exit_code": <int> | 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": "<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": <int>}` 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=<abs>&name=<name>&mode=<octal>&uid=<n>&gid=<n>`
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": "<dir>/<name>"}`.
### `GET /v1/archive?path=<abs>`
`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=<n>` — WebSocket
The agent connects to `127.0.0.1:<n>` **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": <int>, "active_execs": <int>, "active_tunnels": <int>}`.
"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/<file>`) 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.