Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2aa9c3be2 | ||
|
|
36a6c869c9 | ||
|
|
114f463bf5 | ||
|
|
2a46f34fca | ||
|
|
fe889a7f16 | ||
|
|
fffc6492f1 | ||
|
|
0f638c7c4e | ||
|
|
a068ce27bc | ||
|
|
5d03bea544 | ||
|
|
d8c5b501f3 | ||
|
|
ebaafd8cfc | ||
|
|
55ed2d17d0 | ||
|
|
48c60ef89c | ||
|
|
20a25fc518 | ||
|
|
086abd5713 | ||
|
|
7c004fed46 | ||
|
|
45e55b5f7c | ||
|
|
83d63e756d | ||
|
|
cc973c8291 |
No files matched your search
@@ -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,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/
|
||||
|
||||
@@ -283,14 +283,22 @@ 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. Its allowlist is Docker-only code (migration) plus lifecycle/`die` events,
|
||||
which phase 1b moves; shrink it, never grow it. Prefer `ContainerTarget::for_project` or a
|
||||
target a session already holds (`ExecSessionManager::get_target`) over
|
||||
`ContainerTarget::docker(id)`, which hard-codes the host.
|
||||
the build on it. `lifecycle.rs`'s `ProjectHost` owns create/start/stop/find/remove and
|
||||
discarding a project's persistent state; the project commands keep only host-neutral steps
|
||||
(validation, status, sessions, auth bridge, syncs). `watch.rs` gives `container_watch` its
|
||||
death reports and positive-exit check. `no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer`
|
||||
fails on lifecycle calls or `ContainerTarget::docker(id)` in non-test code outside `host/` and
|
||||
`docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session
|
||||
(`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception
|
||||
(`DOCKER_ONLY`); shrink it, never grow it.
|
||||
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
||||
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
||||
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
|
||||
@@ -330,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
|
||||
|
||||
Generated
+2
@@ -6642,6 +6642,8 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -177,7 +177,7 @@ impl AuthBridgeManager {
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) {
|
||||
@@ -199,12 +199,12 @@ impl AuthBridgeManager {
|
||||
log::info!(
|
||||
"Auth bridge: starting for project {} (container {})",
|
||||
project_id,
|
||||
&container_id[..container_id.len().min(12)]
|
||||
&container.id()[..container.id().len().min(12)]
|
||||
);
|
||||
|
||||
let poller = tokio::spawn(poll_loop(
|
||||
project_id.clone(),
|
||||
ContainerTarget::docker(container_id),
|
||||
container,
|
||||
epoch,
|
||||
app,
|
||||
store,
|
||||
|
||||
@@ -322,7 +322,7 @@ async fn tunnel_connection(
|
||||
}
|
||||
|
||||
// The bytes already off the socket go back on the wire first, byte-exact.
|
||||
tunnel_into_with_prelude(container, target, stream, port, head.into_buffer()).await
|
||||
tunnel_connection_with_prelude(container, target, stream, port, head.into_buffer()).await
|
||||
}
|
||||
|
||||
/// What the first bytes of an accepted connection turned out to be.
|
||||
@@ -514,29 +514,7 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||
/// head off the socket to check a token, and then has to put those same bytes
|
||||
/// back on the wire. Passing them here keeps the byte stream exact, rather than
|
||||
/// re-serialising a parsed request.
|
||||
///
|
||||
/// Takes a Docker container id because that is what the browser-view proxy
|
||||
/// holds; [`tunnel_into_with_prelude`] is the same thing for a
|
||||
/// [`ContainerTarget`] already in hand.
|
||||
pub async fn tunnel_connection_with_prelude(
|
||||
container_id: String,
|
||||
target: String,
|
||||
stream: TcpStream,
|
||||
port: u16,
|
||||
prelude: Vec<u8>,
|
||||
) {
|
||||
tunnel_into_with_prelude(
|
||||
ContainerTarget::docker(container_id),
|
||||
target,
|
||||
stream,
|
||||
port,
|
||||
prelude,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`tunnel_connection_with_prelude`] for a [`ContainerTarget`].
|
||||
pub async fn tunnel_into_with_prelude(
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
stream: TcpStream,
|
||||
|
||||
@@ -6,6 +6,7 @@ use tauri::{AppHandle, State};
|
||||
|
||||
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the pane on or off for a project.
|
||||
@@ -57,12 +58,12 @@ pub async fn set_browser_view_enabled(
|
||||
return Ok(manager().status(&project_id, false).await);
|
||||
}
|
||||
|
||||
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
|
||||
let container = running_container(&state, &project_id, "opening the browser view").await?;
|
||||
|
||||
manager()
|
||||
.start(
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
app_handle,
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -106,8 +107,8 @@ pub async fn check_browser_view_support(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
||||
let container_id = running_container(&state, &project_id, "checking for Playwright").await?;
|
||||
crate::browser_view::detect::detect(&container_id).await
|
||||
let container = running_container(&state, &project_id, "checking for Playwright").await?;
|
||||
crate::browser_view::detect::detect(&container).await
|
||||
}
|
||||
|
||||
/// Install `playwright` and `@playwright/cli` into the container.
|
||||
@@ -125,8 +126,8 @@ pub async fn install_browser_view_support(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let container_id = running_container(&state, &project_id, "installing Playwright").await?;
|
||||
install::install_packages(&app_handle, &project_id, &container_id).await
|
||||
let container = running_container(&state, &project_id, "installing Playwright").await?;
|
||||
install::install_packages(&app_handle, &project_id, &container).await
|
||||
}
|
||||
|
||||
/// Install a browser — `chromium` (Playwright's own build, for scripts that
|
||||
@@ -143,8 +144,8 @@ pub async fn install_browser_view_browser(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let target = install::BrowserTarget::parse(&browser)?;
|
||||
let container_id = running_container(&state, &project_id, "installing a browser").await?;
|
||||
install::install_browser(&app_handle, &project_id, &container_id, target).await
|
||||
let container = running_container(&state, &project_id, "installing a browser").await?;
|
||||
install::install_browser(&app_handle, &project_id, &container, target).await
|
||||
}
|
||||
|
||||
/// Detach the view into a window of its own, or raise the one already open.
|
||||
@@ -240,17 +241,17 @@ pub async fn open_page_in_container_browser(
|
||||
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||
return Err("Only http:// and https:// URLs can be opened in the browser.".to_string());
|
||||
}
|
||||
let container_id = running_container(&state, &project_id, "opening a page").await?;
|
||||
let container = running_container(&state, &project_id, "opening a page").await?;
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"Checking the container for Playwright…",
|
||||
);
|
||||
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
||||
let detection = crate::browser_view::detect::detect(&container).await?;
|
||||
let opened = page::open(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
&container_id,
|
||||
&container,
|
||||
&detection,
|
||||
trimmed,
|
||||
page::Viewport::sane(width, height),
|
||||
@@ -273,7 +274,7 @@ pub async fn open_page_in_container_browser(
|
||||
manager()
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
container,
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -309,8 +310,8 @@ pub async fn set_container_page_viewport(
|
||||
height: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = running_container(&state, &project_id, "resizing the page").await?;
|
||||
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||
let container = running_container(&state, &project_id, "resizing the page").await?;
|
||||
page::set_viewport(&container, page::Viewport::sane(width, height)).await
|
||||
}
|
||||
|
||||
/// State of the page this opened, if any. Never fails: "no page" is an answer.
|
||||
@@ -319,10 +320,10 @@ pub async fn get_container_page_state(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<page::PageState, String> {
|
||||
let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else {
|
||||
let Ok(container) = running_container(&state, &project_id, "reading the page").await else {
|
||||
return Ok(page::PageState::default());
|
||||
};
|
||||
Ok(page::state(&container_id).await)
|
||||
Ok(page::state(&container).await)
|
||||
}
|
||||
|
||||
/// Close the page this opened, leaving the view itself running.
|
||||
@@ -331,8 +332,8 @@ pub async fn close_container_page(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = running_container(&state, &project_id, "closing the page").await?;
|
||||
page::close(&container_id).await;
|
||||
let container = running_container(&state, &project_id, "closing the page").await?;
|
||||
page::close(&container).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -356,8 +357,8 @@ pub async fn set_browser_view_match_window(
|
||||
let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let container_id = running_container(&state, &project_id, "matching the window").await?;
|
||||
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||
let container = running_container(&state, &project_id, "matching the window").await?;
|
||||
page::set_viewport(&container, page::Viewport::sane(width, height)).await
|
||||
}
|
||||
|
||||
/// Whether match-window mode is on. Read on mount, like the rest of the
|
||||
@@ -391,28 +392,25 @@ async fn running_container(
|
||||
state: &State<'_, AppState>,
|
||||
project_id: &str,
|
||||
action: &str,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let Some(container_id) = project.container_id.clone() else {
|
||||
let Some(container) = ContainerTarget::for_project(&project) else {
|
||||
return Err(format!(
|
||||
"This project has no container yet. Start it before {}.",
|
||||
action
|
||||
));
|
||||
};
|
||||
if !crate::docker::container::is_container_running(&container_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if !container.is_running().await.unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"The container for “{}” isn't running. Start it before {}.",
|
||||
project.name, action
|
||||
));
|
||||
}
|
||||
Ok(container_id)
|
||||
Ok(container)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -277,12 +277,7 @@ impl PlaywrightDetection {
|
||||
/// Playwright installed with `npm i -g`, or merely *run* once through
|
||||
/// `npx @playwright/mcp`, is found as readily as one in
|
||||
/// `/workspace/node_modules`.
|
||||
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
|
||||
detect_in(&ContainerTarget::docker(container_id)).await
|
||||
}
|
||||
|
||||
/// [`detect`] against a target already in hand.
|
||||
pub async fn detect_in(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
|
||||
pub async fn detect(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
|
||||
let output = container
|
||||
.exec_oneshot(vec![
|
||||
"node".to_string(),
|
||||
|
||||
@@ -238,16 +238,15 @@ pub struct BrowserSetupOutcome {
|
||||
pub async fn install_packages(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let container = ContainerTarget::docker(container_id);
|
||||
emit_progress(
|
||||
app,
|
||||
project_id,
|
||||
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||
);
|
||||
|
||||
let mut step = npm_install(app, project_id, &container, &[VIEWER_PACKAGE]).await?;
|
||||
let mut step = npm_install(app, project_id, container, &[VIEWER_PACKAGE]).await?;
|
||||
if step.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -264,9 +263,9 @@ pub async fn install_packages(
|
||||
// see the note on `npm_install`. The pin can only be read after the first
|
||||
// install has written the manifest, which is why this stays two commands
|
||||
// rather than one.
|
||||
let spec = pinned_playwright_spec(&container).await;
|
||||
let spec = pinned_playwright_spec(container).await;
|
||||
emit_progress(app, project_id, &format!("Installing {}…", spec));
|
||||
let second = npm_install(app, project_id, &container, &[VIEWER_PACKAGE, &spec]).await?;
|
||||
let second = npm_install(app, project_id, container, &[VIEWER_PACKAGE, &spec]).await?;
|
||||
if second.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -278,7 +277,7 @@ pub async fn install_packages(
|
||||
step.log = merge_logs(step.log, second.log);
|
||||
|
||||
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||
let detection = detect::detect_in(&container).await?;
|
||||
let detection = detect::detect(container).await?;
|
||||
|
||||
// A probe that still finds something missing after a successful npm run is
|
||||
// the interesting case, so it is surfaced rather than swallowed. And a
|
||||
@@ -410,11 +409,10 @@ fn merge_logs(first: String, second: String) -> String {
|
||||
pub async fn install_browser(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
target: BrowserTarget,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let container = ContainerTarget::docker(container_id);
|
||||
let before = detect::detect_in(&container).await?;
|
||||
let before = detect::detect(container).await?;
|
||||
let Some(cli) = before.playwright_cli.clone() else {
|
||||
return Err(
|
||||
"Playwright isn't installed in this container yet, so there is nothing to install a \
|
||||
@@ -458,7 +456,7 @@ pub async fn install_browser(
|
||||
project_id,
|
||||
"Step 1/3 — checking whether this container already has the browser system libraries…",
|
||||
);
|
||||
let state = check_libraries(&container, &cli, target).await;
|
||||
let state = check_libraries(container, &cli, target).await;
|
||||
match &state {
|
||||
LibraryState::Present => {
|
||||
emit_progress(
|
||||
@@ -482,7 +480,7 @@ pub async fn install_browser(
|
||||
let deps = run_step(
|
||||
app,
|
||||
project_id,
|
||||
&container,
|
||||
container,
|
||||
"root",
|
||||
"/tmp",
|
||||
vec![
|
||||
@@ -530,7 +528,7 @@ pub async fn install_browser(
|
||||
let dl = run_step(
|
||||
app,
|
||||
project_id,
|
||||
&container,
|
||||
container,
|
||||
user,
|
||||
workdir,
|
||||
vec![
|
||||
@@ -559,7 +557,7 @@ pub async fn install_browser(
|
||||
project_id,
|
||||
&format!("Step 3/3 — checking that {} actually launches…", target.cli_name()),
|
||||
);
|
||||
let verdict = verify_launch(&container, &before, target).await;
|
||||
let verdict = verify_launch(container, &before, target).await;
|
||||
push_section(&mut log, &verdict.detail);
|
||||
if !verdict.ok {
|
||||
warning = merge(
|
||||
@@ -590,7 +588,7 @@ pub async fn install_browser(
|
||||
}
|
||||
|
||||
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||
let detection = detect::detect_in(&container).await?;
|
||||
let detection = detect::detect(container).await?;
|
||||
|
||||
Ok(BrowserSetupOutcome {
|
||||
detection,
|
||||
|
||||
@@ -281,7 +281,7 @@ impl BrowserViewManager {
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
@@ -299,8 +299,7 @@ impl BrowserViewManager {
|
||||
return Ok(self.status(&project_id, true).await);
|
||||
}
|
||||
|
||||
let container = ContainerTarget::docker(container_id.as_str());
|
||||
let detection = detect::detect_in(&container).await?;
|
||||
let detection = detect::detect(&container).await?;
|
||||
if !detection.is_usable() {
|
||||
let blocker = detection.blocker().unwrap_or_else(|| {
|
||||
"Playwright is present but incomplete in this container.".to_string()
|
||||
@@ -328,7 +327,7 @@ impl BrowserViewManager {
|
||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||
// no need to go back to /proc/net to work it out.
|
||||
let proxy = match BrowserViewProxy::bind(
|
||||
container_id.clone(),
|
||||
container.clone(),
|
||||
container_port,
|
||||
PortFamily::V4,
|
||||
token.clone(),
|
||||
|
||||
@@ -91,7 +91,7 @@ pub struct PageState {
|
||||
pub async fn open(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
detection: &PlaywrightDetection,
|
||||
url: &str,
|
||||
viewport: Viewport,
|
||||
@@ -115,17 +115,17 @@ pub async fn open(
|
||||
// Reuse a helper that is already up. Relaunching would throw away the
|
||||
// browser's cookies and storage — which for the auth case means signing in
|
||||
// again to reach the second page, having just signed in on the first.
|
||||
if state(container_id).await.ready {
|
||||
if state(container).await.ready {
|
||||
emit_progress(app, project_id, "Navigating the container's browser…");
|
||||
set_viewport(container_id, viewport).await?;
|
||||
navigate(container_id, url).await?;
|
||||
if let Some(state) = wait_for_url(container_id, url).await {
|
||||
set_viewport(container, viewport).await?;
|
||||
navigate(container, url).await?;
|
||||
if let Some(state) = wait_for_url(container, url).await {
|
||||
return Ok(state);
|
||||
}
|
||||
// It stopped answering; fall through and start a fresh one.
|
||||
}
|
||||
|
||||
close(container_id).await;
|
||||
close(container).await;
|
||||
// Cold start: a browser launch plus a page load, which is the several
|
||||
// seconds the user would otherwise spend wondering whether the click
|
||||
// registered.
|
||||
@@ -150,7 +150,7 @@ pub async fn open(
|
||||
shell_quote(&script),
|
||||
HELPER_LOG
|
||||
);
|
||||
ContainerTarget::docker(container_id)
|
||||
container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||
@@ -160,36 +160,36 @@ pub async fn open(
|
||||
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||
|
||||
emit_progress(app, project_id, "Waiting for the page to load…");
|
||||
wait_until_ready(container_id).await
|
||||
wait_until_ready(container).await
|
||||
}
|
||||
|
||||
/// Resize the open page. Cheap enough to call from a window-resize handler.
|
||||
pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> {
|
||||
pub async fn set_viewport(container: &ContainerTarget, viewport: Viewport) -> Result<(), String> {
|
||||
write_control(
|
||||
container_id,
|
||||
container,
|
||||
serde_json::json!({ "viewport": viewport }).to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Navigate the open page without relaunching the browser.
|
||||
pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> {
|
||||
write_control(container_id, serde_json::json!({ "url": url }).to_string()).await
|
||||
pub async fn navigate(container: &ContainerTarget, url: &str) -> Result<(), String> {
|
||||
write_control(container, serde_json::json!({ "url": url }).to_string()).await
|
||||
}
|
||||
|
||||
/// Ask the helper to shut down. Best effort: a container that has none is the
|
||||
/// normal case, and the caller is usually about to start one anyway.
|
||||
pub async fn close(container_id: &str) {
|
||||
let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await;
|
||||
pub async fn close(container: &ContainerTarget) {
|
||||
let _ = write_control(container, serde_json::json!({ "close": true }).to_string()).await;
|
||||
}
|
||||
|
||||
/// Current state, or a default when no helper has ever run here.
|
||||
pub async fn state(container_id: &str) -> PageState {
|
||||
pub async fn state(container: &ContainerTarget) -> PageState {
|
||||
let script = format!(
|
||||
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
|
||||
STATE_PATH
|
||||
);
|
||||
let Ok((out, _)) = ContainerTarget::docker(container_id)
|
||||
let Ok((out, _)) = container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
@@ -204,12 +204,12 @@ pub async fn state(container_id: &str) -> PageState {
|
||||
|
||||
/// Write the control file through Node rather than a shell redirect, so a URL
|
||||
/// is never interpreted by `sh`.
|
||||
async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||
async fn write_control(container: &ContainerTarget, json: String) -> Result<(), String> {
|
||||
let script = format!(
|
||||
"require('fs').writeFileSync('{}',process.argv[1]);",
|
||||
CONTROL_PATH
|
||||
);
|
||||
ContainerTarget::docker(container_id)
|
||||
container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||
@@ -225,10 +225,10 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||
/// Bounded much tighter than a cold start: the browser is already up, so this
|
||||
/// is one navigation. `None` means it stopped answering, and the caller starts
|
||||
/// a fresh helper rather than reporting a page that isn't there.
|
||||
async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||
async fn wait_for_url(container: &ContainerTarget, url: &str) -> Option<PageState> {
|
||||
let deadline = std::time::Instant::now() + REUSE_TIMEOUT;
|
||||
loop {
|
||||
let state = state(container_id).await;
|
||||
let state = state(container).await;
|
||||
if state.ready && state.url.as_deref() == Some(url) {
|
||||
return Some(state);
|
||||
}
|
||||
@@ -240,10 +240,10 @@ async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||
}
|
||||
|
||||
/// Poll the state file until the helper says the page is up, or says why not.
|
||||
async fn wait_until_ready(container_id: &str) -> Result<PageState, String> {
|
||||
async fn wait_until_ready(container: &ContainerTarget) -> Result<PageState, String> {
|
||||
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let state = state(container_id).await;
|
||||
let state = state(container).await;
|
||||
if let Some(error) = state.error.clone() {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
@@ -255,15 +255,15 @@ fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) {
|
||||
}
|
||||
|
||||
let state = app.state::<crate::AppState>();
|
||||
let Some(container_id) = state
|
||||
let Some(container) = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.and_then(|p| p.container_id)
|
||||
.and_then(|p| crate::host::ContainerTarget::for_project(&p))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = super::page::set_viewport(
|
||||
&container_id,
|
||||
&container,
|
||||
super::page::Viewport::sane(width, height),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -62,6 +62,7 @@ use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
use crate::auth_bridge::proc_net::PortFamily;
|
||||
use crate::auth_bridge::tunnel::tunnel_connection_with_prelude;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
/// Host loopback ports the pane may be served on, and therefore the exact set of
|
||||
/// origins enumerated in the app's `frame-src`. Keep the two in sync: adding a
|
||||
@@ -103,9 +104,9 @@ impl Drop for BrowserViewProxy {
|
||||
|
||||
impl BrowserViewProxy {
|
||||
/// Take the first free port in [`PROXY_PORTS`] on the host loopback and
|
||||
/// start gating connections into `container_id`'s `container_port`.
|
||||
/// start gating connections into `container`'s `container_port`.
|
||||
pub async fn bind(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
container_port: u16,
|
||||
family: PortFamily,
|
||||
token: String,
|
||||
@@ -121,7 +122,7 @@ impl BrowserViewProxy {
|
||||
Ok(listener) => {
|
||||
let task = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
container_id,
|
||||
container,
|
||||
family.socat_target(container_port),
|
||||
container_port,
|
||||
token,
|
||||
@@ -172,7 +173,7 @@ fn host_authorities(port: u16) -> Vec<String> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
container_port: u16,
|
||||
token: String,
|
||||
@@ -195,7 +196,7 @@ async fn accept_loop(
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(serve_connection(
|
||||
stream,
|
||||
container_id.clone(),
|
||||
container.clone(),
|
||||
target.clone(),
|
||||
container_port,
|
||||
token.clone(),
|
||||
@@ -214,7 +215,7 @@ async fn accept_loop(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn serve_connection(
|
||||
mut stream: TcpStream,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
container_port: u16,
|
||||
token: String,
|
||||
@@ -249,7 +250,7 @@ async fn serve_connection(
|
||||
|
||||
// Authorized: hand the socket to the same socat-over-Docker-exec tunnel the
|
||||
// auth bridge uses, replaying the head we had to buffer to make the call.
|
||||
tunnel_connection_with_prelude(container_id, target, stream, container_port, head).await;
|
||||
tunnel_connection_with_prelude(container, target, stream, container_port, head).await;
|
||||
}
|
||||
|
||||
/// Read bytes until the end of the HTTP request head (`\r\n\r\n`), or fail.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::auth_bridge::AuthBridgeStatus;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the bridge on or off for a project and return the resulting status.
|
||||
@@ -29,16 +30,13 @@ pub async fn set_auth_bridge_enabled(
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
if let Some(container_id) = project.container_id {
|
||||
if crate::docker::container::is_container_running(&container_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(container) = ContainerTarget::for_project(&project) {
|
||||
if container.is_running().await.unwrap_or(false) {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
container,
|
||||
app_handle,
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
|
||||
@@ -1202,15 +1202,12 @@ pub(crate) struct FetchedFile {
|
||||
/// file — or the whole *directory tree*, since the type check happens after the
|
||||
/// read — landed in host RAM twice. This function buffers, so every caller of
|
||||
/// it must name a ceiling.
|
||||
///
|
||||
/// Takes a container id rather than a [`ContainerTarget`] because the file
|
||||
/// viewer's commands call it with the id they already hold.
|
||||
pub(crate) async fn fetch_container_file(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
container_path: &str,
|
||||
max_bytes: u64,
|
||||
) -> Result<FetchedFile, String> {
|
||||
let mut stream = ContainerTarget::docker(container_id).download_archive(container_path);
|
||||
let mut stream = container.download_archive(container_path);
|
||||
|
||||
// A tar member is a 512-byte header plus payload padded to 512. 8 KiB of
|
||||
// slack past the payload cap guarantees the header and the whole capped
|
||||
@@ -1297,7 +1294,7 @@ pub async fn read_container_file(
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES);
|
||||
let fetched = fetch_container_file(target.id(), &path, cap).await?;
|
||||
let fetched = fetch_container_file(&target, &path, cap).await?;
|
||||
|
||||
Ok(FileContents {
|
||||
contents_base64: BASE64.encode(&fetched.bytes),
|
||||
@@ -1451,14 +1448,14 @@ pub(crate) fn not_running_message(action: &str, why: &str) -> String {
|
||||
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
|
||||
/// is the first thing to touch the container, so a stopped project fails inside
|
||||
/// path *validation* and reads like the path was the problem.
|
||||
///
|
||||
/// Takes a container id rather than a [`ContainerTarget`] because the file
|
||||
/// viewer's commands call it with the id they already hold.
|
||||
pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
|
||||
pub(crate) async fn require_running(
|
||||
container: &ContainerTarget,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
// An unreachable daemon is an error; an inspect failure (container gone)
|
||||
// reads as "not running" — `is_running`'s contract, and what this did
|
||||
// when it inspected the container itself.
|
||||
let running = ContainerTarget::docker(container_id).is_running().await?;
|
||||
let running = container.is_running().await?;
|
||||
if running {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1532,7 +1529,7 @@ pub async fn download_container_file(
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
require_running(target.id(), "saving a file to the host").await?;
|
||||
require_running(&target, "saving a file to the host").await?;
|
||||
|
||||
let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else {
|
||||
// Dismissed. Not an error, and deliberately distinguishable from one:
|
||||
@@ -1887,7 +1884,7 @@ pub async fn upload_files_to_container(
|
||||
|
||||
// Both checks before the dialog, so a project that cannot receive files
|
||||
// says so instead of asking the user to choose some first.
|
||||
require_running(target.id(), "uploading files").await?;
|
||||
require_running(&target, "uploading files").await?;
|
||||
resolve_container_dir(&target, "Upload", &container_dir).await?;
|
||||
|
||||
let Some(picked) = pick_files_to_upload(&window).await else {
|
||||
@@ -2241,7 +2238,7 @@ pub async fn download_container_backup(
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
require_running(target.id(), "backing up").await?;
|
||||
require_running(&target, "backing up").await?;
|
||||
|
||||
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
|
||||
// Read-only source: `tar -C` it, so absoluteness and `..` are what matter.
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::file_viewer::registry::{
|
||||
use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates};
|
||||
use crate::file_viewer::window::open_viewer_window;
|
||||
use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::Project;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -113,13 +114,11 @@ fn project_of(state: &AppState, project_id: &str) -> Result<Project, String> {
|
||||
}
|
||||
|
||||
/// `action` completes "Start the project before …", e.g. "saving this file".
|
||||
async fn running_container_of(project: &Project, action: &str) -> Result<String, String> {
|
||||
let container_id = project
|
||||
.container_id
|
||||
.clone()
|
||||
async fn running_container_of(project: &Project, action: &str) -> Result<ContainerTarget, String> {
|
||||
let container = ContainerTarget::for_project(project)
|
||||
.ok_or_else(|| not_running_message(action, "files live in its container"))?;
|
||||
require_running(&container_id, action).await?;
|
||||
Ok(container_id)
|
||||
require_running(&container, action).await?;
|
||||
Ok(container)
|
||||
}
|
||||
|
||||
/// The container of the project a viewer window belongs to, if it is running.
|
||||
@@ -127,7 +126,7 @@ async fn running_container_for(
|
||||
state: &AppState,
|
||||
target: &ViewerTarget,
|
||||
action: &str,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
running_container_of(&project_of(state, &target.project_id)?, action).await
|
||||
}
|
||||
|
||||
@@ -156,17 +155,17 @@ pub async fn open_file_viewer(
|
||||
) -> Result<(), String> {
|
||||
require_main(window.label())?;
|
||||
let project = project_of(&state, &project_id)?;
|
||||
let container_id = running_container_of(&project, "opening files").await?;
|
||||
let container = running_container_of(&project, "opening files").await?;
|
||||
|
||||
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
|
||||
let mut candidates = candidate_paths(&path, &mounts)?;
|
||||
let mut matches = probe_candidates(&container_id, &candidates).await?;
|
||||
let mut matches = probe_candidates(&container, &candidates).await?;
|
||||
// Not in the project: try home and /tmp, as a second exec so a project file
|
||||
// never becomes a chooser because a namesake sits in /tmp.
|
||||
if matches.is_empty() {
|
||||
let fallbacks = fallback_candidate_paths(&path);
|
||||
if !fallbacks.is_empty() {
|
||||
matches = probe_candidates(&container_id, &fallbacks).await?;
|
||||
matches = probe_candidates(&container, &fallbacks).await?;
|
||||
candidates.extend(fallbacks);
|
||||
}
|
||||
}
|
||||
@@ -253,9 +252,9 @@ pub async fn viewer_read_file(
|
||||
) -> Result<ViewerFile, String> {
|
||||
let (_label, target) = own_target(&window, ®istry)?;
|
||||
let path = resolved_path(&target)?;
|
||||
let container_id = running_container_for(&state, &target, "opening files").await?;
|
||||
let container = running_container_for(&state, &target, "opening files").await?;
|
||||
let cap = max_bytes.clamp(1, MAX_READ_BYTES);
|
||||
let fetched = fetch_container_file(&container_id, &path, cap).await?;
|
||||
let fetched = fetch_container_file(&container, &path, cap).await?;
|
||||
let (editable, readonly_reason) = match validate_container_write_path("File", &path) {
|
||||
Ok(()) => (true, None),
|
||||
Err(reason) => (false, Some(reason)),
|
||||
@@ -278,8 +277,8 @@ pub async fn viewer_poll_file(
|
||||
) -> Result<ViewerPoll, String> {
|
||||
let (_label, target) = own_target(&window, ®istry)?;
|
||||
let path = resolved_path(&target)?;
|
||||
let container_id = running_container_for(&state, &target, "checking this file for changes").await?;
|
||||
poll_file(&container_id, &path).await
|
||||
let container = running_container_for(&state, &target, "checking this file for changes").await?;
|
||||
poll_file(&container, &path).await
|
||||
}
|
||||
|
||||
/// Errors from `write_file` pass through unchanged: the frontend matches the
|
||||
@@ -302,8 +301,8 @@ pub async fn viewer_write_file(
|
||||
let bytes = BASE64
|
||||
.decode(contents_base64.as_bytes())
|
||||
.map_err(|_| "The editor sent malformed content.".to_string())?;
|
||||
let container_id = running_container_for(&state, &target, "saving this file").await?;
|
||||
write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await
|
||||
let container = running_container_for(&state, &target, "saving this file").await?;
|
||||
write_file(&container, &state.exec_manager, &path, &bytes, &base_hash).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -945,10 +945,10 @@ pub async fn apply_marketplace_now(
|
||||
let mut results = Vec::new();
|
||||
for p in projects {
|
||||
let running = match ContainerTarget::for_project(&p) {
|
||||
Some(target) => target.is_running().await.unwrap_or(false),
|
||||
None => false,
|
||||
Some(target) if target.is_running().await.unwrap_or(false) => Some(target),
|
||||
_ => None,
|
||||
};
|
||||
if !running {
|
||||
let Some(target) = running else {
|
||||
if project_id.is_some() {
|
||||
return Err(format!(
|
||||
"\"{}\" is not running. Its marketplace items are applied when it starts.",
|
||||
@@ -956,9 +956,8 @@ pub async fn apply_marketplace_now(
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let cid = p.container_id.clone().unwrap_or_default();
|
||||
let report = mk::sync_project(&state.marketplace, &settings, &p, &cid).await;
|
||||
};
|
||||
let report = mk::sync_project(&state.marketplace, &settings, &p, &target).await;
|
||||
let _ = app_handle.emit(
|
||||
mk::SYNC_FINISHED_EVENT,
|
||||
serde_json::json!({ "project_id": p.id, "report": report }),
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::project_commands::{create_container_for_project, emit_progress};
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::{create_container_for_project, default_docker_socket};
|
||||
use crate::docker;
|
||||
use crate::docker::migration::{self as mig, Recovery};
|
||||
use crate::models::{
|
||||
@@ -1810,14 +1811,6 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
||||
migration_store::clear_ownerless_for_project(project_id);
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn human_bytes(n: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let mut v = n as f64;
|
||||
|
||||
@@ -2,7 +2,9 @@ use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
|
||||
use crate::host::lifecycle::{DiscardLeftovers, ProjectHost};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -114,48 +116,6 @@ fn store_secrets_for_project(project: &Project, explicitly_cleared: &[&str]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the project's container, threading every global setting through.
|
||||
///
|
||||
/// Exists so that the two ordinary create paths below and base-image migration
|
||||
/// cannot drift apart — a container created by a migration must be
|
||||
/// indistinguishable from one created by a normal start, or the next
|
||||
/// `container_needs_recreation` would immediately throw it away.
|
||||
///
|
||||
/// `create_image` is what to create *from* (the snapshot or the base);
|
||||
/// `base_image_name` is the configured base, which `create_container` needs in
|
||||
/// order to tell those two apart when it stamps the lineage labels.
|
||||
pub(crate) async fn create_container_for_project(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
docker_socket: &str,
|
||||
aws_config_path: Option<&str>,
|
||||
create_image: &str,
|
||||
base_image_name: &str,
|
||||
extras: docker::CreateExtras<'_>,
|
||||
) -> Result<String, String> {
|
||||
docker::create_container(
|
||||
project,
|
||||
docker_socket,
|
||||
create_image,
|
||||
base_image_name,
|
||||
extras,
|
||||
aws_config_path,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Populate secret fields on a project struct from the OS keychain.
|
||||
pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
||||
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
||||
@@ -774,7 +734,8 @@ pub async fn remove_project(
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = match docker::find_existing_container(project).await {
|
||||
let host = ProjectHost::for_project(project);
|
||||
let container_ref = match host.find_current(project).await {
|
||||
Ok(found) => found,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
@@ -785,15 +746,14 @@ pub async fn remove_project(
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
if let Some(ref container) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container.id()) {
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
if let Err(e) = docker::remove_container(container_id).await {
|
||||
if let Err(e) = host.remove(container).await {
|
||||
log::warn!(
|
||||
"Failed to remove container {} for project {}: {}",
|
||||
container_id, project_id, e
|
||||
container.id(), project_id, e
|
||||
);
|
||||
// Recorded by name, not id: the name is the stable handle a
|
||||
// later retry can still resolve (Docker's remove-container
|
||||
@@ -805,15 +765,12 @@ pub async fn remove_project(
|
||||
|
||||
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
|
||||
// containers first, then the per-project network they were attached to.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
host.remove_legacy_leftovers(project).await;
|
||||
|
||||
// Clean up the snapshot image + volumes
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
report.image = Some(docker::get_snapshot_image_name(project));
|
||||
}
|
||||
report.volumes = docker::remove_project_volumes(project).await;
|
||||
let left = host.discard_project_state(project).await;
|
||||
report.image = left.image;
|
||||
report.volumes = left.volumes;
|
||||
}
|
||||
|
||||
// Clean up keychain secrets for this project
|
||||
@@ -1204,9 +1161,9 @@ async fn start_project_container_locked(
|
||||
// in memory when building environment variables for the container.
|
||||
load_secrets_for_project(&mut project);
|
||||
|
||||
// Load settings for image resolution and global AWS
|
||||
// Load settings: backend validation below, and the host's start (image
|
||||
// resolution, global AWS, …).
|
||||
let settings = state.settings_store.get();
|
||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// Validate backend requirements
|
||||
if project.backend == Backend::Bedrock {
|
||||
@@ -1322,140 +1279,18 @@ async fn start_project_container_locked(
|
||||
}
|
||||
|
||||
// Wrap container operations so that any failure resets status to Stopped.
|
||||
let result: Result<String, String> = async {
|
||||
// Ensure image exists
|
||||
emit_progress(&app_handle, &project_id, "Checking image...");
|
||||
if !docker::image_exists(&image_name).await? {
|
||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||
}
|
||||
|
||||
// Determine docker socket path
|
||||
let docker_socket = settings.docker_socket_path
|
||||
.as_deref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default_docker_socket());
|
||||
|
||||
// AWS config path from global settings
|
||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||
|
||||
// What we would create this container from *right now*: the project's
|
||||
// snapshot when one exists, else the configured base. This is the value
|
||||
// `container_needs_recreation` compares against the container's
|
||||
// `triple-c.create-image` label — the check that replaced the old
|
||||
// tautological one. It is resolved *before* the commit below, so it
|
||||
// describes the pre-commit world the existing container was born into.
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let expected_create_image =
|
||||
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||
// Check if config changed — if so, snapshot + recreate
|
||||
let needs_recreate = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
&project,
|
||||
&expected_create_image,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
).await.unwrap_or(false);
|
||||
|
||||
if needs_recreate {
|
||||
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||
// Snapshot the filesystem before destroying
|
||||
emit_progress(&app_handle, &project_id, "Saving container state...");
|
||||
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
|
||||
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||
}
|
||||
emit_progress(&app_handle, &project_id, "Recreating container...");
|
||||
let _ = docker::stop_container(&existing_id).await;
|
||||
docker::remove_container(&existing_id).await?;
|
||||
|
||||
// Legacy MCP cleanup: the old container may have been attached to
|
||||
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
||||
// that network now, before the replacement is created without it.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes).
|
||||
// Re-resolved after the commit above: when no snapshot existed
|
||||
// before, one does now, and creating from the base instead
|
||||
// would throw away the state that was just saved.
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
|
||||
// The commit above moved `:latest` and orphaned the image it
|
||||
// used to point at; the container holding that image open was
|
||||
// removed a few lines up, so now is when Docker will actually
|
||||
// let it go. Detached because this is housekeeping and the
|
||||
// project is already running — and it sweeps every orphan, not
|
||||
// just this one, so recreations that happened before the sweep
|
||||
// existed are cleaned up too.
|
||||
tauri::async_runtime::spawn(async {
|
||||
docker::sweep_orphaned_snapshots_logged("after recreation").await;
|
||||
});
|
||||
|
||||
new_id
|
||||
} else {
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&existing_id).await?;
|
||||
existing_id
|
||||
}
|
||||
} else {
|
||||
// Container doesn't exist (first start, or Docker pruned it).
|
||||
// Check for a snapshot image first — it preserves system-level
|
||||
// changes (apt/pip/npm installs) from the previous session.
|
||||
if expected_create_image == snapshot_image {
|
||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||
}
|
||||
let create_image = expected_create_image.clone();
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
};
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let result: Result<ContainerTarget, String> = async {
|
||||
let target = host
|
||||
.ensure_started(&project, &settings, &|msg: &str| {
|
||||
emit_progress(&app_handle, &project_id, msg)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Sync Bedrock credentials on every start: refresh static/session creds
|
||||
// so rotated keys are picked up without a full container recreation, and
|
||||
// clear stale creds when the project no longer uses static-cred Bedrock.
|
||||
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
|
||||
if let Err(e) = docker::sync_bedrock_credentials(&target, &project).await {
|
||||
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
|
||||
}
|
||||
|
||||
@@ -1466,10 +1301,10 @@ async fn start_project_container_locked(
|
||||
state.marketplace.clone(),
|
||||
state.settings_store.get(),
|
||||
project.clone(),
|
||||
container_id.clone(),
|
||||
target.clone(),
|
||||
);
|
||||
|
||||
Ok(container_id)
|
||||
Ok(target)
|
||||
}.await;
|
||||
|
||||
// On failure, reset status to Stopped so the project doesn't get stuck.
|
||||
@@ -1478,7 +1313,8 @@ async fn start_project_container_locked(
|
||||
let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped);
|
||||
}
|
||||
|
||||
let container_id = result?;
|
||||
let target = result?;
|
||||
let container_id = target.id().to_string();
|
||||
|
||||
// Update project with container info using granular methods (Issue 14: TOCTOU)
|
||||
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
||||
@@ -1491,7 +1327,7 @@ async fn start_project_container_locked(
|
||||
.auth_bridge
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id.clone(),
|
||||
target.clone(),
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -1527,13 +1363,13 @@ pub async fn stop_project_container(
|
||||
// Drop host listeners first: they only make sense while the container runs.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
if let Some(container) = ContainerTarget::for_project(&project) {
|
||||
// Close exec sessions for this project
|
||||
emit_progress(&app_handle, &project_id, "Stopping container...");
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
|
||||
if let Err(e) = docker::stop_container(container_id).await {
|
||||
log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
|
||||
if let Err(e) = ProjectHost::for_project(&project).stop(&container).await {
|
||||
log::warn!("Stop failed for container {} (project {}): {} — resetting to Stopped anyway", container.id(), project_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1588,13 +1424,13 @@ pub async fn rebuild_project_container(
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = docker::find_existing_container(&project).await?;
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let container_ref = host.find_current(&project).await?;
|
||||
if let Some(ref container) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container.id()) {
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
docker::remove_container(container_id).await?;
|
||||
host.remove(container).await?;
|
||||
state.projects_store.set_container_id(&project_id, None)?;
|
||||
}
|
||||
|
||||
@@ -1607,12 +1443,10 @@ pub async fn rebuild_project_container(
|
||||
// discard. No pending-cleanup record for either: unlike `remove_project`,
|
||||
// Reset keeps the project record, so a later Reset attempt can retry
|
||||
// these itself rather than needing startup housekeeping to do it.
|
||||
let mut leftover_image = None;
|
||||
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
leftover_image = Some(docker::get_snapshot_image_name(&project));
|
||||
}
|
||||
let leftover_volumes = docker::remove_project_volumes(&project).await;
|
||||
let DiscardLeftovers {
|
||||
image: leftover_image,
|
||||
volumes: leftover_volumes,
|
||||
} = host.discard_project_state(&project).await;
|
||||
if leftover_image.is_some() || !leftover_volumes.is_empty() {
|
||||
log::warn!(
|
||||
"Reset for project {} could not fully clean up — image: {:?}, volumes: {:?} — the \
|
||||
@@ -1679,8 +1513,8 @@ pub async fn reconcile_project_statuses(
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_running = if let Some(ref container_id) = project.container_id {
|
||||
docker::is_container_running(container_id).await.unwrap_or(false)
|
||||
let is_running = if let Some(container) = ContainerTarget::for_project(project) {
|
||||
container.is_running().await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -1695,12 +1529,12 @@ pub async fn reconcile_project_statuses(
|
||||
// bridge lives in this process, so re-arm it here. `start` is
|
||||
// idempotent, so a bridge that is already polling is untouched.
|
||||
if project.auth_bridge_enabled {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
if let Some(target) = crate::host::ContainerTarget::for_project(&project) {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(
|
||||
project.id.clone(),
|
||||
container_id.clone(),
|
||||
target,
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -1720,13 +1554,6 @@ pub async fn reconcile_project_statuses(
|
||||
Ok(state.projects_store.list())
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
//! disconnected, while the sidebar, Start/Stop and Project Home all still
|
||||
//! described a live container.
|
||||
//!
|
||||
//! One subscription to Docker's event stream, filtered daemon-side to container
|
||||
//! `die` events, so an idle daemon costs nothing and the app hears about a death
|
||||
//! as it happens. No polling loop. Each `die` naming a project's container is
|
||||
//! One subscription to the host's death reports (`host::watch`; for Docker,
|
||||
//! the daemon's event stream filtered to container `die` events, so an idle
|
||||
//! daemon costs nothing and the app hears about a death as it happens — no
|
||||
//! polling loop). Each `die` naming a project's container is
|
||||
//! confirmed with one inspect before anything changes (an event can be
|
||||
//! replayed, and a restart can reuse the id), and the flip itself is
|
||||
//! [`ProjectsStore::mark_exited`]'s compare-and-set, which never touches a
|
||||
@@ -22,20 +23,17 @@
|
||||
//! the only window that replay cannot cover is a daemon restart, which forgets
|
||||
//! its event history, and that is exactly when containers go away.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bollard::errors::Error as DockerError;
|
||||
use bollard::models::{EventMessage, EventMessageTypeEnum};
|
||||
use bollard::system::EventsOptions;
|
||||
use bollard::Docker;
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::host::watch::ExitSource;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Project, ProjectStatus};
|
||||
use crate::storage::projects_store::{ExitMark, ProjectsStore};
|
||||
|
||||
@@ -72,46 +70,28 @@ pub struct ContainerExited<'a> {
|
||||
pub reason: ExitReason,
|
||||
}
|
||||
|
||||
/// The container id a Docker event reports as having died, if it is one.
|
||||
pub(crate) fn died_container(msg: &EventMessage) -> Option<&str> {
|
||||
if msg.typ != Some(EventMessageTypeEnum::CONTAINER) || msg.action.as_deref() != Some("die") {
|
||||
return None;
|
||||
}
|
||||
msg.actor.as_ref()?.id.as_deref().filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
/// `true` only when Docker positively says the container is not running: it
|
||||
/// inspects as stopped, or it no longer exists. Any other failure is "don't
|
||||
/// know" — `docker::is_container_running` maps every error to `false`, which
|
||||
/// is right for its callers and wrong here, where it would mark every project
|
||||
/// Stopped the moment the daemon hiccups.
|
||||
async fn has_exited(docker: &Docker, container_id: &str) -> bool {
|
||||
match docker.inspect_container(container_id, None).await {
|
||||
Ok(info) => !info.state.and_then(|s| s.running).unwrap_or(false),
|
||||
Err(DockerError::DockerResponseServerError { status_code: 404, .. }) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_busy(project_id: &str) -> bool {
|
||||
crate::project_lock::held(project_id).is_some()
|
||||
}
|
||||
|
||||
/// Confirm `container_id` really stopped and, if a project was Running on it,
|
||||
/// Confirm `container` really stopped and, if a project was Running on it,
|
||||
/// flip that project. Returns what changed.
|
||||
async fn settle(docker: &Docker, store: &ProjectsStore, container_id: &str) -> Option<Project> {
|
||||
async fn settle(store: &ProjectsStore, container: &ContainerTarget) -> Option<Project> {
|
||||
let container_id = container.id();
|
||||
for _ in 0..SETTLE_ATTEMPTS {
|
||||
// Cheap and allocation-free: most containers that die are not ours
|
||||
// (probe and scratch containers, other software on the daemon), and
|
||||
// those cost no inspect at all.
|
||||
let seen = store.running_on(container_id)?;
|
||||
if !has_exited(docker, container_id).await {
|
||||
// Positive exit only: a host that can't answer must not mark every
|
||||
// project Stopped.
|
||||
if !container.has_exited().await {
|
||||
return None;
|
||||
}
|
||||
match store.mark_exited(container_id, &seen, project_busy) {
|
||||
ExitMark::Flipped(p) => return Some(*p),
|
||||
ExitMark::NotApplicable => return None,
|
||||
// The store moved while Docker was answering — possibly a stop and
|
||||
// The store moved while the host was answering — possibly a stop and
|
||||
// start of this very container. The inspect is stale; ask again.
|
||||
ExitMark::Changed => continue,
|
||||
}
|
||||
@@ -120,27 +100,20 @@ async fn settle(docker: &Docker, store: &ProjectsStore, container_id: &str) -> O
|
||||
}
|
||||
|
||||
/// Re-check every Running project once. Used on each (re)subscription.
|
||||
async fn sweep(docker: &Docker, store: &ProjectsStore) -> Vec<Project> {
|
||||
async fn sweep(store: &ProjectsStore) -> Vec<Project> {
|
||||
let mut changed = Vec::new();
|
||||
for p in store.list() {
|
||||
if p.status != ProjectStatus::Running {
|
||||
continue;
|
||||
}
|
||||
let Some(cid) = p.container_id.as_deref() else { continue };
|
||||
if let Some(c) = settle(docker, store, cid).await {
|
||||
let Some(container) = ContainerTarget::for_project(&p) else { continue };
|
||||
if let Some(c) = settle(store, &container).await {
|
||||
changed.push(c);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn die_filter() -> HashMap<String, Vec<String>> {
|
||||
HashMap::from([
|
||||
("type".to_string(), vec!["container".to_string()]),
|
||||
("event".to_string(), vec!["die".to_string()]),
|
||||
])
|
||||
}
|
||||
|
||||
/// The wait before connection attempt `n + 1`, after `n` consecutive
|
||||
/// unproductive ones: 5 s, 10 s, 20 s, 40 s, then 60 s for good.
|
||||
fn retry_delay(consecutive_failures: u32) -> Duration {
|
||||
@@ -148,7 +121,7 @@ fn retry_delay(consecutive_failures: u32) -> Duration {
|
||||
(RETRY_MIN * 2u32.pow(doublings)).min(RETRY_MAX)
|
||||
}
|
||||
|
||||
/// `fut`, unless the app starts shutting down first. Every Docker call in here
|
||||
/// `fut`, unless the app starts shutting down first. Every host call in here
|
||||
/// goes through this: a wedged daemon must not hold shutdown for its full
|
||||
/// budget.
|
||||
async fn unless_cancelled<T>(
|
||||
@@ -174,32 +147,26 @@ async fn watch_once(
|
||||
sweep_reason: ExitReason,
|
||||
on_change: &impl Fn(&Project, ExitReason),
|
||||
) -> Option<bool> {
|
||||
let Ok(docker) = crate::docker::get_docker() else { return Some(false) };
|
||||
if unless_cancelled(cancel, docker.ping()).await?.is_err() {
|
||||
let source = ExitSource::Docker;
|
||||
if !unless_cancelled(cancel, source.reachable()).await? {
|
||||
return Some(false);
|
||||
}
|
||||
// A second's overlap: the daemon replays from `since`, so a death during
|
||||
// A second's overlap: the host replays from `since`, so a death during
|
||||
// the sweep below is still delivered. Duplicates are harmless — `settle`
|
||||
// confirms with an inspect every time.
|
||||
let since = (chrono::Utc::now().timestamp() - 1).to_string();
|
||||
for p in unless_cancelled(cancel, sweep(&docker, store)).await? {
|
||||
let since = chrono::Utc::now().timestamp() - 1;
|
||||
for p in unless_cancelled(cancel, sweep(store)).await? {
|
||||
on_change(&p, sweep_reason);
|
||||
}
|
||||
let mut events = docker.events(Some(EventsOptions::<String> {
|
||||
since: Some(since),
|
||||
until: None,
|
||||
filters: die_filter(),
|
||||
}));
|
||||
let mut deaths = source.deaths(since);
|
||||
let connected = Instant::now();
|
||||
let mut delivered = false;
|
||||
loop {
|
||||
match unless_cancelled(cancel, events.next()).await? {
|
||||
Some(Ok(msg)) => {
|
||||
match unless_cancelled(cancel, deaths.next()).await? {
|
||||
Some(Ok(container)) => {
|
||||
delivered = true;
|
||||
if let Some(cid) = died_container(&msg) {
|
||||
if let Some(p) = unless_cancelled(cancel, settle(&docker, store, cid)).await? {
|
||||
on_change(&p, ExitReason::Exited);
|
||||
}
|
||||
if let Some(p) = unless_cancelled(cancel, settle(store, &container)).await? {
|
||||
on_change(&p, ExitReason::Exited);
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
@@ -265,41 +232,6 @@ pub fn spawn(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bollard::models::EventActor;
|
||||
|
||||
fn event(typ: EventMessageTypeEnum, action: &str, id: Option<&str>) -> EventMessage {
|
||||
EventMessage {
|
||||
typ: Some(typ),
|
||||
action: Some(action.to_string()),
|
||||
actor: Some(EventActor { id: id.map(str::to_string), attributes: None }),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_container_die_names_a_container() {
|
||||
let die = event(EventMessageTypeEnum::CONTAINER, "die", Some("abc"));
|
||||
assert_eq!(died_container(&die), Some("abc"));
|
||||
|
||||
for (typ, action, id) in [
|
||||
(EventMessageTypeEnum::CONTAINER, "stop", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "start", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "exec_die", Some("abc")),
|
||||
(EventMessageTypeEnum::NETWORK, "die", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "die", None),
|
||||
(EventMessageTypeEnum::CONTAINER, "die", Some("")),
|
||||
] {
|
||||
assert_eq!(died_container(&event(typ, action, id)), None, "{typ:?} {action} {id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_subscription_is_filtered_to_container_deaths_daemon_side() {
|
||||
let f = die_filter();
|
||||
assert_eq!(f.get("type"), Some(&vec!["container".to_string()]));
|
||||
assert_eq!(f.get("event"), Some(&vec!["die".to_string()]));
|
||||
assert_eq!(f.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_back_off_to_a_minute_and_stay_there() {
|
||||
@@ -321,7 +253,7 @@ mod tests {
|
||||
assert_eq!(CONTAINER_EXITED_EVENT, "project-container-exited");
|
||||
}
|
||||
|
||||
/// The shutdown path: a Docker call that never answers (a wedged daemon)
|
||||
/// The shutdown path: a host call that never answers (a wedged daemon)
|
||||
/// must give way to cancellation at once, not hold teardown.
|
||||
#[tokio::test]
|
||||
async fn a_hung_docker_call_yields_to_cancellation() {
|
||||
|
||||
@@ -26,3 +26,13 @@ pub async fn check_docker_available() -> Result<bool, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon socket a project container gets bind-mounted when Docker access
|
||||
/// is enabled and no path is configured.
|
||||
pub fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use sha2::{Sha256, Digest};
|
||||
|
||||
use super::ca_certs;
|
||||
use super::client::get_docker;
|
||||
use crate::models::AppSettings;
|
||||
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||
@@ -2015,7 +2016,7 @@ pub const CONFIG_VOLUME_PREFIX: &str = "triple-c-claude-config-";
|
||||
/// `aws_config_path` is configured), since the entrypoint already refreshes
|
||||
/// `~/.aws` from the host on every start in that case.
|
||||
pub async fn sync_bedrock_credentials(
|
||||
container_id: &str,
|
||||
target: &crate::host::ContainerTarget,
|
||||
project: &Project,
|
||||
) -> Result<(), String> {
|
||||
let static_bedrock = if project.backend == Backend::Bedrock {
|
||||
@@ -2038,10 +2039,10 @@ pub async fn sync_bedrock_credentials(
|
||||
let script = r#"if [ ! -d /tmp/.host-aws ]; then rm -f "$HOME/.aws/credentials"; fi"#;
|
||||
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||
let env = vec!["HOME=/home/claude".to_string()];
|
||||
if let Err(e) = crate::docker::exec::exec_oneshot_env(container_id, cmd, env).await {
|
||||
if let Err(e) = target.exec_oneshot_env(cmd, env).await {
|
||||
log::warn!(
|
||||
"Failed to clear stale AWS credentials in container {}: {}",
|
||||
container_id,
|
||||
target.id(),
|
||||
e
|
||||
);
|
||||
}
|
||||
@@ -2093,7 +2094,7 @@ chmod 600 "$HOME/.aws/credentials""#;
|
||||
|
||||
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||
let (output, exit_code) =
|
||||
crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env)
|
||||
target.exec_oneshot_env_status(cmd, env)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
|
||||
if exit_code != 0 {
|
||||
@@ -2104,7 +2105,7 @@ chmod 600 "$HOME/.aws/credentials""#;
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("Wrote Bedrock static credentials into container {}", container_id);
|
||||
log::info!("Wrote Bedrock static credentials into container {}", target.id());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4143,6 +4144,49 @@ pub async fn is_container_running(container_id: &str) -> Result<bool, String> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Create the project's container, threading every global setting through.
|
||||
///
|
||||
/// Exists so that the ordinary create paths (`host::lifecycle`) and base-image migration
|
||||
/// cannot drift apart — a container created by a migration must be
|
||||
/// indistinguishable from one created by a normal start, or the next
|
||||
/// `container_needs_recreation` would immediately throw it away.
|
||||
///
|
||||
/// `create_image` is what to create *from* (the snapshot or the base);
|
||||
/// `base_image_name` is the configured base, which `create_container` needs in
|
||||
/// order to tell those two apart when it stamps the lineage labels.
|
||||
pub async fn create_container_for_project(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
docker_socket: &str,
|
||||
aws_config_path: Option<&str>,
|
||||
create_image: &str,
|
||||
base_image_name: &str,
|
||||
extras: CreateExtras<'_>,
|
||||
) -> Result<String, String> {
|
||||
create_container(
|
||||
project,
|
||||
docker_socket,
|
||||
create_image,
|
||||
base_image_name,
|
||||
extras,
|
||||
aws_config_path,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The Docker half of `container_watch`: reachability, the daemon's container
|
||||
//! `die` event stream, and the "has it positively exited" check. Everything
|
||||
//! about *what to do* with a death (confirm, compare-and-set the store, tell
|
||||
//! the frontend, back off) is host-neutral and stays in `container_watch.rs`,
|
||||
//! which reaches these through `host::watch`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
|
||||
use bollard::errors::Error as DockerError;
|
||||
use bollard::models::{EventMessage, EventMessageTypeEnum};
|
||||
use bollard::system::EventsOptions;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
|
||||
use super::client::get_docker;
|
||||
|
||||
/// Whether the daemon answers at all.
|
||||
pub async fn ping() -> bool {
|
||||
match get_docker() {
|
||||
Ok(docker) => docker.ping().await.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The container id a Docker event reports as having died, if it is one.
|
||||
pub(crate) fn died_container(msg: &EventMessage) -> Option<&str> {
|
||||
if msg.typ != Some(EventMessageTypeEnum::CONTAINER) || msg.action.as_deref() != Some("die") {
|
||||
return None;
|
||||
}
|
||||
msg.actor.as_ref()?.id.as_deref().filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
fn die_filter() -> HashMap<String, Vec<String>> {
|
||||
HashMap::from([
|
||||
("type".to_string(), vec!["container".to_string()]),
|
||||
("event".to_string(), vec!["die".to_string()]),
|
||||
])
|
||||
}
|
||||
|
||||
/// Container ids as Docker reports them dying, replayed from `since` (unix
|
||||
/// seconds). Filtered daemon-side to container `die` events, so an idle daemon
|
||||
/// costs nothing. Anything else that comes through is dropped here. An `Err`
|
||||
/// item, or the stream ending, means the connection is gone.
|
||||
pub fn container_deaths(since: i64) -> Pin<Box<dyn Stream<Item = Result<String, String>> + Send>> {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Box::pin(futures_util::stream::once(async move { Err(e) })),
|
||||
};
|
||||
let events = docker.events(Some(EventsOptions::<String> {
|
||||
since: Some(since.to_string()),
|
||||
until: None,
|
||||
filters: die_filter(),
|
||||
}));
|
||||
Box::pin(events.filter_map(|item| async move {
|
||||
match item {
|
||||
Ok(msg) => died_container(&msg).map(|id| Ok(id.to_string())),
|
||||
Err(e) => Some(Err(e.to_string())),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// `true` only when Docker positively says the container is not running: it
|
||||
/// inspects as stopped, or it no longer exists. Any other failure is "don't
|
||||
/// know" — `is_container_running` maps every error to `false`, which is right
|
||||
/// for its callers and wrong for the watcher, where it would mark every project
|
||||
/// Stopped the moment the daemon hiccups.
|
||||
pub async fn has_exited(container_id: &str) -> bool {
|
||||
let Ok(docker) = get_docker() else { return false };
|
||||
match docker.inspect_container(container_id, None).await {
|
||||
Ok(info) => !info.state.and_then(|s| s.running).unwrap_or(false),
|
||||
Err(DockerError::DockerResponseServerError { status_code: 404, .. }) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bollard::models::EventActor;
|
||||
|
||||
fn event(typ: EventMessageTypeEnum, action: &str, id: Option<&str>) -> EventMessage {
|
||||
EventMessage {
|
||||
typ: Some(typ),
|
||||
action: Some(action.to_string()),
|
||||
actor: Some(EventActor { id: id.map(str::to_string), attributes: None }),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_container_die_names_a_container() {
|
||||
let die = event(EventMessageTypeEnum::CONTAINER, "die", Some("abc"));
|
||||
assert_eq!(died_container(&die), Some("abc"));
|
||||
|
||||
for (typ, action, id) in [
|
||||
(EventMessageTypeEnum::CONTAINER, "stop", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "start", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "exec_die", Some("abc")),
|
||||
(EventMessageTypeEnum::NETWORK, "die", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "die", None),
|
||||
(EventMessageTypeEnum::CONTAINER, "die", Some("")),
|
||||
] {
|
||||
assert_eq!(died_container(&event(typ, action, id)), None, "{typ:?} {action} {id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_subscription_is_filtered_to_container_deaths_daemon_side() {
|
||||
let f = die_filter();
|
||||
assert_eq!(f.get("type"), Some(&vec!["container".to_string()]));
|
||||
assert_eq!(f.get("event"), Some(&vec!["die".to_string()]));
|
||||
assert_eq!(f.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod ca_certs;
|
||||
pub mod client;
|
||||
pub mod events;
|
||||
pub mod container;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
|
||||
@@ -40,7 +40,10 @@ pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll {
|
||||
ViewerPoll { exists: true, hash, size }
|
||||
}
|
||||
|
||||
pub async fn poll_file(container_id: &str, container_path: &str) -> Result<ViewerPoll, String> {
|
||||
pub async fn poll_file(
|
||||
container: &ContainerTarget,
|
||||
container_path: &str,
|
||||
) -> Result<ViewerPoll, String> {
|
||||
let cmd = vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
@@ -48,7 +51,7 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result<Viewe
|
||||
"poll".to_string(),
|
||||
container_path.to_string(),
|
||||
];
|
||||
let (stdout, stderr, code) = ContainerTarget::docker(container_id)
|
||||
let (stdout, stderr, code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
if code != 0 && code != 4 {
|
||||
|
||||
@@ -131,12 +131,12 @@ pub fn parse_probe_output(stdout: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
pub async fn probe_candidates(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
candidates: &[String],
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()];
|
||||
cmd.extend(candidates.iter().cloned());
|
||||
let (stdout, _stderr, _code) = ContainerTarget::docker(container_id)
|
||||
let (stdout, _stderr, _code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
Ok(parse_probe_output(&stdout))
|
||||
|
||||
@@ -172,7 +172,7 @@ fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result<SavedFile, String>
|
||||
}
|
||||
|
||||
pub async fn write_file(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
exec_manager: &ExecSessionManager,
|
||||
target: &str,
|
||||
bytes: &[u8],
|
||||
@@ -180,9 +180,8 @@ pub async fn write_file(
|
||||
) -> Result<SavedFile, String> {
|
||||
check_write_input(bytes.len(), base_hash)?;
|
||||
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
|
||||
let container = ContainerTarget::docker(container_id);
|
||||
let tmp_path = exec_manager
|
||||
.write_file_to_container(&container, &tmp_name, bytes)
|
||||
.write_file_to_container(container, &tmp_name, bytes)
|
||||
.await?;
|
||||
let cmd = write_command(target, &tmp_path, base_hash);
|
||||
let (stdout, stderr, code) = container
|
||||
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Bringing a project's container into existence, starting, stopping and
|
||||
//! removing it — the host-specific half of the project lifecycle commands.
|
||||
//!
|
||||
//! The commands in `commands/project_commands.rs` keep everything that is the
|
||||
//! same on every host: validating the LLM backend config, the Bedrock SSO
|
||||
//! check, project status transitions, the project lock, closing terminal
|
||||
//! sessions, the auth bridge, marketplace and credential syncs. They ask a
|
||||
//! [`ProjectHost`] for the rest. For Docker that is the
|
||||
//! find → maybe-snapshot-and-recreate → create → start sequence, which used to
|
||||
//! sit inline in `start_project_container_locked` and is moved here unchanged.
|
||||
//!
|
||||
//! Docker-only operations with no host-neutral meaning — base-image migration,
|
||||
//! snapshot sweeps, legacy cleanup at startup — keep calling `crate::docker`
|
||||
//! directly; see the boundary test in `host/mod.rs`.
|
||||
|
||||
use super::ContainerTarget;
|
||||
use crate::docker;
|
||||
use crate::models::{AppSettings, Project};
|
||||
use crate::models::container_config;
|
||||
|
||||
/// The host a project runs on. One variant today; an AWS variant will carry
|
||||
/// the connection, region and environment (see the spec's data model).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
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)]
|
||||
pub struct DiscardLeftovers {
|
||||
pub image: Option<String>,
|
||||
pub volumes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProjectHost {
|
||||
pub fn for_project(_project: &Project) -> Self {
|
||||
ProjectHost::Docker
|
||||
}
|
||||
|
||||
/// Make sure the project's container exists, matches the project's
|
||||
/// current configuration, and is running. Returns where it runs.
|
||||
///
|
||||
/// `progress` receives the same human-readable steps the start view has
|
||||
/// always shown. Failure leaves the caller to reset the project status.
|
||||
pub async fn ensure_started(
|
||||
&self,
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
progress: &(dyn Fn(&str) + Send + Sync),
|
||||
) -> Result<ContainerTarget, String> {
|
||||
match self {
|
||||
ProjectHost::Docker => docker_ensure_started(project, settings, progress)
|
||||
.await
|
||||
.map(ContainerTarget::docker),
|
||||
}
|
||||
}
|
||||
|
||||
/// The container the host currently has for this project, found by asking
|
||||
/// the host rather than trusting `project.container_id` — that field can
|
||||
/// be stale (a crash between remove and the store update, a hand-edited
|
||||
/// `projects.json`).
|
||||
pub async fn find_current(&self, project: &Project) -> Result<Option<ContainerTarget>, String> {
|
||||
match self {
|
||||
ProjectHost::Docker => Ok(docker::find_existing_container(project)
|
||||
.await?
|
||||
.map(ContainerTarget::docker)),
|
||||
}
|
||||
}
|
||||
|
||||
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")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop (errors ignored — a stopped container is the goal) and remove.
|
||||
pub async fn remove(&self, target: &ContainerTarget) -> Result<(), String> {
|
||||
match target {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
docker::remove_container(container_id).await
|
||||
}
|
||||
ContainerTarget::Agent(_) => Err(AGENT_LIFECYCLE.replace("{op}", "Removing")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete what outlives the container — for Docker the snapshot image and
|
||||
/// both named volumes (`~/.claude`, the OAuth login and transcripts go with
|
||||
/// them). Best-effort; reports what it could not remove.
|
||||
pub async fn discard_project_state(&self, project: &Project) -> DiscardLeftovers {
|
||||
match self {
|
||||
ProjectHost::Docker => {
|
||||
let mut left = DiscardLeftovers::default();
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project.id, e);
|
||||
left.image = Some(docker::get_snapshot_image_name(project));
|
||||
}
|
||||
left.volumes = docker::remove_project_volumes(project).await;
|
||||
left
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove leftovers of features this host no longer has (Docker: the
|
||||
/// deleted MCP feature's containers and network).
|
||||
pub async fn remove_legacy_leftovers(&self, project: &Project) {
|
||||
match self {
|
||||
ProjectHost::Docker => {
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Docker start sequence, moved here verbatim from
|
||||
/// `start_project_container_locked`. Returns the running container's id.
|
||||
async fn docker_ensure_started(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
progress: &(dyn Fn(&str) + Send + Sync),
|
||||
) -> Result<String, String> {
|
||||
let image_name =
|
||||
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// Ensure image exists
|
||||
progress("Checking image...");
|
||||
if !docker::image_exists(&image_name).await? {
|
||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||
}
|
||||
|
||||
// Determine docker socket path
|
||||
let docker_socket = settings.docker_socket_path
|
||||
.as_deref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| docker::default_docker_socket());
|
||||
|
||||
// AWS config path from global settings
|
||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||
|
||||
// What we would create this container from *right now*: the project's
|
||||
// snapshot when one exists, else the configured base. This is the value
|
||||
// `container_needs_recreation` compares against the container's
|
||||
// `triple-c.create-image` label — the check that replaced the old
|
||||
// tautological one. It is resolved *before* the commit below, so it
|
||||
// describes the pre-commit world the existing container was born into.
|
||||
let snapshot_image = docker::get_snapshot_image_name(project);
|
||||
let expected_create_image =
|
||||
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(project).await? {
|
||||
// Check if config changed — if so, snapshot + recreate
|
||||
let needs_recreate = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
project,
|
||||
&expected_create_image,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
).await.unwrap_or(false);
|
||||
|
||||
if needs_recreate {
|
||||
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||
// Snapshot the filesystem before destroying
|
||||
progress("Saving container state...");
|
||||
if let Err(e) = docker::commit_container_snapshot(&existing_id, project).await {
|
||||
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||
}
|
||||
progress("Recreating container...");
|
||||
let _ = docker::stop_container(&existing_id).await;
|
||||
docker::remove_container(&existing_id).await?;
|
||||
|
||||
// Legacy MCP cleanup: the old container may have been attached to
|
||||
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
||||
// that network now, before the replacement is created without it.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes).
|
||||
// Re-resolved after the commit above: when no snapshot existed
|
||||
// before, one does now, and creating from the base instead
|
||||
// would throw away the state that was just saved.
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let new_id = docker::create_container_for_project(
|
||||
project,
|
||||
settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
progress("Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
|
||||
// The commit above moved `:latest` and orphaned the image it
|
||||
// used to point at; the container holding that image open was
|
||||
// removed a few lines up, so now is when Docker will actually
|
||||
// let it go. Detached because this is housekeeping and the
|
||||
// project is already running — and it sweeps every orphan, not
|
||||
// just this one, so recreations that happened before the sweep
|
||||
// existed are cleaned up too.
|
||||
tauri::async_runtime::spawn(async {
|
||||
docker::sweep_orphaned_snapshots_logged("after recreation").await;
|
||||
});
|
||||
|
||||
new_id
|
||||
} else {
|
||||
progress("Starting container...");
|
||||
docker::start_container(&existing_id).await?;
|
||||
existing_id
|
||||
}
|
||||
} else {
|
||||
// Container doesn't exist (first start, or Docker pruned it).
|
||||
// Check for a snapshot image first — it preserves system-level
|
||||
// changes (apt/pip/npm installs) from the previous session.
|
||||
if expected_create_image == snapshot_image {
|
||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||
}
|
||||
let create_image = expected_create_image.clone();
|
||||
|
||||
progress("Creating container...");
|
||||
let new_id = docker::create_container_for_project(
|
||||
project,
|
||||
settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
progress("Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
};
|
||||
|
||||
Ok(container_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// End to end against a real daemon and the registry sandbox image: a
|
||||
/// throwaway project (fresh UUID, so its container, volumes and snapshot
|
||||
/// name are its own) is started, reached, stopped, started again on the
|
||||
/// *same* container, then removed and its state discarded.
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon and the triple-c-sandbox image; creates and removes a throwaway project container"]
|
||||
async fn a_project_container_round_trips_through_the_host() {
|
||||
let project = Project::new("lifecycle-live-test".to_string(), Vec::new());
|
||||
let settings = AppSettings::default();
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let steps = std::sync::Mutex::new(Vec::<String>::new());
|
||||
let progress = |m: &str| steps.lock().unwrap().push(m.to_string());
|
||||
// Failures inside the round trip return instead of panicking, so the
|
||||
// cleanup below always runs.
|
||||
fn check(ok: bool, what: &str) -> Result<(), String> {
|
||||
if ok { Ok(()) } else { Err(format!("check failed: {what}")) }
|
||||
}
|
||||
|
||||
let outcome = async {
|
||||
check(host.find_current(&project).await?.is_none(), "no container before the first start")?;
|
||||
|
||||
let first = host.ensure_started(&project, &settings, &progress).await?;
|
||||
check(first.is_running().await?, "running after start")?;
|
||||
let (out, code) = first
|
||||
.exec_oneshot_env_status(vec!["id".into(), "-un".into()], vec![])
|
||||
.await?;
|
||||
check(out.trim() == "claude" && code == 0, "exec runs as claude")?;
|
||||
check(host.find_current(&project).await? == Some(first.clone()), "find_current names it")?;
|
||||
|
||||
host.stop(&first).await?;
|
||||
check(first.has_exited().await, "exited after stop")?;
|
||||
|
||||
// Same config, so the existing container is started, not recreated.
|
||||
let second = host.ensure_started(&project, &settings, &progress).await?;
|
||||
check(second == first, "restart reuses the container")?;
|
||||
check(second.is_running().await?, "running after restart")?;
|
||||
|
||||
host.remove(&second).await?;
|
||||
check(host.find_current(&project).await?.is_none(), "gone after remove")?;
|
||||
Ok::<_, String>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Always clean up, even after a failed assertion above.
|
||||
if let Ok(Some(t)) = host.find_current(&project).await {
|
||||
let _ = host.remove(&t).await;
|
||||
}
|
||||
let left = host.discard_project_state(&project).await;
|
||||
outcome.expect("round trip");
|
||||
assert_eq!(left, DiscardLeftovers::default(), "nothing left behind");
|
||||
let steps = steps.into_inner().unwrap();
|
||||
assert_eq!(
|
||||
steps,
|
||||
[
|
||||
"Checking image...",
|
||||
"Creating container...",
|
||||
"Starting container...",
|
||||
"Checking image...",
|
||||
"Starting container...",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+120
-27
@@ -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,11 +27,15 @@
|
||||
//! `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;
|
||||
|
||||
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;
|
||||
@@ -130,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 {
|
||||
@@ -149,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +339,9 @@ impl ContainerTarget {
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, (0, 0)).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +366,9 @@ impl ContainerTarget {
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, ids).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +415,9 @@ impl ContainerTarget {
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_host_file_with_ids(ep, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +428,7 @@ impl ContainerTarget {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::download_archive(container_id, path)
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::download_archive(ep, path),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,22 +465,11 @@ mod tests {
|
||||
assert_eq!(ContainerTarget::docker("abc").id(), "abc");
|
||||
}
|
||||
|
||||
/// Phase 1's boundary, pinned: outside `docker/` and `host/`, only the
|
||||
/// files listed here may name bollard or `docker::exec`. Each entry is
|
||||
/// Docker-only by nature (migration, snapshots, sidecars) or is scheduled
|
||||
/// for phase 1b (lifecycle, the `die` event stream). Shrink this list; do
|
||||
/// not grow it — new container-interior code goes through
|
||||
/// [`ContainerTarget`].
|
||||
#[test]
|
||||
fn no_container_interior_bollard_outside_the_host_layer() {
|
||||
const ALLOWED: &[&str] = &[
|
||||
// Docker-only: base-image migration replays apt / restores a payload.
|
||||
"commands/migration_commands.rs",
|
||||
// Phase 1b: lifecycle and the `die` event stream.
|
||||
"container_watch.rs",
|
||||
];
|
||||
/// Every non-`docker/`, non-`host/` source file, with its path relative to
|
||||
/// `src/`, minus the files in `allowed`.
|
||||
fn sources_outside_the_host_layer(allowed: &[&str]) -> Vec<(String, String)> {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![src.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
@@ -452,23 +485,83 @@ mod tests {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !rel.ends_with(".rs") || ALLOWED.contains(&rel.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
if text.contains("bollard::")
|
||||
|| text.contains("docker::exec")
|
||||
|| text.contains("get_docker")
|
||||
{
|
||||
offenders.push(rel);
|
||||
if rel.ends_with(".rs") && !allowed.contains(&rel.as_str()) {
|
||||
out.push((rel, std::fs::read_to_string(&path).unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
||||
offenders.sort();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Docker-only by nature: base-image migration replays apt, restores a
|
||||
/// payload, commits and swaps containers. It has no AWS meaning.
|
||||
const DOCKER_ONLY: &[&str] = &["commands/migration_commands.rs"];
|
||||
|
||||
/// The host boundary, pinned: outside `docker/` and `host/`, nothing names
|
||||
/// bollard or `docker::exec`. Container-interior code goes through
|
||||
/// [`ContainerTarget`]. Shrink [`DOCKER_ONLY`]; do not grow it.
|
||||
#[test]
|
||||
fn no_container_interior_bollard_outside_the_host_layer() {
|
||||
let offenders: Vec<String> = sources_outside_the_host_layer(DOCKER_ONLY)
|
||||
.into_iter()
|
||||
.filter(|(_, text)| {
|
||||
text.contains("bollard::")
|
||||
|| text.contains("docker::exec")
|
||||
|| text.contains("get_docker")
|
||||
})
|
||||
.map(|(rel, _)| rel)
|
||||
.collect();
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"these files reach into the container without going through host::ContainerTarget: {:?}",
|
||||
offenders
|
||||
);
|
||||
}
|
||||
|
||||
/// The lifecycle half of the boundary: outside `docker/` and `host/`, no
|
||||
/// non-test code creates, starts, stops, finds or recreates a container
|
||||
/// itself (that is [`lifecycle::ProjectHost`]'s job), and none builds a
|
||||
/// [`ContainerTarget::docker`] from a bare id, which would hard-code the
|
||||
/// host. Comments and `#[cfg(test)]` code are not checked.
|
||||
///
|
||||
/// `docker::remove_container` is deliberately absent: startup's
|
||||
/// pending-cleanup retry removes Docker leftovers it recorded *by name*,
|
||||
/// which is Docker housekeeping, not lifecycle.
|
||||
#[test]
|
||||
fn no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer() {
|
||||
const BANNED: &[&str] = &[
|
||||
"docker::create_container(",
|
||||
"docker::start_container(",
|
||||
"docker::stop_container(",
|
||||
"docker::find_existing_container(",
|
||||
"docker::container_needs_recreation(",
|
||||
"docker::commit_container_snapshot(",
|
||||
"docker::remove_snapshot_image(",
|
||||
"docker::remove_project_volumes(",
|
||||
"docker::is_container_running(",
|
||||
"container::is_container_running(",
|
||||
"ContainerTarget::docker(",
|
||||
];
|
||||
let mut offenders = Vec::new();
|
||||
for (rel, text) in sources_outside_the_host_layer(DOCKER_ONLY) {
|
||||
let code = text.split("#[cfg(test)]").next().unwrap_or("");
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
for pat in BANNED {
|
||||
if line.contains(pat) {
|
||||
offenders.push(format!("{}:{}: {}", rel, n + 1, pat));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"container lifecycle / bare Docker targets outside host::lifecycle:\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Where `container_watch` learns that a project container stopped on its
|
||||
//! own. Host-neutral shape: a reachability check, a stream of containers
|
||||
//! reported dead (replayable from a point in time), and a positive-exit check
|
||||
//! used to confirm each report before anything changes.
|
||||
//!
|
||||
//! Docker pushes `die` events. An AWS host will have no push stream from the
|
||||
//! app's point of view; its [`deaths`] will be a `DescribeTasks` poll that
|
||||
//! yields stopped tasks, which `container_watch` handles the same way because
|
||||
//! every report is confirmed with [`ContainerTarget::has_exited`] anyway.
|
||||
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use std::pin::Pin;
|
||||
|
||||
use super::ContainerTarget;
|
||||
use crate::docker;
|
||||
|
||||
/// Which host's deaths to watch. One variant today.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExitSource {
|
||||
Docker,
|
||||
}
|
||||
|
||||
pub type DeathStream = Pin<Box<dyn Stream<Item = Result<ContainerTarget, String>> + Send>>;
|
||||
|
||||
impl ExitSource {
|
||||
/// Whether the host answers at all. A `false` here is a failed connection
|
||||
/// attempt for the watcher's backoff.
|
||||
pub async fn reachable(&self) -> bool {
|
||||
match self {
|
||||
ExitSource::Docker => docker::events::ping().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Containers reported dead, replayed from `since` (unix seconds). An `Err`
|
||||
/// item, or the stream ending, means the connection is gone.
|
||||
pub fn deaths(&self, since: i64) -> DeathStream {
|
||||
match self {
|
||||
ExitSource::Docker => Box::pin(
|
||||
docker::events::container_deaths(since).map(|r| r.map(ContainerTarget::docker)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerTarget {
|
||||
/// `true` only when the host positively says the container is not running
|
||||
/// (stopped, or gone). Any failure to find out is `false` — unlike
|
||||
/// [`ContainerTarget::is_running`], whose callers want errors to read as
|
||||
/// "not running".
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ use crate::models::marketplace::{
|
||||
effective_installs, CatalogItem, ItemKind, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot,
|
||||
SyncReport,
|
||||
};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{AppSettings, Project};
|
||||
use catalog::{item_fingerprint, parse_catalog};
|
||||
use tree::GitTree;
|
||||
@@ -505,7 +506,7 @@ pub async fn sync_project(
|
||||
mgr: &MarketplaceManager,
|
||||
settings: &AppSettings,
|
||||
project: &Project,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
) -> SyncReport {
|
||||
let lock = mgr.sync_lock(&project.id);
|
||||
let _sync_guard = lock.lock().await;
|
||||
@@ -533,7 +534,7 @@ pub async fn sync_project(
|
||||
items,
|
||||
p.skipped.len()
|
||||
);
|
||||
let result = sync::sync_container(container_id, &p).await;
|
||||
let result = sync::sync_container(container, &p).await;
|
||||
sync::with_payload_skips(sync::report_from_result(result), &p.skipped)
|
||||
}
|
||||
Err(e) => sync::report_from_result(Err(e)),
|
||||
@@ -556,13 +557,16 @@ pub fn spawn_project_sync(
|
||||
mgr: Arc<MarketplaceManager>,
|
||||
settings: AppSettings,
|
||||
project: Project,
|
||||
container_id: String,
|
||||
// The target the start just produced — not `ContainerTarget::for_project`:
|
||||
// `project.container_id` is written after this is called, so it could name
|
||||
// a container that was just replaced, or none at all.
|
||||
container: ContainerTarget,
|
||||
) {
|
||||
if !should_sync(&mgr, &settings, &project) {
|
||||
return;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let report = sync_project(&mgr, &settings, &project, &container_id).await;
|
||||
let report = sync_project(&mgr, &settings, &project, &container).await;
|
||||
if !report.errors.is_empty() {
|
||||
log::warn!(
|
||||
"Marketplace sync for project {} reported errors: {:?}",
|
||||
@@ -1132,9 +1136,10 @@ mod tests {
|
||||
|
||||
let lock = mgr.sync_lock(&project.id);
|
||||
let guard = lock.lock().await;
|
||||
let nowhere = ContainerTarget::docker("no-such-container");
|
||||
let blocked = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(300),
|
||||
sync_project(&mgr, &settings, &project, "no-such-container"),
|
||||
sync_project(&mgr, &settings, &project, &nowhere),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "sync must wait while another sync holds the lock");
|
||||
@@ -1143,7 +1148,7 @@ mod tests {
|
||||
|
||||
// No Docker (or no such container) here: the failure becomes a stored
|
||||
// report instead of an error.
|
||||
let report = sync_project(&mgr, &settings, &project, "no-such-container").await;
|
||||
let report = sync_project(&mgr, &settings, &project, &nowhere).await;
|
||||
assert_eq!(report.errors.len(), 1, "{report:?}");
|
||||
assert!(!report.finished_at.is_empty());
|
||||
assert_eq!(mgr.report(&project.id), Some(report));
|
||||
|
||||
@@ -92,9 +92,11 @@ fn tail(text: &str, max: usize) -> &str {
|
||||
|
||||
/// Wait for readiness, upload the payload and the script, run the script as
|
||||
/// `claude`, and return its report.
|
||||
pub async fn sync_container(container_id: &str, payload: &Payload) -> Result<SyncReport, String> {
|
||||
let target = ContainerTarget::docker(container_id);
|
||||
wait_until_ready(&target).await?;
|
||||
pub async fn sync_container(
|
||||
target: &ContainerTarget,
|
||||
payload: &Payload,
|
||||
) -> Result<SyncReport, String> {
|
||||
wait_until_ready(target).await?;
|
||||
|
||||
let (out, code) = target
|
||||
.exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![])
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Build output of the agent crate must never enter the image build context.
|
||||
triple-c-agent/target
|
||||
@@ -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"]
|
||||
@@ -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() {
|
||||
|
||||
Generated
+1301
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user