Compare commits
30
Commits
v0.4.40-mac
...
v0.4.43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb9e51f08d | ||
|
|
74deabd630 | ||
|
|
c2aa9c3be2 | ||
|
|
36a6c869c9 | ||
|
|
114f463bf5 | ||
|
|
2a46f34fca | ||
|
|
fe889a7f16 | ||
|
|
fffc6492f1 | ||
|
|
0f638c7c4e | ||
|
|
a068ce27bc | ||
|
|
5d03bea544 | ||
|
|
d8c5b501f3 | ||
|
|
ebaafd8cfc | ||
|
|
55ed2d17d0 | ||
|
|
48c60ef89c | ||
|
|
20a25fc518 | ||
|
|
086abd5713 | ||
|
|
7c004fed46 | ||
|
|
45e55b5f7c | ||
|
|
83d63e756d | ||
|
|
cc973c8291 | ||
|
|
3ec100e06c | ||
|
|
a19643e0e8 | ||
|
|
32a4d345f2 | ||
|
|
229dc2cefc | ||
|
|
41010a9c50 | ||
|
|
737a608dd5 | ||
|
|
209b8855a4 | ||
|
|
3a4e298849 | ||
|
|
1118cfa458 |
No files matched your search
@@ -66,7 +66,13 @@ env:
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
#
|
||||
# SUSPEND skips needrestart entirely. Mode `a` (restart automatically) is not
|
||||
# safe: on a host-mode runner it restarted act-runner.service mid-job and
|
||||
# killed the job. Mode `l` (list only) is a fallback for a needrestart that
|
||||
# ignores SUSPEND; it never prompts and never restarts anything.
|
||||
NEEDRESTART_SUSPEND: "1"
|
||||
NEEDRESTART_MODE: l
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -84,6 +90,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:
|
||||
@@ -269,7 +278,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -280,8 +289,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
@@ -332,6 +341,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]
|
||||
@@ -355,7 +374,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -378,8 +397,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
|
||||
@@ -22,7 +22,13 @@ env:
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
#
|
||||
# SUSPEND skips needrestart entirely. Mode `a` (restart automatically) is not
|
||||
# safe: on a host-mode runner it restarted act-runner.service mid-job and
|
||||
# killed the job. Mode `l` (list only) is a fallback for a needrestart that
|
||||
# ignores SUSPEND; it never prompts and never restarts anything.
|
||||
NEEDRESTART_SUSPEND: "1"
|
||||
NEEDRESTART_MODE: l
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -125,7 +131,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
echo "Node.js at: $(which node)"
|
||||
@@ -147,8 +153,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -264,8 +264,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`docker/`** — Docker API layer using bollard:
|
||||
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||
- `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an
|
||||
attached exec is opened; terminal sessions and the auth bridge both go through it.
|
||||
- `exec.rs` — Docker's implementation of everything `host::ContainerTarget` does inside a
|
||||
container. `create_attached_exec_spec()` is the **single** place an attached exec is opened.
|
||||
- `image.rs` — Image build/pull with progress streaming
|
||||
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
|
||||
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
|
||||
@@ -282,6 +282,23 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
|
||||
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`
|
||||
(`Docker`, and `Agent` — reached through the in-container `triple-c-agent` by `agent.rs`, per
|
||||
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`; AWS planned —
|
||||
`docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`)
|
||||
wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`.
|
||||
`agent.rs` reuses `docker/exec.rs`'s `OneshotOutput`, host-file reader and uid probe so the two
|
||||
variants cannot drift; an `Agent` target's token never appears in `Debug` or error text.
|
||||
**Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or
|
||||
`get_docker()`** — `host::tests::no_container_interior_bollard_outside_the_host_layer` fails
|
||||
the build on it. `lifecycle.rs`'s `ProjectHost` owns create/start/stop/find/remove and
|
||||
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
|
||||
@@ -321,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
+3
@@ -6618,6 +6618,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bollard",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"fern",
|
||||
@@ -6641,6 +6642,8 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
|
||||
@@ -21,6 +21,7 @@ bollard = "0.18"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
bytes = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "6"
|
||||
@@ -32,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"
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
//! |---|---|
|
||||
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
|
||||
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
|
||||
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
|
||||
//! | Container stopped/died another way | poller's own `is_running` check → loop exits |
|
||||
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
|
||||
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
|
||||
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
|
||||
@@ -55,8 +55,7 @@ use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
|
||||
use crate::host::{ContainerTarget, PROC_NET_OUTPUT_LIMIT};
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
use proc_net::PortFamily;
|
||||
@@ -178,7 +177,7 @@ impl AuthBridgeManager {
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) {
|
||||
@@ -200,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(),
|
||||
container_id,
|
||||
container,
|
||||
epoch,
|
||||
app,
|
||||
store,
|
||||
@@ -276,7 +275,7 @@ impl AuthBridgeManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn poll_loop(
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
epoch: u64,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
@@ -301,7 +300,7 @@ async fn poll_loop(
|
||||
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
|
||||
break;
|
||||
}
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !container.is_running().await.unwrap_or(false) {
|
||||
log::info!(
|
||||
"Auth bridge: container for project {} is no longer running — tearing down",
|
||||
project_id
|
||||
@@ -327,7 +326,7 @@ async fn poll_loop(
|
||||
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||
let discovery = tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
res = exec_oneshot_limited(&container_id, "claude", cmd, PROC_NET_OUTPUT_LIMIT) => {
|
||||
res = container.exec_oneshot_limited("claude", cmd, PROC_NET_OUTPUT_LIMIT) => {
|
||||
res.map(|(out, _)| out)
|
||||
}
|
||||
};
|
||||
@@ -340,7 +339,7 @@ async fn poll_loop(
|
||||
// gateway/STT/web-terminal ports can be re-pointed while the
|
||||
// bridge is running, and a stale reservation set is a hole.
|
||||
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
||||
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||
if reconcile(&container, &discovered, &skip, &state).await {
|
||||
emit_status(&app, &project_id, &state, true).await;
|
||||
}
|
||||
}
|
||||
@@ -530,7 +529,7 @@ const MAX_CONFLICTS: usize = 32;
|
||||
/// Bring the set of host listeners in line with what the container is currently
|
||||
/// listening on. Returns whether anything the UI cares about changed.
|
||||
async fn reconcile(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
discovered: &BTreeMap<u16, PortFamily>,
|
||||
skip: &HashSet<u16>,
|
||||
state: &Arc<Mutex<BridgeState>>,
|
||||
@@ -583,7 +582,7 @@ async fn reconcile(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||
match PortForward::bind(container.clone(), port, family).await {
|
||||
Ok(forward) => {
|
||||
if st.conflicts.remove(&port).is_some() {
|
||||
log::info!("Auth bridge: host port {} became available", port);
|
||||
@@ -833,7 +832,13 @@ mod tests {
|
||||
(45000u16..45200).map(|p| (p, PortFamily::V4)).collect();
|
||||
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||
|
||||
reconcile("no-such-container", &discovered, &HashSet::new(), &state).await;
|
||||
reconcile(
|
||||
&ContainerTarget::docker("no-such-container"),
|
||||
&discovered,
|
||||
&HashSet::new(),
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut st = state.lock().await;
|
||||
assert!(
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! and stdout wired to the TCP socket. `socat` ships in the container image.
|
||||
//!
|
||||
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||
//! [`crate::host::ContainerTarget::attach_exec`], the same helper the
|
||||
//! interactive terminal sessions are built on.
|
||||
//!
|
||||
//! ## What the host listener is, and is not
|
||||
@@ -56,14 +56,12 @@
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use crate::host::{AttachedExec, ContainerTarget, ExecChunk};
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
use crate::docker::exec::{create_attached_exec, AttachedExec};
|
||||
|
||||
use super::proc_net::PortFamily;
|
||||
|
||||
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
|
||||
@@ -143,13 +141,13 @@ impl Drop for PortForward {
|
||||
}
|
||||
|
||||
impl PortForward {
|
||||
/// Bind `port` on the host loopback and start proxying into `container_id`.
|
||||
/// Bind `port` on the host loopback and start proxying into `container`.
|
||||
///
|
||||
/// The bind happens before the task is spawned, so an already-taken port is
|
||||
/// reported to the caller as an error rather than disappearing into a
|
||||
/// background task.
|
||||
pub async fn bind(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
port: u16,
|
||||
family: PortFamily,
|
||||
) -> Result<Self, std::io::Error> {
|
||||
@@ -196,7 +194,7 @@ impl PortForward {
|
||||
};
|
||||
|
||||
let target = family.socat_target(port);
|
||||
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||
let task = tokio::spawn(accept_loop(container, port, target, v4, v6));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
@@ -219,7 +217,7 @@ impl PortForward {
|
||||
/// Accept on both loopback listeners until aborted. Dropping this future drops
|
||||
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
|
||||
async fn accept_loop(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
port: u16,
|
||||
target: String,
|
||||
v4: TcpListener,
|
||||
@@ -258,7 +256,7 @@ async fn accept_loop(
|
||||
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(tunnel_connection(
|
||||
container_id.clone(),
|
||||
container.clone(),
|
||||
target.clone(),
|
||||
stream,
|
||||
port,
|
||||
@@ -288,7 +286,12 @@ async fn accept_optional(
|
||||
///
|
||||
/// Nothing is forwarded until that decision is made, so a refused request never
|
||||
/// reaches the container at all — not even a `docker exec`.
|
||||
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
|
||||
async fn tunnel_connection(
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
mut stream: TcpStream,
|
||||
port: u16,
|
||||
) {
|
||||
let head = match read_leading_bytes(&mut stream).await {
|
||||
Ok(head) => head,
|
||||
Err(e) => {
|
||||
@@ -319,7 +322,7 @@ async fn tunnel_connection(container_id: String, target: String, mut stream: Tcp
|
||||
}
|
||||
|
||||
// The bytes already off the socket go back on the wire first, byte-exact.
|
||||
tunnel_connection_with_prelude(container_id, 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.
|
||||
@@ -512,7 +515,7 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||
/// back on the wire. Passing them here keeps the byte stream exact, rather than
|
||||
/// re-serialising a parsed request.
|
||||
pub async fn tunnel_connection_with_prelude(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
stream: TcpStream,
|
||||
port: u16,
|
||||
@@ -524,7 +527,7 @@ pub async fn tunnel_connection_with_prelude(
|
||||
mut output,
|
||||
mut input,
|
||||
..
|
||||
} = match create_attached_exec(&container_id, cmd, false).await {
|
||||
} = match container.attach_exec(cmd, false).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
@@ -587,12 +590,12 @@ pub async fn tunnel_connection_with_prelude(
|
||||
// Only stdout is payload. The exec is created with tty = false
|
||||
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||
// diagnostics out of the proxied byte stream.
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if host_tx.write_all(&message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
log::debug!(
|
||||
"Auth bridge: socat stderr for port {}: {}",
|
||||
port,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
/// Marks the JSON payload in the probe's stdout, so unrelated chatter on the
|
||||
/// same stream (npm notices, Node warnings) can't be mistaken for the result.
|
||||
@@ -277,12 +277,14 @@ 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> {
|
||||
let output = exec_oneshot(
|
||||
container_id,
|
||||
vec!["node".to_string(), "-e".to_string(), PROBE.to_string()],
|
||||
)
|
||||
.await?;
|
||||
pub async fn detect(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
|
||||
let output = container
|
||||
.exec_oneshot(vec![
|
||||
"node".to_string(),
|
||||
"-e".to_string(),
|
||||
PROBE.to_string(),
|
||||
])
|
||||
.await?;
|
||||
|
||||
parse_probe_output(&output)
|
||||
}
|
||||
|
||||
@@ -67,9 +67,7 @@ use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::exec::{
|
||||
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
|
||||
};
|
||||
use crate::host::{AttachedExec, ContainerTarget, ExecSpec};
|
||||
|
||||
use super::detect::{self, PlaywrightDetection};
|
||||
|
||||
@@ -240,7 +238,7 @@ pub struct BrowserSetupOutcome {
|
||||
pub async fn install_packages(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
emit_progress(
|
||||
app,
|
||||
@@ -248,7 +246,7 @@ pub async fn install_packages(
|
||||
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||
);
|
||||
|
||||
let mut step = npm_install(app, project_id, container_id, &[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{}",
|
||||
@@ -265,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_id).await;
|
||||
let spec = pinned_playwright_spec(container).await;
|
||||
emit_progress(app, project_id, &format!("Installing {}…", spec));
|
||||
let second = npm_install(app, project_id, container_id, &[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{}",
|
||||
@@ -279,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(container_id).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
|
||||
@@ -331,7 +329,7 @@ pub async fn install_packages(
|
||||
async fn npm_install(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
specs: &[&str],
|
||||
) -> Result<StepResult, String> {
|
||||
let mut cmd = vec![
|
||||
@@ -348,7 +346,7 @@ async fn npm_install(
|
||||
run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
"claude",
|
||||
INSTALL_DIR,
|
||||
cmd,
|
||||
@@ -363,20 +361,20 @@ async fn npm_install(
|
||||
/// Read from the manifest npm just wrote rather than guessed, and falling back
|
||||
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
|
||||
/// install something, not nothing.
|
||||
async fn pinned_playwright_spec(container_id: &str) -> String {
|
||||
async fn pinned_playwright_spec(container: &ContainerTarget) -> String {
|
||||
let script = format!(
|
||||
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
|
||||
process.stdout.write(d.playwright||'');}}catch(e){{}}",
|
||||
INSTALL_DIR
|
||||
);
|
||||
let (out, _code) = exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let (out, _code) = container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let version: &str = out.trim();
|
||||
// A version, not a range or a URL: anything else goes to the fallback
|
||||
@@ -411,10 +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 before = detect::detect(container_id).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_id, &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_id,
|
||||
container,
|
||||
"root",
|
||||
"/tmp",
|
||||
vec![
|
||||
@@ -530,7 +528,7 @@ pub async fn install_browser(
|
||||
let dl = run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
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_id, &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(container_id).await?;
|
||||
let detection = detect::detect(container).await?;
|
||||
|
||||
Ok(BrowserSetupOutcome {
|
||||
detection,
|
||||
@@ -642,9 +640,12 @@ impl LibraryState {
|
||||
/// dpkg's status file. A *missing* package on such an image is simply not in
|
||||
/// any index, so apt fails, Playwright reports the failure, and this returns
|
||||
/// [`LibraryState::Unknown`] — which installs, which is the right answer.
|
||||
async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState {
|
||||
let run = exec_oneshot_as(
|
||||
container_id,
|
||||
async fn check_libraries(
|
||||
container: &ContainerTarget,
|
||||
cli: &str,
|
||||
target: BrowserTarget,
|
||||
) -> LibraryState {
|
||||
let run = container.exec_oneshot_as(
|
||||
"root",
|
||||
vec![
|
||||
"node".to_string(),
|
||||
@@ -708,7 +709,7 @@ struct LaunchVerdict {
|
||||
/// Launch the browser headless, load one page, and close it. Seconds, and it is
|
||||
/// the only thing that distinguishes "downloaded" from "usable".
|
||||
async fn verify_launch(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
detection: &PlaywrightDetection,
|
||||
target: BrowserTarget,
|
||||
) -> LaunchVerdict {
|
||||
@@ -724,8 +725,7 @@ async fn verify_launch(
|
||||
};
|
||||
let dir = manifest.trim_end_matches("package.json").trim_end_matches('/');
|
||||
|
||||
let run = exec_oneshot_as(
|
||||
container_id,
|
||||
let run = container.exec_oneshot_as(
|
||||
"claude",
|
||||
vec![
|
||||
"node".to_string(),
|
||||
@@ -884,13 +884,13 @@ impl StepResult {
|
||||
/// Run one command in the container, streaming every line it prints to the pane
|
||||
/// as `container-progress` and keeping the tail for the result.
|
||||
///
|
||||
/// Uses `create_attached_exec_as` — the single attached-exec path — rather than
|
||||
/// Uses [`ContainerTarget::attach`] — the single attached-exec path — rather than
|
||||
/// `exec_oneshot`, because these commands run for minutes and the point is that
|
||||
/// the user can watch them.
|
||||
async fn run_step(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
user: &str,
|
||||
workdir: &str,
|
||||
cmd: Vec<String>,
|
||||
@@ -900,7 +900,14 @@ async fn run_step(
|
||||
exec_id,
|
||||
mut output,
|
||||
input,
|
||||
} = create_attached_exec_as(container_id, cmd, false, user, workdir).await?;
|
||||
} = container
|
||||
.attach(
|
||||
ExecSpec::new(cmd)
|
||||
.tty(false)
|
||||
.user(user)
|
||||
.working_dir(Some(workdir)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Nothing is ever written to this exec. Closing stdin means anything that
|
||||
// would prompt sees EOF and gives up, instead of waiting for a person who
|
||||
@@ -944,7 +951,7 @@ async fn run_step(
|
||||
)),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Ok(Ok(())) => Ok(StepResult {
|
||||
exit_code: wait_for_exec_exit(&exec_id).await.unwrap_or(0),
|
||||
exit_code: container.wait_exec_exit(&exec_id).await.unwrap_or(0),
|
||||
log,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
//! | Trigger | Path |
|
||||
//! |---|---|
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_running` check |
|
||||
//! | Project deleted | supervisor's `store.get()` check |
|
||||
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
|
||||
//! | Viewer died in the container | supervisor's periodic HTTP liveness probe |
|
||||
@@ -90,8 +90,7 @@ use tokio::sync::{watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::auth_bridge::proc_net::{self, PortFamily};
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
use detect::PlaywrightDetection;
|
||||
@@ -282,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> {
|
||||
@@ -300,7 +299,7 @@ impl BrowserViewManager {
|
||||
return Ok(self.status(&project_id, true).await);
|
||||
}
|
||||
|
||||
let detection = detect::detect(&container_id).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()
|
||||
@@ -320,15 +319,15 @@ impl BrowserViewManager {
|
||||
// Doing this before choosing a port also frees the one a previous
|
||||
// session was using, so sessions don't walk up the range. Best-effort:
|
||||
// a container with no dashboard makes this a no-op.
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
let _ = kill_dashboard(&container, &cli_entry).await;
|
||||
|
||||
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
|
||||
let (container_port, entry_path) = start_viewer(&container, &cli_entry).await?;
|
||||
|
||||
let token = generate_token();
|
||||
// `--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(),
|
||||
@@ -337,7 +336,7 @@ impl BrowserViewManager {
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
let _ = kill_dashboard(&container, &cli_entry).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -354,7 +353,7 @@ impl BrowserViewManager {
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let supervisor = tokio::spawn(supervise(
|
||||
project_id.clone(),
|
||||
container_id.clone(),
|
||||
container,
|
||||
cli_entry,
|
||||
container_port,
|
||||
epoch,
|
||||
@@ -427,7 +426,7 @@ impl BrowserViewManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn supervise(
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
cli_entry: String,
|
||||
container_port: u16,
|
||||
epoch: u64,
|
||||
@@ -443,7 +442,7 @@ async fn supervise(
|
||||
log::info!("Browser view: project {} is gone — tearing down", project_id);
|
||||
break;
|
||||
}
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !container.is_running().await.unwrap_or(false) {
|
||||
log::info!(
|
||||
"Browser view: container for project {} is no longer running — tearing down",
|
||||
project_id
|
||||
@@ -459,7 +458,7 @@ async fn supervise(
|
||||
// view never waits out an in-flight exec.
|
||||
let alive = tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
res = probe_entry_path(&container_id, container_port) => res.is_ok(),
|
||||
res = probe_entry_path(&container, container_port) => res.is_ok(),
|
||||
};
|
||||
if !alive {
|
||||
log::warn!(
|
||||
@@ -477,7 +476,7 @@ async fn supervise(
|
||||
}
|
||||
|
||||
proxy.shutdown().await;
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
let _ = kill_dashboard(&container, &cli_entry).await;
|
||||
|
||||
// Deregister, unless a newer session has already taken this project's slot.
|
||||
let superseded = {
|
||||
@@ -538,7 +537,11 @@ const VIEWER_LOG: &str = "/tmp/triple-c-browser-view.log";
|
||||
/// redirected to [`VIEWER_LOG`] (both so `exec_oneshot` can return immediately
|
||||
/// rather than waiting on an inherited stdout, and so a failure has a trail),
|
||||
/// and readiness is established by [`wait_until_ready`] instead.
|
||||
async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result<(), String> {
|
||||
async fn launch_viewer(
|
||||
container: &ContainerTarget,
|
||||
cli_entry: &str,
|
||||
port: u16,
|
||||
) -> Result<(), String> {
|
||||
// `NO_UPDATE_NOTIFIER` stops the CLI phoning registry.npmjs.org on every
|
||||
// launch; the container may have no egress, and we don't want to wait out a
|
||||
// DNS timeout before the dashboard binds.
|
||||
@@ -549,13 +552,11 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
|
||||
port,
|
||||
VIEWER_LOG
|
||||
);
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script],
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
|
||||
container
|
||||
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
|
||||
}
|
||||
|
||||
/// The dashboard singleton is keyed on a hash of the working directory, so
|
||||
@@ -567,17 +568,15 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
|
||||
const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true";
|
||||
|
||||
/// Stop the dashboard daemon. Verified to free the port and stop answering.
|
||||
async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result<String, String> {
|
||||
async fn kill_dashboard(container: &ContainerTarget, cli_entry: &str) -> Result<String, String> {
|
||||
let script = format!(
|
||||
"{}; NO_UPDATE_NOTIFIER=1 node {} show --kill",
|
||||
WORKDIR_PREFIX,
|
||||
shell_quote(cli_entry)
|
||||
);
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script],
|
||||
)
|
||||
.await
|
||||
container
|
||||
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Turn a failed start into something the user can act on.
|
||||
@@ -604,13 +603,16 @@ fn explain_start_failure(err: &str, log: &str) -> String {
|
||||
}
|
||||
|
||||
/// Tail of the viewer's own output, for a start that didn't come up.
|
||||
async fn read_viewer_log(container_id: &str) -> String {
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
async fn read_viewer_log(container: &ContainerTarget) -> String {
|
||||
container
|
||||
.exec_oneshot(vec![
|
||||
"tail".to_string(),
|
||||
"-n".to_string(),
|
||||
"40".to_string(),
|
||||
VIEWER_LOG.to_string(),
|
||||
])
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -645,14 +647,17 @@ const MAX_PORT_ATTEMPTS: usize = 3;
|
||||
/// this layer, and the pane would then front it. What contains that is
|
||||
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
|
||||
/// iframe is sandboxed — not this function.
|
||||
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
|
||||
async fn start_viewer(
|
||||
container: &ContainerTarget,
|
||||
cli_entry: &str,
|
||||
) -> Result<(u16, String), String> {
|
||||
let mut tried: Vec<u16> = Vec::new();
|
||||
let mut last: Option<String> = None;
|
||||
|
||||
for _ in 0..MAX_PORT_ATTEMPTS {
|
||||
// Re-read the listener snapshot each attempt: the port that was free a
|
||||
// moment ago is exactly the one we may have just lost.
|
||||
let port = match pick_viewer_port(container_id, &tried).await {
|
||||
let port = match pick_viewer_port(container, &tried).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Report why the *attempts* failed, not just "nothing free":
|
||||
@@ -666,21 +671,21 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
|
||||
};
|
||||
tried.push(port);
|
||||
|
||||
launch_viewer(container_id, cli_entry, port).await?;
|
||||
launch_viewer(container, cli_entry, port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
match wait_until_ready(container_id, port).await {
|
||||
match wait_until_ready(container, port).await {
|
||||
Ok(path) => return Ok((port, path)),
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(container_id).await;
|
||||
let log = read_viewer_log(container).await;
|
||||
// Always kill before retrying: the dashboard is a singleton, so
|
||||
// a launcher that came up on some *other* port would otherwise
|
||||
// make every further attempt a no-op that silently ignores the
|
||||
// port we asked for.
|
||||
let _ = kill_dashboard(container_id, cli_entry).await;
|
||||
let _ = kill_dashboard(container, cli_entry).await;
|
||||
last = Some(explain_start_failure(&e, &log));
|
||||
}
|
||||
}
|
||||
@@ -691,10 +696,9 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
|
||||
/// and that this start has not already tried.
|
||||
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
|
||||
let text = exec_oneshot(
|
||||
container_id,
|
||||
vec![
|
||||
async fn pick_viewer_port(container: &ContainerTarget, tried: &[u16]) -> Result<u16, String> {
|
||||
let text = container
|
||||
.exec_oneshot(vec![
|
||||
// Absolute path, deliberately, for the same reason the auth bridge
|
||||
// uses one: `container/Dockerfile` puts a container-writable
|
||||
// directory first on `PATH`, so a bare `cat` is a name the container
|
||||
@@ -704,10 +708,9 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
|
||||
"/usr/bin/cat".to_string(),
|
||||
"/proc/net/tcp".to_string(),
|
||||
"/proc/net/tcp6".to_string(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
])
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let taken = proc_net::parse_loopback_listeners(&text);
|
||||
VIEWER_PORTS
|
||||
.clone()
|
||||
@@ -722,10 +725,10 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
|
||||
}
|
||||
|
||||
/// Poll the viewer until it answers, and return the path the pane should load.
|
||||
async fn wait_until_ready(container_id: &str, port: u16) -> Result<String, String> {
|
||||
async fn wait_until_ready(container: &ContainerTarget, port: u16) -> Result<String, String> {
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let last = match probe_entry_path(container_id, port).await {
|
||||
let last = match probe_entry_path(container, port).await {
|
||||
Ok(path) => return Ok(path),
|
||||
Err(e) => e,
|
||||
};
|
||||
@@ -751,7 +754,7 @@ const PROBE_MARKER: &str = "__TRIPLE_C_BV_PATH__";
|
||||
/// a redirect — which matters, because a redirect drops the `?token=` the proxy
|
||||
/// gate wants and would leave a fresh connection to be authorised with nothing.
|
||||
/// A `200` (no redirect) is fine too; then the entry point is just `/`.
|
||||
async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, String> {
|
||||
async fn probe_entry_path(container: &ContainerTarget, port: u16) -> Result<String, String> {
|
||||
// The request is bounded on both sides. Verified: the dashboard answers a
|
||||
// bad WebSocket path by holding the socket open forever rather than
|
||||
// erroring, so "no reply" is a state this probe has to be able to leave —
|
||||
@@ -766,10 +769,7 @@ async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, Strin
|
||||
);
|
||||
let out = tokio::time::timeout(
|
||||
PROBE_TIMEOUT * 2,
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
),
|
||||
container.exec_oneshot(vec!["node".to_string(), "-e".to_string(), script]),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "the viewer probe did not return".to_string())??;
|
||||
|
||||
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::exec::exec_oneshot_as;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
use super::detect::PlaywrightDetection;
|
||||
|
||||
@@ -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,52 +150,52 @@ pub async fn open(
|
||||
shell_quote(&script),
|
||||
HELPER_LOG
|
||||
);
|
||||
exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||
container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.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, _)) = exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
let Ok((out, _)) = container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return PageState::default();
|
||||
};
|
||||
@@ -204,20 +204,20 @@ 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
|
||||
);
|
||||
exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||
container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||
}
|
||||
|
||||
/// Wait for a *running* helper to report the URL we just asked it for.
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -76,8 +76,7 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
|
||||
use crate::host::{AttachedExec, ContainerTarget};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -867,7 +866,7 @@ async fn type_code_then_enter<W: tokio::io::AsyncWrite + Unpin>(
|
||||
async fn run_setup_token(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
target: &ContainerTarget,
|
||||
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
mut cancel_rx: oneshot::Receiver<()>,
|
||||
) -> Result<String, String> {
|
||||
@@ -877,16 +876,16 @@ async fn run_setup_token(
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = create_attached_exec(
|
||||
container_id,
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
SETUP_TOKEN_SCRIPT.to_string(),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
} = target
|
||||
.attach_exec(
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
SETUP_TOKEN_SCRIPT.to_string(),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut stripper = AnsiStripper::default();
|
||||
let mut redactor = SecretRedactor::default();
|
||||
@@ -1041,7 +1040,7 @@ async fn run_setup_token(
|
||||
// Falling through to the token parse is right either way — a run that
|
||||
// printed a token succeeded whatever Docker says about it — but it is worth
|
||||
// saying so rather than silently calling it a clean exit.
|
||||
match wait_for_exec_exit(&exec_id).await {
|
||||
match target.wait_exec_exit(&exec_id).await {
|
||||
Some(0) => {}
|
||||
Some(code) => {
|
||||
return Err(format!(
|
||||
@@ -1091,13 +1090,13 @@ pub async fn acquire_claude_token(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project.container_id.clone().ok_or_else(|| {
|
||||
let target = ContainerTarget::for_project(&project).ok_or_else(|| {
|
||||
format!(
|
||||
"Project '{}' has no container yet. Start it, then run authentication again.",
|
||||
project.name
|
||||
)
|
||||
})?;
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !target.is_running().await.unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"The container for '{}' is not running. Start it, then run authentication again.",
|
||||
project.name
|
||||
@@ -1133,8 +1132,7 @@ pub async fn acquire_claude_token(
|
||||
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
|
||||
);
|
||||
|
||||
let result =
|
||||
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await;
|
||||
let result = run_setup_token(&app_handle, &project_id, &target, input_rx, cancel_rx).await;
|
||||
|
||||
// Release the flow. Nothing else needs unwinding: this command changes no
|
||||
// persisted state until the token itself is stored, which is the point.
|
||||
|
||||
@@ -4,14 +4,11 @@ use std::sync::Arc;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine as _;
|
||||
use bollard::container::{DownloadFromContainerOptions, LogOutput};
|
||||
use bollard::exec::{CreateExecOptions, StartExecResults};
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, OUTPUT_LIMIT_MARKER};
|
||||
use crate::host::{ContainerTarget, ExecChunk, ExecSpec, OUTPUT_LIMIT_MARKER};
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
@@ -63,9 +60,7 @@ pub async fn list_container_files(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
// `exec_oneshot` discards the exit code, which is how a `find` that listed
|
||||
@@ -78,7 +73,8 @@ pub async fn list_container_files(
|
||||
// are the error message, its `-printf` records are the listing, and the
|
||||
// parser should never be handed the former.
|
||||
let (records, diagnostics, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", list_argv(&path), Vec::new())
|
||||
target
|
||||
.exec_oneshot_streams_as("claude", list_argv(&path), Vec::new())
|
||||
.await
|
||||
.map_err(|e| describe_listing_failure(&path, e))?;
|
||||
|
||||
@@ -156,7 +152,7 @@ fn list_argv(path: &str) -> Vec<String> {
|
||||
/// folder can act on.
|
||||
///
|
||||
/// One case is worth naming: a directory with more entries than
|
||||
/// [`crate::docker::exec::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is
|
||||
/// [`crate::host::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is
|
||||
/// the point where a `find` record set passes 8 MiB, and what the panel showed
|
||||
/// was "Command output exceeded 8388608 bytes and was abandoned" — a true
|
||||
/// statement about a buffer, and no help at all about a directory.
|
||||
@@ -430,7 +426,11 @@ pub(crate) fn validate_container_write_path(what: &str, path: &str) -> Result<()
|
||||
/// and the lexical answer stands: failing every write closed would break the
|
||||
/// panel outright for a risk the container user does not need this code path to
|
||||
/// take.
|
||||
async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Result<(), String> {
|
||||
async fn resolve_container_dir(
|
||||
target: &ContainerTarget,
|
||||
what: &str,
|
||||
dir: &str,
|
||||
) -> Result<(), String> {
|
||||
validate_container_write_path(what, dir)?;
|
||||
|
||||
// Split streams, not the combined buffer: `realpath`'s answer is a *path*
|
||||
@@ -438,18 +438,18 @@ async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Res
|
||||
// hazard the listing above took apart for `find`. A warning on stderr —
|
||||
// and there is one whenever a component is unreadable — used to be spliced
|
||||
// into the string this then compared against the write roots.
|
||||
let (stdout, diagnostics, code) = exec_oneshot_streams_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec![
|
||||
"realpath".to_string(),
|
||||
"-m".to_string(),
|
||||
"--".to_string(),
|
||||
dir.to_string(),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
let (stdout, diagnostics, code) = target
|
||||
.exec_oneshot_streams_as(
|
||||
"claude",
|
||||
vec![
|
||||
"realpath".to_string(),
|
||||
"-m".to_string(),
|
||||
"--".to_string(),
|
||||
dir.to_string(),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let resolved = stdout.trim();
|
||||
if code != 0 || resolved.is_empty() {
|
||||
@@ -1188,7 +1188,7 @@ pub(crate) struct FetchedFile {
|
||||
|
||||
/// Fetch a single regular file from a container as exact bytes.
|
||||
///
|
||||
/// The viewer's read. It deliberately goes through Docker's archive endpoint rather than
|
||||
/// The viewer's read. It deliberately goes through the archive endpoint rather than
|
||||
/// `exec_oneshot`: that reader runs every chunk through `String::from_utf8_lossy`
|
||||
/// and merges stderr into stdout, so it would both corrupt any non-UTF-8 file
|
||||
/// and be able to splice diagnostics into what the caller believes is content.
|
||||
@@ -1203,18 +1203,11 @@ pub(crate) struct FetchedFile {
|
||||
/// read — landed in host RAM twice. This function buffers, so every caller of
|
||||
/// it must name a ceiling.
|
||||
pub(crate) async fn fetch_container_file(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
container_path: &str,
|
||||
max_bytes: u64,
|
||||
) -> Result<FetchedFile, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut stream = docker.download_from_container(
|
||||
container_id,
|
||||
Some(DownloadFromContainerOptions {
|
||||
path: container_path.to_string(),
|
||||
}),
|
||||
);
|
||||
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,13 +1290,11 @@ pub async fn read_container_file(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.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(container_id, &path, cap).await?;
|
||||
let fetched = fetch_container_file(&target, &path, cap).await?;
|
||||
|
||||
Ok(FileContents {
|
||||
contents_base64: BASE64.encode(&fetched.bytes),
|
||||
@@ -1333,9 +1324,7 @@ pub async fn rename_container_path(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
// The name is checked by `validate_entry_name`; the path it is applied to
|
||||
@@ -1349,7 +1338,7 @@ pub async fn rename_container_path(
|
||||
// directory component to resolve.
|
||||
let parent = parent_dir(&from_path);
|
||||
if parent != "/" {
|
||||
resolve_container_dir(container_id, "Item", &parent).await?;
|
||||
resolve_container_dir(&target, "Item", &parent).await?;
|
||||
}
|
||||
|
||||
let new_name = to_path.trim();
|
||||
@@ -1364,8 +1353,7 @@ pub async fn rename_container_path(
|
||||
// and exits 0 — so `-n` on its own would report a rename that never
|
||||
// happened. The existence check is what turns it into an error the user
|
||||
// sees; `-n` stays as the belt-and-braces against the race between them.
|
||||
let (_, exists) = exec_oneshot_as(
|
||||
container_id,
|
||||
let (_, exists) = target.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["test".to_string(), "-e".to_string(), dest.clone()],
|
||||
Vec::new(),
|
||||
@@ -1375,8 +1363,7 @@ pub async fn rename_container_path(
|
||||
return Err(format!("\"{}\" already exists in this folder", new_name));
|
||||
}
|
||||
|
||||
let (output, code) = exec_oneshot_as(
|
||||
container_id,
|
||||
let (output, code) = target.exec_oneshot_as(
|
||||
"claude",
|
||||
vec![
|
||||
"mv".to_string(),
|
||||
@@ -1417,19 +1404,16 @@ pub async fn create_container_directory(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
resolve_container_dir(container_id, "Folder", &parent_path).await?;
|
||||
resolve_container_dir(&target, "Folder", &parent_path).await?;
|
||||
|
||||
let name = name.trim();
|
||||
validate_entry_name(name)?;
|
||||
let dest = join_path(&parent_path, name);
|
||||
|
||||
let (output, code) = exec_oneshot_as(
|
||||
container_id,
|
||||
let (output, code) = target.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["mkdir".to_string(), "--".to_string(), dest.clone()],
|
||||
Vec::new(),
|
||||
@@ -1459,20 +1443,19 @@ pub(crate) fn not_running_message(action: &str, why: &str) -> String {
|
||||
/// Refuse, in a sentence, before a Docker error has to speak for us.
|
||||
///
|
||||
/// Both file transfers and the backup run through `docker exec`, which needs a
|
||||
/// running container. Without this the failure surfaces as bollard's
|
||||
/// running container. Without this the failure surfaces as Docker's
|
||||
/// `is not running` wrapped in whatever the caller was doing, and for the
|
||||
/// 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.
|
||||
pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
let running = docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info| info.state)
|
||||
.and_then(|s| s.running)
|
||||
.unwrap_or(false);
|
||||
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 = container.is_running().await?;
|
||||
if running {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1543,12 +1526,10 @@ pub async fn download_container_file(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
require_running(container_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:
|
||||
@@ -1564,8 +1545,6 @@ pub async fn download_container_file(
|
||||
// costs. See the note on `pick_save_path` about which ones those are.
|
||||
let dest = resolve_host_path(&chosen, HostPathUse::WriteChosenName).await?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
// `$TC_SRC`, never argv interpolation: the path reaches the shell as an
|
||||
// environment value, so a name containing a quote or a `$` is data.
|
||||
//
|
||||
@@ -1627,30 +1606,19 @@ esac
|
||||
echo "TC_SIZE=$SZ" >&2
|
||||
dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(vec!["sh".to_string(), "-c".to_string(), script.to_string()]),
|
||||
env: Some(vec![format!("TC_SRC={}", container_path)]),
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
// As `claude`, no TTY, no stdin and no working directory (the image's
|
||||
// default) — the options this exec has always been created with.
|
||||
let exec = target
|
||||
.attach(
|
||||
ExecSpec::new(vec!["sh".to_string(), "-c".to_string(), script.to_string()])
|
||||
.env(vec![format!("TC_SRC={}", container_path)])
|
||||
.working_dir(None)
|
||||
.no_stdin(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start download: {}", e))?;
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec.id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start download: {}", e))?;
|
||||
|
||||
let mut output = match result {
|
||||
StartExecResults::Attached { output, .. } => output,
|
||||
StartExecResults::Detached => return Err("Download exec started detached".to_string()),
|
||||
};
|
||||
let exec_id = exec.exec_id;
|
||||
let mut output = exec.output;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let partial = partial_download_path(&dest)?;
|
||||
@@ -1696,7 +1664,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if let Err(e) = writer.write_all(&message).await {
|
||||
stream_err = Some(format!("Failed to write {}: {}", dest.display(), e));
|
||||
break;
|
||||
@@ -1727,7 +1695,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
push_capped(&mut stderr_text, &message);
|
||||
if declared.is_none() {
|
||||
declared = parse_declared_size(&stderr_text);
|
||||
@@ -1760,7 +1728,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
// success renames a truncated partial over the file the user already had
|
||||
// and reports the byte count as if it were the whole thing. That is the one
|
||||
// way this command could destroy data, so silence is failure.
|
||||
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
||||
let exit_code = target.wait_exec_exit(&exec_id).await;
|
||||
if stream_err.is_none() && exit_code != Some(0) {
|
||||
// Framed and clipped, never verbatim.
|
||||
//
|
||||
@@ -1894,7 +1862,7 @@ pub struct UploadOutcome {
|
||||
/// * the destination goes through [`resolve_container_dir`], so it is inside
|
||||
/// [`CONTAINER_WRITE_ROOTS`] both as written and as it resolves;
|
||||
/// * the read, the size ceiling and the descriptor check are
|
||||
/// `docker::exec::upload_host_file_with_ids`', unchanged — including
|
||||
/// `ContainerTarget::upload_host_file_with_ids`', unchanged — including
|
||||
/// landing the file owned by the container user rather than root.
|
||||
///
|
||||
/// The dialog itself is opened from Rust ([`pick_files_to_upload`]), which is
|
||||
@@ -1911,15 +1879,13 @@ pub async fn upload_files_to_container(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
// 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(container_id, "uploading files").await?;
|
||||
resolve_container_dir(container_id, "Upload", &container_dir).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 {
|
||||
return Ok(None);
|
||||
@@ -1930,14 +1896,14 @@ pub async fn upload_files_to_container(
|
||||
// files; this one is the check that actually guards the extraction. A modal
|
||||
// has no time limit, and `resolve_container_dir` answers a question about
|
||||
// the container's filesystem — which the container is free to change while
|
||||
// the dialog is open. Without this, `docker::exec`'s doc comment claim that
|
||||
// the dialog is open. Without this, the uploader's doc comment claim that
|
||||
// the destination "has already been confirmed" would be true only of a
|
||||
// moment that had passed.
|
||||
resolve_container_dir(container_id, "Upload", &container_dir).await?;
|
||||
resolve_container_dir(&target, "Upload", &container_dir).await?;
|
||||
|
||||
// Once for the whole selection, not once per file — see
|
||||
// `upload_host_file_with_ids`.
|
||||
let ids = crate::docker::exec::container_user_ids(container_id).await;
|
||||
let ids = target.user_ids().await;
|
||||
|
||||
let mut outcome = UploadOutcome {
|
||||
uploaded: Vec::new(),
|
||||
@@ -1951,7 +1917,7 @@ pub async fn upload_files_to_container(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match upload_one(container_id, &path, &container_dir, ids).await {
|
||||
match upload_one(&target, &path, &container_dir, ids).await {
|
||||
Ok(dest) => {
|
||||
log::info!("Uploaded {} into project {} at {}", path, project_id, dest);
|
||||
outcome.uploaded.push(dest);
|
||||
@@ -1966,7 +1932,7 @@ pub async fn upload_files_to_container(
|
||||
/// naming the file, because the caller may be reporting several at once and
|
||||
/// "is a folder" on its own does not say which one.
|
||||
async fn upload_one(
|
||||
container_id: &str,
|
||||
target: &ContainerTarget,
|
||||
host_path: &str,
|
||||
container_dir: &str,
|
||||
ids: (u64, u64),
|
||||
@@ -1990,7 +1956,7 @@ async fn upload_one(
|
||||
// The ceiling is enforced against the open descriptor inside the uploader;
|
||||
// this copy of it exists so the refusal arrives as a sentence instead of
|
||||
// after a 300 MB read.
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
use crate::host::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"{} is too large to upload ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
@@ -2000,14 +1966,9 @@ async fn upload_one(
|
||||
));
|
||||
}
|
||||
|
||||
crate::docker::exec::upload_host_file_with_ids(
|
||||
container_id,
|
||||
&resolved,
|
||||
container_dir,
|
||||
&name,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
target
|
||||
.upload_host_file_with_ids(&resolved, container_dir, &name, ids)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Container-authored diagnostic text, cut down to something safe to quote.
|
||||
@@ -2274,14 +2235,10 @@ pub async fn download_container_backup(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
require_running(container_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.
|
||||
@@ -2326,33 +2283,22 @@ tar czf - --ignore-failed-read \
|
||||
|
||||
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(cmd),
|
||||
env: Some(vec![
|
||||
// As `claude`, no TTY, no stdin and no working directory (the image's
|
||||
// default) — the options this exec has always been created with.
|
||||
let exec = target
|
||||
.attach(
|
||||
ExecSpec::new(cmd)
|
||||
.env(vec![
|
||||
"HOME=/home/claude".to_string(),
|
||||
format!("TC_BACKUP_SRC={}", path),
|
||||
]),
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
])
|
||||
.working_dir(None)
|
||||
.no_stdin(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create backup exec: {}", e))?;
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec.id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start backup exec: {}", e))?;
|
||||
|
||||
let mut output = match result {
|
||||
StartExecResults::Attached { output, .. } => output,
|
||||
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
|
||||
};
|
||||
.map_err(|e| format!("Failed to start backup: {}", e))?;
|
||||
let exec_id = exec.exec_id;
|
||||
let mut output = exec.output;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let partial = partial_download_path(&dest)?;
|
||||
@@ -2401,14 +2347,14 @@ tar czf - --ignore-failed-read \
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if let Err(e) = writer.write_all(&message).await {
|
||||
stream_err = Some(format!("Failed to write backup file: {}", e));
|
||||
break;
|
||||
}
|
||||
total += message.len() as u64;
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
push_capped(&mut stderr_text, &message);
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -2430,7 +2376,7 @@ tar czf - --ignore-failed-read \
|
||||
// when `total > 0`. Poll until the exec actually reports finished so the
|
||||
// exit code is reliably populated; if it can't be determined we fall back to
|
||||
// the `total == 0` check below.
|
||||
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
||||
let exit_code = target.wait_exec_exit(&exec_id).await;
|
||||
|
||||
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
|
||||
stream_err = Some(format!(
|
||||
@@ -3564,10 +3510,11 @@ mod tests {
|
||||
|
||||
// `/workspace/escape` is under a write root as a string and is `/etc` as
|
||||
// a location.
|
||||
let escaped = resolve_container_dir(&name, "Folder", "/workspace/escape").await;
|
||||
let target = ContainerTarget::docker(name.clone());
|
||||
let escaped = resolve_container_dir(&target, "Folder", "/workspace/escape").await;
|
||||
println!("container escape: {:?}", escaped);
|
||||
assert!(escaped.is_err(), "a symlink out of /workspace was accepted");
|
||||
resolve_container_dir(&name, "Folder", "/workspace")
|
||||
resolve_container_dir(&target, "Folder", "/workspace")
|
||||
.await
|
||||
.expect("/workspace itself");
|
||||
|
||||
@@ -3575,7 +3522,7 @@ mod tests {
|
||||
// reporting itself empty — `-H` follows the start point, and only the
|
||||
// start point.
|
||||
let (records, diagnostics, code) =
|
||||
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace/link"), Vec::new())
|
||||
target.exec_oneshot_streams_as("claude", list_argv("/workspace/link"), Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
println!("link: code={} diagnostics={:?}", code, diagnostics);
|
||||
@@ -3588,7 +3535,7 @@ mod tests {
|
||||
// The link itself is still *marked* as one in its parent's listing, and
|
||||
// still navigable.
|
||||
let (records, _, _) =
|
||||
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace"), Vec::new())
|
||||
target.exec_oneshot_streams_as("claude", list_argv("/workspace"), Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let parent = parse_find_output("/workspace", &records);
|
||||
@@ -3607,7 +3554,7 @@ mod tests {
|
||||
for path in ["/workspace/broken", "/workspace/loop"] {
|
||||
let started = std::time::Instant::now();
|
||||
let (records, diagnostics, code) =
|
||||
exec_oneshot_streams_as(&name, "claude", list_argv(path), Vec::new())
|
||||
target.exec_oneshot_streams_as("claude", list_argv(path), Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let took = started.elapsed();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
//!
|
||||
//! ## Container access
|
||||
//!
|
||||
//! All work happens inside the container via the existing `docker exec`
|
||||
//! plumbing in [`crate::docker::exec`] — no second mechanism is introduced.
|
||||
//! All work happens inside the container via the existing exec plumbing,
|
||||
//! reached through [`crate::host::ContainerTarget`] — no second mechanism is
|
||||
//! introduced.
|
||||
//! The heavy lifting (walking dirs, grepping transcripts, parsing JSON with
|
||||
//! `jq`) runs *in* the container and only a small JSON summary crosses the
|
||||
//! wire, so multi-megabyte transcripts are never streamed back.
|
||||
@@ -56,14 +57,11 @@
|
||||
//! commands fail loudly, since they cannot do anything useful without a
|
||||
//! running container.
|
||||
|
||||
use bollard::exec::{CreateExecOptions, StartExecOptions};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::{exec_oneshot_env, exec_oneshot_env_status};
|
||||
use crate::docker::CONTAINER_HOME;
|
||||
use crate::host::{ContainerTarget, ExecSpec};
|
||||
use crate::AppState;
|
||||
|
||||
/// Newest N session transcripts to inspect. Caps the work done inside the
|
||||
@@ -192,7 +190,7 @@ pub struct SchedulerNotification {
|
||||
// Shared helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve a project to a *running* container id.
|
||||
/// Resolve a project to its *running* container.
|
||||
///
|
||||
/// `Ok(None)` means "there is nothing to inspect" — no container recorded, or
|
||||
/// the container exists but is stopped. Callers that are read-only turn that
|
||||
@@ -201,19 +199,19 @@ pub struct SchedulerNotification {
|
||||
async fn running_container_for(
|
||||
project_id: &str,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<Option<ContainerTarget>, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = match project.container_id {
|
||||
Some(id) => id,
|
||||
let target = match ContainerTarget::for_project(&project) {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if is_container_running(&container_id).await.unwrap_or(false) {
|
||||
Ok(Some(container_id))
|
||||
if target.is_running().await.unwrap_or(false) {
|
||||
Ok(Some(target))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -224,7 +222,7 @@ async fn running_container_for(
|
||||
async fn require_running_container(
|
||||
project_id: &str,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
running_container_for(project_id, state).await?.ok_or_else(|| {
|
||||
"Container is not running — start the project first.".to_string()
|
||||
})
|
||||
@@ -240,13 +238,13 @@ fn home_env() -> Vec<String> {
|
||||
/// the first line) so the combined stream `exec_oneshot_env` returns is pure
|
||||
/// stdout and stays parseable as JSON. A non-zero exit therefore surfaces as
|
||||
/// empty output, which the callers treat as "nothing to report".
|
||||
async fn run_script(container_id: &str, script: impl Into<String>) -> Result<String, String> {
|
||||
exec_oneshot_env(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script.into()],
|
||||
home_env(),
|
||||
)
|
||||
.await
|
||||
async fn run_script(target: &ContainerTarget, script: impl Into<String>) -> Result<String, String> {
|
||||
target
|
||||
.exec_oneshot_env(
|
||||
vec!["sh".to_string(), "-c".to_string(), script.into()],
|
||||
home_env(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Parse script output as JSON, degrading to a default value (and a log line)
|
||||
@@ -315,11 +313,11 @@ pub(crate) fn validate_session_id(id: &str) -> Result<(), String> {
|
||||
/// Run `triple-c-scheduler <args…>` as a bare argv vector — no shell is
|
||||
/// involved, so caller-supplied ids cannot be interpreted as shell syntax.
|
||||
/// Returns the combined output, erroring with it on a non-zero exit.
|
||||
async fn run_scheduler(container_id: &str, args: Vec<String>) -> Result<String, String> {
|
||||
async fn run_scheduler(target: &ContainerTarget, args: Vec<String>) -> Result<String, String> {
|
||||
let mut cmd = vec!["triple-c-scheduler".to_string()];
|
||||
cmd.extend(args);
|
||||
|
||||
let (output, exit_code) = exec_oneshot_env_status(container_id, cmd, home_env()).await?;
|
||||
let (output, exit_code) = target.exec_oneshot_env_status(cmd, home_env()).await?;
|
||||
if exit_code != 0 {
|
||||
let detail = output.trim();
|
||||
return Err(if detail.is_empty() {
|
||||
@@ -395,8 +393,8 @@ pub async fn list_claude_sessions(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ClaudeSession>, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
@@ -404,7 +402,7 @@ pub async fn list_claude_sessions(
|
||||
// a const usize — never from the caller.
|
||||
let script = SESSIONS_SCRIPT.replace("__MAX__", &MAX_SESSIONS.to_string());
|
||||
|
||||
let raw = run_script(&container_id, script).await?;
|
||||
let raw = run_script(&target, script).await?;
|
||||
let sessions: Vec<RawSession> = parse_or_default(&raw, "session list");
|
||||
|
||||
Ok(sessions
|
||||
@@ -566,12 +564,12 @@ pub async fn list_container_capabilities(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ContainerCapabilities, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(ContainerCapabilities::default()),
|
||||
};
|
||||
|
||||
let raw = run_script(&container_id, CAPABILITIES_SCRIPT).await?;
|
||||
let raw = run_script(&target, CAPABILITIES_SCRIPT).await?;
|
||||
Ok(parse_or_default(&raw, "container capabilities"))
|
||||
}
|
||||
|
||||
@@ -681,12 +679,12 @@ pub async fn list_scheduled_tasks(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ScheduledTask>, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let raw = run_script(&container_id, SCHEDULER_LIST_SCRIPT).await?;
|
||||
let raw = run_script(&target, SCHEDULER_LIST_SCRIPT).await?;
|
||||
let tasks: Vec<RawScheduledTask> = parse_or_default(&raw, "scheduled tasks");
|
||||
|
||||
Ok(tasks
|
||||
@@ -731,13 +729,13 @@ pub async fn get_scheduled_task_log(
|
||||
// Clamped, and an integer by type — cannot carry shell syntax.
|
||||
let tail = tail_lines.unwrap_or(200).clamp(1, 5000);
|
||||
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(String::new()),
|
||||
};
|
||||
|
||||
run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec![
|
||||
"logs".to_string(),
|
||||
"--id".to_string(),
|
||||
@@ -756,15 +754,15 @@ pub async fn get_scheduler_notifications(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<SchedulerNotification>, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let script =
|
||||
SCHEDULER_NOTIFICATIONS_SCRIPT.replace("__MAX__", &MAX_NOTIFICATIONS.to_string());
|
||||
|
||||
let raw = run_script(&container_id, script).await?;
|
||||
let raw = run_script(&target, script).await?;
|
||||
let notifications: Vec<RawNotification> = parse_or_default(&raw, "scheduler notifications");
|
||||
|
||||
Ok(notifications
|
||||
@@ -1140,9 +1138,9 @@ pub async fn add_scheduled_task(
|
||||
&schedule,
|
||||
working_dir.as_deref(),
|
||||
)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let output = run_scheduler(&container_id, input.add_args()).await?;
|
||||
let output = run_scheduler(&target, input.add_args()).await?;
|
||||
let task_id = parse_created_task_id(&output).ok_or_else(|| {
|
||||
format!(
|
||||
"The scheduler did not report a task id. Its output was: {}",
|
||||
@@ -1200,9 +1198,9 @@ pub async fn update_scheduled_task(
|
||||
&schedule,
|
||||
working_dir.as_deref(),
|
||||
)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let output = run_scheduler(&container_id, input.add_args()).await?;
|
||||
let output = run_scheduler(&target, input.add_args()).await?;
|
||||
let new_id = parse_created_task_id(&output).ok_or_else(|| {
|
||||
format!(
|
||||
"The scheduler did not report a task id, so the original task was left in place. Its output was: {}",
|
||||
@@ -1211,7 +1209,7 @@ pub async fn update_scheduled_task(
|
||||
})?;
|
||||
|
||||
run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["remove".to_string(), "--id".to_string(), task_id.clone()],
|
||||
)
|
||||
.await
|
||||
@@ -1224,7 +1222,7 @@ pub async fn update_scheduled_task(
|
||||
|
||||
if enabled == Some(false) {
|
||||
if let Err(e) = run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["disable".to_string(), "--id".to_string(), new_id.clone()],
|
||||
)
|
||||
.await
|
||||
@@ -1253,11 +1251,11 @@ pub async fn set_scheduled_task_enabled(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_task_id(&task_id)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let subcommand = if enabled { "enable" } else { "disable" };
|
||||
let output = run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec![subcommand.to_string(), "--id".to_string(), task_id],
|
||||
)
|
||||
.await?;
|
||||
@@ -1277,39 +1275,20 @@ pub async fn run_scheduled_task_now(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_task_id(&task_id)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
&container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(false),
|
||||
attach_stderr: Some(false),
|
||||
// Argv vector — no shell, so `task_id` is inert as data.
|
||||
cmd: Some(vec![
|
||||
"triple-c-scheduler".to_string(),
|
||||
"run".to_string(),
|
||||
"--id".to_string(),
|
||||
task_id.clone(),
|
||||
]),
|
||||
env: Some(home_env()),
|
||||
user: Some("claude".to_string()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
|
||||
docker
|
||||
.start_exec(
|
||||
&exec.id,
|
||||
Some(StartExecOptions {
|
||||
detach: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
// Argv vector — no shell, so `task_id` is inert as data. Stdin, stdout and
|
||||
// stderr are all left unattached; the exec is started detached.
|
||||
let spec = ExecSpec::new(vec![
|
||||
"triple-c-scheduler".to_string(),
|
||||
"run".to_string(),
|
||||
"--id".to_string(),
|
||||
task_id.clone(),
|
||||
])
|
||||
.env(home_env())
|
||||
.no_stdin();
|
||||
let exec_id = target
|
||||
.exec_detached(spec)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start task: {}", e))?;
|
||||
|
||||
@@ -1317,7 +1296,7 @@ pub async fn run_scheduled_task_now(
|
||||
"Triggered scheduler task {} in project {} (detached exec {})",
|
||||
task_id,
|
||||
project_id,
|
||||
exec.id
|
||||
exec_id
|
||||
);
|
||||
Ok(format!("Task {} started.", task_id))
|
||||
}
|
||||
@@ -1330,10 +1309,10 @@ pub async fn remove_scheduled_task(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_task_id(&task_id)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let output = run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["remove".to_string(), "--id".to_string(), task_id],
|
||||
)
|
||||
.await?;
|
||||
@@ -1346,9 +1325,9 @@ pub async fn clear_scheduler_notifications(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["notifications".to_string(), "--clear".to_string()],
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashSet};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::marketplace::{self as mk, auth, diff, gh_login, git, MarketplaceManager};
|
||||
use crate::models::marketplace::{
|
||||
is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate,
|
||||
@@ -944,11 +944,11 @@ pub async fn apply_marketplace_now(
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
for p in projects {
|
||||
let running = match &p.container_id {
|
||||
Some(cid) => is_container_running(cid).await.unwrap_or(false),
|
||||
None => false,
|
||||
let running = match ContainerTarget::for_project(&p) {
|
||||
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 }),
|
||||
@@ -1051,13 +1050,13 @@ pub async fn start_marketplace_gh_container_login(
|
||||
return Err("Signing in through a container needs a host name without a port.".to_string());
|
||||
}
|
||||
let project = find_project(&state, &project_id)?;
|
||||
let container_id = project.container_id.clone().ok_or_else(|| {
|
||||
let target = ContainerTarget::for_project(&project).ok_or_else(|| {
|
||||
format!(
|
||||
"\"{}\" has no container yet. Start it, then try again.",
|
||||
project.name
|
||||
)
|
||||
})?;
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !target.is_running().await.unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"\"{}\" is not running. Start it, then try again.",
|
||||
project.name
|
||||
@@ -1070,7 +1069,7 @@ pub async fn start_marketplace_gh_container_login(
|
||||
}
|
||||
let account_id = uuid::Uuid::new_v4().to_string();
|
||||
let result =
|
||||
gh_login::run_gh_container_login(&app_handle, &account_id, &container_id, &host, rx).await;
|
||||
gh_login::run_gh_container_login(&app_handle, &account_id, &target, &host, rx).await;
|
||||
// `rx` is gone now, so this frees our slot and never a newer login's.
|
||||
state.marketplace.release_gh_login().await;
|
||||
let token = result?;
|
||||
|
||||
@@ -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;
|
||||
@@ -1860,7 +1853,7 @@ async fn stage_payload(
|
||||
project_id: &str,
|
||||
verbatim: &[String],
|
||||
) -> Result<String, String> {
|
||||
use bollard::container::LogOutput;
|
||||
use crate::host::ExecChunk;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
@@ -1913,14 +1906,14 @@ async fn stage_payload(
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if let Err(e) = writer.write_all(&message).await {
|
||||
stream_err = Some(format!("Failed to write the staging file: {}", e));
|
||||
break;
|
||||
}
|
||||
total += message.len() as u64;
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
stderr_text.push_str(&String::from_utf8_lossy(&message));
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -1979,7 +1972,7 @@ async fn stage_payload(
|
||||
/// anything already present in the base, so collisions should be rare either
|
||||
/// way.)
|
||||
async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), String> {
|
||||
use bollard::container::LogOutput;
|
||||
use crate::host::ExecChunk;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
@@ -2007,7 +2000,7 @@ async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), Stri
|
||||
let mut text = String::new();
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdErr { message }) | Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) | Ok(ExecChunk::StdOut(message)) => {
|
||||
text.push_str(&String::from_utf8_lossy(&message))
|
||||
}
|
||||
Ok(_) => {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project};
|
||||
use crate::AppState;
|
||||
|
||||
@@ -159,9 +160,7 @@ pub async fn open_terminal_session(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
if let Some(id) = resume_session_id.as_deref() {
|
||||
@@ -189,7 +188,7 @@ pub async fn open_terminal_session(
|
||||
state
|
||||
.exec_manager
|
||||
.create_session(
|
||||
container_id,
|
||||
&target,
|
||||
&session_id,
|
||||
cmd,
|
||||
move |data| {
|
||||
@@ -240,7 +239,7 @@ pub async fn paste_image_to_terminal(
|
||||
image_data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
let target = state.exec_manager.get_target(&session_id).await?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -250,7 +249,7 @@ pub async fn paste_image_to_terminal(
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||
.write_file_to_container(&target, &file_name, &image_data)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -283,7 +282,7 @@ pub async fn upload_host_file_to_terminal(
|
||||
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
||||
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
||||
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
let target = state.exec_manager.get_target(&session_id).await?;
|
||||
|
||||
let meta = tokio::fs::metadata(&host_path)
|
||||
.await
|
||||
@@ -313,7 +312,7 @@ pub async fn upload_host_file_to_terminal(
|
||||
// with the code that does the reading, which re-applies it to the open
|
||||
// descriptor — this check is here only so the refusal reads like a sentence
|
||||
// instead of arriving after a 300 MB read.
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
use crate::host::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
@@ -326,20 +325,18 @@ pub async fn upload_host_file_to_terminal(
|
||||
|
||||
// Ensure the destination directory exists rather than relying on Docker's
|
||||
// archive extractor to create the parent for the uploaded tar entry.
|
||||
crate::docker::exec::exec_oneshot(
|
||||
&container_id,
|
||||
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
|
||||
)
|
||||
.await?;
|
||||
target
|
||||
.exec_oneshot(vec![
|
||||
"mkdir".to_string(),
|
||||
"-p".to_string(),
|
||||
"/tmp/triple-c-drops".to_string(),
|
||||
])
|
||||
.await?;
|
||||
|
||||
let file_name = format!("triple-c-drops/{}", base);
|
||||
crate::docker::exec::upload_host_file_to_container(
|
||||
&container_id,
|
||||
&host_path,
|
||||
"/tmp",
|
||||
&file_name,
|
||||
)
|
||||
.await
|
||||
target
|
||||
.upload_host_file(&host_path, "/tmp", &file_name)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -347,8 +344,8 @@ pub async fn start_audio_bridge(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Get container_id from the terminal session
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
// Get the container from the terminal session
|
||||
let target = state.exec_manager.get_target(&session_id).await?;
|
||||
|
||||
// Create audio bridge exec session with ID "audio-{session_id}"
|
||||
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
|
||||
@@ -362,7 +359,7 @@ pub async fn start_audio_bridge(
|
||||
state
|
||||
.exec_manager
|
||||
.create_session_with_tty(
|
||||
&container_id,
|
||||
&target,
|
||||
&audio_session_id,
|
||||
cmd,
|
||||
false,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+222
-353
@@ -1,58 +1,44 @@
|
||||
use bollard::container::{LogOutput, UploadToContainerOptions};
|
||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use futures_util::StreamExt;
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::host::{ExecChunk, ExecSpec};
|
||||
|
||||
/// A `docker exec` that has been created and started with stdin/stdout/stderr
|
||||
/// attached — the raw duplex halves, before any policy about what to do with
|
||||
/// them.
|
||||
///
|
||||
/// This is the single place in the codebase that knows how to open an attached
|
||||
/// exec. Both consumers are built on it:
|
||||
/// * [`ExecSessionManager`] — interactive terminals and the audio bridge,
|
||||
/// which pump bytes through mpsc channels and a callback.
|
||||
/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes
|
||||
/// straight between a host TCP socket and these halves.
|
||||
///
|
||||
/// With `tty = false` the output stream is demultiplexed by Docker, so the
|
||||
/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That
|
||||
/// distinction matters for the auth bridge: `socat`'s diagnostics must not be
|
||||
/// spliced into the proxied byte stream.
|
||||
pub struct AttachedExec {
|
||||
pub exec_id: String,
|
||||
pub output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
|
||||
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||
}
|
||||
// `AttachedExec` lives in `host` now (host-neutral output chunks); this
|
||||
// module's job is to produce one from Docker. Its consumers are
|
||||
// `host::sessions::ExecSessionManager` (terminals, audio bridge) and the auth
|
||||
// bridge's per-connection `socat` tunnels, both through `host::ContainerTarget`.
|
||||
// With `tty = false` Docker demultiplexes stdout from stderr, which the auth
|
||||
// bridge relies on: `socat`'s diagnostics must not be spliced into the proxied
|
||||
// byte stream.
|
||||
pub use crate::host::AttachedExec;
|
||||
|
||||
/// Create and start an exec with stdin + stdout + stderr attached, returning the
|
||||
/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec
|
||||
/// this app opens.
|
||||
pub async fn create_attached_exec(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
) -> Result<AttachedExec, String> {
|
||||
create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await
|
||||
}
|
||||
|
||||
/// [`create_attached_exec`] with the user and working directory spelled out.
|
||||
/// An attached exec with the user and working directory spelled out.
|
||||
///
|
||||
/// Only base-image migration needs this: replaying `apt` and unpacking a
|
||||
/// payload tar at `/` have to run as **root**, and every other caller wants the
|
||||
/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It
|
||||
/// stays the single place an attached exec is opened.
|
||||
/// Only base-image migration calls this directly: replaying `apt` and unpacking
|
||||
/// a payload tar at `/` have to run as **root**. Everything else goes through
|
||||
/// `host::ContainerTarget::attach`.
|
||||
pub async fn create_attached_exec_as(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
user: &str,
|
||||
working_dir: &str,
|
||||
) -> Result<AttachedExec, String> {
|
||||
let spec = ExecSpec::new(cmd)
|
||||
.tty(tty)
|
||||
.user(user)
|
||||
.working_dir(Some(working_dir));
|
||||
create_attached_exec_spec(container_id, &spec).await
|
||||
}
|
||||
|
||||
/// The single place an attached exec is opened on Docker. Everything else —
|
||||
/// [`create_attached_exec_as`] and `host::ContainerTarget::attach` — is a thin
|
||||
/// wrapper over this.
|
||||
pub async fn create_attached_exec_spec(
|
||||
container_id: &str,
|
||||
spec: &ExecSpec,
|
||||
) -> Result<AttachedExec, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -60,13 +46,14 @@ pub async fn create_attached_exec_as(
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdin: Some(true),
|
||||
attach_stdin: Some(spec.attach_stdin),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(tty),
|
||||
cmd: Some(cmd),
|
||||
user: Some(user.to_string()),
|
||||
working_dir: Some(working_dir.to_string()),
|
||||
tty: Some(spec.tty),
|
||||
cmd: Some(spec.cmd.clone()),
|
||||
env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
|
||||
user: Some(spec.user.clone()),
|
||||
working_dir: spec.working_dir.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -82,244 +69,87 @@ pub async fn create_attached_exec_as(
|
||||
{
|
||||
StartExecResults::Attached { output, input } => Ok(AttachedExec {
|
||||
exec_id,
|
||||
output,
|
||||
output: Box::pin(output.map(|r| {
|
||||
r.map(log_output_to_chunk)
|
||||
.map_err(|e| e.to_string())
|
||||
})),
|
||||
input,
|
||||
}),
|
||||
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSession {
|
||||
pub exec_id: String,
|
||||
pub container_id: String,
|
||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl ExecSession {
|
||||
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
self.input_tx
|
||||
.send(data)
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
&self.exec_id,
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.try_send(());
|
||||
/// bollard's `LogOutput` → the host-neutral [`ExecChunk`]. `Console` is what
|
||||
/// Docker calls a TTY stream; it has no stderr, so it is stdout.
|
||||
pub fn log_output_to_chunk(o: LogOutput) -> ExecChunk {
|
||||
match o {
|
||||
LogOutput::StdOut { message } | LogOutput::Console { message } => ExecChunk::StdOut(message),
|
||||
LogOutput::StdErr { message } => ExecChunk::StdErr(message),
|
||||
LogOutput::StdIn { message } => ExecChunk::Other(message),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSessionManager {
|
||||
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
|
||||
/// Start a command with nothing attached and return its exec id without
|
||||
/// waiting for it.
|
||||
pub async fn exec_detached(container_id: &str, spec: &ExecSpec) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(false),
|
||||
attach_stderr: Some(false),
|
||||
tty: Some(spec.tty),
|
||||
cmd: Some(spec.cmd.clone()),
|
||||
env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
|
||||
user: Some(spec.user.clone()),
|
||||
working_dir: spec.working_dir.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
docker
|
||||
.start_exec(
|
||||
&exec.id,
|
||||
Some(bollard::exec::StartExecOptions {
|
||||
detach: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
Ok(exec.id)
|
||||
}
|
||||
|
||||
impl ExecSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_tty<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
let AttachedExec {
|
||||
pub async fn resize_exec(exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = create_attached_exec(container_id, cmd, tty).await?;
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
// Output reader task
|
||||
let session_id_clone = session_id.to_string();
|
||||
let shutdown_tx_clone = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = output.next() => {
|
||||
match msg {
|
||||
Some(Ok(output)) => {
|
||||
on_output(output.into_bytes().to_vec());
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::error!("Exec output error for {}: {}", session_id_clone, e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
log::info!("Exec output stream ended for {}", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
log::info!("Exec session {} shutting down", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
on_exit();
|
||||
let _ = shutdown_tx_clone;
|
||||
});
|
||||
|
||||
// Input writer task
|
||||
tokio::spawn(async move {
|
||||
while let Some(data) = input_rx.recv().await {
|
||||
if let Err(e) = input.write_all(&data).await {
|
||||
log::error!("Failed to write to exec stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let session = ExecSession {
|
||||
exec_id,
|
||||
container_id: container_id.to_string(),
|
||||
input_tx,
|
||||
shutdown_tx,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id.to_string(), session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.send_input(data).await
|
||||
}
|
||||
|
||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
// Clone the exec_id under the lock, then drop the lock before the
|
||||
// async Docker API call to avoid holding the mutex across await.
|
||||
let exec_id = {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.exec_id.clone()
|
||||
};
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
&exec_id,
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
pub async fn close_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.remove(session_id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_sessions_for_container(&self, container_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let ids_to_close: Vec<String> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| s.container_id == container_id)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids_to_close {
|
||||
if let Some(session) = sessions.remove(&id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_all_sessions(&self) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
for (_, session) in sessions.drain() {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
Ok(session.container_id.clone())
|
||||
}
|
||||
|
||||
pub async fn write_file_to_container(
|
||||
&self,
|
||||
container_id: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Owned by the container user, stamped now: a default tar header would
|
||||
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||
let (uid, gid) = container_user_ids(container_id).await;
|
||||
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/tmp".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("/tmp/{}", file_name))
|
||||
}
|
||||
/// Docker's archive API: `path` as a tar stream. Errors are stringified so the
|
||||
/// stream is host-neutral.
|
||||
pub fn download_archive(container_id: &str, path: &str) -> crate::host::ArchiveStream {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Box::pin(futures_util::stream::once(async move { Err(e) })),
|
||||
};
|
||||
let stream = docker.download_from_container(
|
||||
container_id,
|
||||
Some(bollard::container::DownloadFromContainerOptions {
|
||||
path: path.to_string(),
|
||||
}),
|
||||
);
|
||||
Box::pin(stream.map(|r| r.map_err(|e| e.to_string())))
|
||||
}
|
||||
|
||||
/// Ceiling on one host file packed into a container upload.
|
||||
@@ -376,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))??;
|
||||
@@ -421,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('/'))
|
||||
}
|
||||
|
||||
@@ -444,14 +284,22 @@ pub async fn upload_bytes_to_container(
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
) -> Result<String, String> {
|
||||
upload_bytes_with_ids(container_id, dest_dir, file_name, data, mode, (0, 0)).await
|
||||
}
|
||||
|
||||
/// [`upload_bytes_to_container`] with the tar header's owner set, stamped now.
|
||||
pub async fn upload_bytes_with_ids(
|
||||
container_id: &str,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Root-owned on purpose: migration's `tar -T` list is read back as root,
|
||||
// and the marketplace sync uploads into a `claude`-owned directory it
|
||||
// prepares first, so `claude` can still read and delete the files. The
|
||||
// mtime still gets stamped so the file doesn't read as 1970.
|
||||
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
|
||||
let tar_buf = build_single_file_tar(file_name, data, mode, uid, gid, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
@@ -524,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),
|
||||
@@ -657,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]);
|
||||
@@ -680,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 {
|
||||
@@ -749,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 {
|
||||
@@ -758,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)),
|
||||
}
|
||||
@@ -785,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.
|
||||
@@ -824,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) {
|
||||
@@ -843,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]
|
||||
@@ -925,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;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::docker::exec::exec_oneshot_streams_as;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct ViewerPoll {
|
||||
@@ -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,8 +51,9 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result<Viewe
|
||||
"poll".to_string(),
|
||||
container_path.to_string(),
|
||||
];
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
let (stdout, stderr, code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
if code != 0 && code != 4 {
|
||||
return Err(format!(
|
||||
"Could not check the file: {}",
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
//! hold the resolved path, not the one that was clicked.
|
||||
|
||||
use crate::commands::file_commands::validate_container_path;
|
||||
use crate::docker::exec::exec_oneshot_streams_as;
|
||||
use crate::docker::CONTAINER_HOME;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
pub const MAX_CANDIDATES: usize = 16;
|
||||
const MAX_RAW_LEN: usize = 4096;
|
||||
@@ -131,13 +131,14 @@ 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) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
let (stdout, _stderr, _code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
Ok(parse_probe_output(&stdout))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::commands::file_commands::clip_container_text;
|
||||
use crate::docker::exec::{exec_oneshot_streams_as, ExecSessionManager};
|
||||
use crate::host::{ContainerTarget, ExecSessionManager};
|
||||
|
||||
/// Spec §4/§5: only untruncated (≤ 1 MiB) text is editable, so nothing larger is saved.
|
||||
pub const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||||
@@ -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],
|
||||
@@ -181,11 +181,12 @@ pub async fn write_file(
|
||||
check_write_input(bytes.len(), base_hash)?;
|
||||
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
|
||||
let tmp_path = exec_manager
|
||||
.write_file_to_container(container_id, &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) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
let (stdout, stderr, code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
saved_file(classify_write(code, &stdout, &stderr), bytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -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...",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Where a project's container runs, and the one interface the rest of the app
|
||||
//! uses to reach inside it.
|
||||
//!
|
||||
//! Spec: `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md` ("Host
|
||||
//! abstraction"). This is phase 1 of that plan: every operation that happens
|
||||
//! *inside* a project container — one-shot and attached execs, file transfer,
|
||||
//! "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.
|
||||
//! [`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
|
||||
//!
|
||||
//! The set of hosts is closed and known at compile time, every method is async,
|
||||
//! and a `dyn` async trait would need boxing on every call (or the
|
||||
//! `async-trait` crate) for no benefit. Adding a host means adding a variant and
|
||||
//! letting the compiler point at every `match` that needs an arm.
|
||||
//!
|
||||
//! ## What does *not* go through here
|
||||
//!
|
||||
//! Things that only make sense for the local Docker host: image build/pull,
|
||||
//! base-image migration, snapshot commits, volumes, the LiteLLM gateway and STT
|
||||
//! sidecars, and (until phase 1b) container create/start/stop and the `die`
|
||||
//! event stream. Those keep calling `crate::docker` directly. The
|
||||
//! `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;
|
||||
use futures_util::Stream;
|
||||
use std::pin::Pin;
|
||||
use tokio::io::AsyncWrite;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::Project;
|
||||
|
||||
/// One chunk of output from an exec, already demultiplexed.
|
||||
///
|
||||
/// Host-neutral stand-in for bollard's `LogOutput`. With `tty = true` there is
|
||||
/// no stdout/stderr split and everything arrives as [`ExecChunk::StdOut`] (Docker
|
||||
/// reports a TTY stream as `Console`, which maps here too — consumers that cared
|
||||
/// treated the two the same).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExecChunk {
|
||||
StdOut(Bytes),
|
||||
StdErr(Bytes),
|
||||
/// stdin echo or anything else a host might surface. Consumers ignore it.
|
||||
Other(Bytes),
|
||||
}
|
||||
|
||||
impl ExecChunk {
|
||||
pub fn into_bytes(self) -> Bytes {
|
||||
match self {
|
||||
ExecChunk::StdOut(b) | ExecChunk::StdErr(b) | ExecChunk::Other(b) => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ExecOutputStream = Pin<Box<dyn Stream<Item = Result<ExecChunk, String>> + Send>>;
|
||||
|
||||
/// An exec that has been created and started with its streams attached.
|
||||
///
|
||||
/// `input` is a sink that is never read when the exec was opened with
|
||||
/// `attach_stdin: false`.
|
||||
pub struct AttachedExec {
|
||||
pub exec_id: String,
|
||||
pub output: ExecOutputStream,
|
||||
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||
}
|
||||
|
||||
/// Everything about an attached exec except where it runs.
|
||||
///
|
||||
/// Defaults match what this app has always used: run as `claude` in
|
||||
/// `/workspace`, no TTY, stdin attached, no extra env.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecSpec {
|
||||
pub cmd: Vec<String>,
|
||||
pub user: String,
|
||||
pub working_dir: Option<String>,
|
||||
pub env: Vec<String>,
|
||||
pub tty: bool,
|
||||
pub attach_stdin: bool,
|
||||
}
|
||||
|
||||
impl ExecSpec {
|
||||
pub fn new(cmd: Vec<String>) -> Self {
|
||||
Self {
|
||||
cmd,
|
||||
user: "claude".to_string(),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
env: Vec::new(),
|
||||
tty: false,
|
||||
attach_stdin: true,
|
||||
}
|
||||
}
|
||||
pub fn user(mut self, user: &str) -> Self {
|
||||
self.user = user.to_string();
|
||||
self
|
||||
}
|
||||
pub fn working_dir(mut self, dir: Option<&str>) -> Self {
|
||||
self.working_dir = dir.map(str::to_string);
|
||||
self
|
||||
}
|
||||
pub fn env(mut self, env: Vec<String>) -> Self {
|
||||
self.env = env;
|
||||
self
|
||||
}
|
||||
pub fn tty(mut self, tty: bool) -> Self {
|
||||
self.tty = tty;
|
||||
self
|
||||
}
|
||||
pub fn no_stdin(mut self) -> Self {
|
||||
self.attach_stdin = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte stream of a tar archive read out of a container.
|
||||
pub type ArchiveStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>>;
|
||||
|
||||
/// A specific project container on a specific host.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
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 {
|
||||
pub fn docker(container_id: impl Into<String>) -> Self {
|
||||
ContainerTarget::Docker {
|
||||
container_id: container_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The target for a project's container, if it has one yet.
|
||||
pub fn for_project(project: &Project) -> Option<Self> {
|
||||
project.container_id.as_deref().map(Self::docker)
|
||||
}
|
||||
|
||||
/// The host-specific identifier (a Docker container id today). For logs
|
||||
/// and for keying per-container state; never parse it.
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => container_id,
|
||||
ContainerTarget::Agent(ep) => ep.base_url.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── state ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether the container is running. An inspect failure (gone, daemon
|
||||
/// down) reads as `false`, as `docker::is_container_running` always has.
|
||||
pub async fn is_running(&self) -> Result<bool, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::container::is_container_running(container_id).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::is_running(ep).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── one-shot execs ───────────────────────────────────────────────────────
|
||||
|
||||
/// Run as `claude`, return combined output. Errors on a non-UTF-8-safe
|
||||
/// transport failure only; a non-zero exit is *not* an error here.
|
||||
pub async fn exec_oneshot(&self, cmd: Vec<String>) -> Result<String, String> {
|
||||
match self {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_env(
|
||||
&self,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_env_status(
|
||||
&self,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
match self {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_as(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
match self {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_limited(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
match self {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// stdout and stderr kept apart: `(stdout, stderr, exit_code)`.
|
||||
pub async fn exec_oneshot_streams_as(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
match self {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a command and return without waiting or reading its output.
|
||||
/// Returns the exec id.
|
||||
pub async fn exec_detached(&self, spec: ExecSpec) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_detached(container_id, &spec).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_detached(ep, &spec).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── attached execs ───────────────────────────────────────────────────────
|
||||
|
||||
/// Run as `claude` in `/workspace` with stdin attached.
|
||||
pub async fn attach_exec(&self, cmd: Vec<String>, tty: bool) -> Result<AttachedExec, String> {
|
||||
self.attach(ExecSpec::new(cmd).tty(tty)).await
|
||||
}
|
||||
|
||||
pub async fn attach(&self, spec: ExecSpec) -> Result<AttachedExec, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::create_attached_exec_spec(container_id, &spec).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::attach(ep, &spec).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit code of an exec this target started, once it has finished.
|
||||
/// `None` if the host could not say.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ── files ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The container user's `(uid, gid)`, falling back to `(1000, 1000)`.
|
||||
pub async fn user_ids(&self) -> (u64, u64) {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::container_user_ids(container_id).await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::user_ids(ep).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `data` to `dest_dir/file_name` as root-owned with `mode`.
|
||||
pub async fn upload_bytes(
|
||||
&self,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_bytes_to_container(
|
||||
container_id,
|
||||
dest_dir,
|
||||
file_name,
|
||||
data,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, (0, 0)).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::upload_bytes`] with the owner set to `ids`.
|
||||
pub async fn upload_bytes_with_ids(
|
||||
&self,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_bytes_with_ids(
|
||||
container_id,
|
||||
dest_dir,
|
||||
file_name,
|
||||
data,
|
||||
mode,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, ids).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a host file in, owned by the container user.
|
||||
pub async fn upload_host_file(
|
||||
&self,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_host_file_to_container(
|
||||
container_id,
|
||||
host_path,
|
||||
dest_dir,
|
||||
dest_name,
|
||||
)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::upload_host_file`] with the owner ids already known.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
&self,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_host_file_with_ids(
|
||||
container_id,
|
||||
host_path,
|
||||
dest_dir,
|
||||
dest_name,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_host_file_with_ids(ep, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `path` out of the container as a tar stream (Docker's archive
|
||||
/// API). Dropping the stream cancels the transfer.
|
||||
pub fn download_archive(&self, path: &str) -> ArchiveStream {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::download_archive(container_id, path)
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::download_archive(ep, path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exec_spec_defaults_match_what_the_app_always_used() {
|
||||
let s = ExecSpec::new(vec!["true".into()]);
|
||||
assert_eq!(s.user, "claude");
|
||||
assert_eq!(s.working_dir.as_deref(), Some("/workspace"));
|
||||
assert!(s.env.is_empty());
|
||||
assert!(!s.tty);
|
||||
assert!(s.attach_stdin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_project_follows_the_container_id() {
|
||||
let mut p: Project = serde_json::from_value(serde_json::json!({
|
||||
"id": "p1", "name": "p", "paths": [], "container_id": null,
|
||||
"status": "stopped", "backend": "anthropic",
|
||||
"allow_docker_access": false, "full_permissions": false,
|
||||
"created_at": "now", "updated_at": "now"
|
||||
}))
|
||||
.expect("minimal project deserializes");
|
||||
assert_eq!(ContainerTarget::for_project(&p), None);
|
||||
p.container_id = Some("abc".into());
|
||||
assert_eq!(
|
||||
ContainerTarget::for_project(&p),
|
||||
Some(ContainerTarget::docker("abc"))
|
||||
);
|
||||
assert_eq!(ContainerTarget::docker("abc").id(), "abc");
|
||||
}
|
||||
|
||||
/// 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 out = Vec::new();
|
||||
let mut stack = vec![src.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let rel = path
|
||||
.strip_prefix(&src)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if path.is_dir() {
|
||||
if rel != "docker" && rel != "host" {
|
||||
stack.push(path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if rel.ends_with(".rs") && !allowed.contains(&rel.as_str()) {
|
||||
out.push((rel, std::fs::read_to_string(&path).unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
||||
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,214 @@
|
||||
//! Interactive exec sessions — terminals and the audio bridge — keyed by the
|
||||
//! frontend's session id. Host-neutral: every session holds the
|
||||
//! [`ContainerTarget`] it was opened on and does all its I/O through it.
|
||||
//! (Moved here from `docker/exec.rs` in phase 1 of the AWS host work.)
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use super::{AttachedExec, ContainerTarget};
|
||||
|
||||
pub struct ExecSession {
|
||||
pub exec_id: String,
|
||||
pub target: ContainerTarget,
|
||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl ExecSession {
|
||||
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
self.input_tx
|
||||
.send(data)
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
self.target.resize_exec(&self.exec_id, cols, rows).await
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.try_send(());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSessionManager {
|
||||
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
|
||||
}
|
||||
|
||||
impl ExecSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session<F>(
|
||||
&self,
|
||||
target: &ContainerTarget,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
self.create_session_with_tty(target, session_id, cmd, true, on_output, on_exit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_tty<F>(
|
||||
&self,
|
||||
target: &ContainerTarget,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
let AttachedExec {
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = target.attach_exec(cmd, tty).await?;
|
||||
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
// Output reader task
|
||||
let session_id_clone = session_id.to_string();
|
||||
let shutdown_tx_clone = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = output.next() => {
|
||||
match msg {
|
||||
Some(Ok(output)) => {
|
||||
on_output(output.into_bytes().to_vec());
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::error!("Exec output error for {}: {}", session_id_clone, e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
log::info!("Exec output stream ended for {}", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
log::info!("Exec session {} shutting down", session_id_clone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
on_exit();
|
||||
let _ = shutdown_tx_clone;
|
||||
});
|
||||
|
||||
// Input writer task
|
||||
tokio::spawn(async move {
|
||||
while let Some(data) = input_rx.recv().await {
|
||||
if let Err(e) = input.write_all(&data).await {
|
||||
log::error!("Failed to write to exec stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let session = ExecSession {
|
||||
exec_id,
|
||||
target: target.clone(),
|
||||
input_tx,
|
||||
shutdown_tx,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id.to_string(), session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.send_input(data).await
|
||||
}
|
||||
|
||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
// Clone the exec_id under the lock, then drop the lock before the
|
||||
// async host call to avoid holding the mutex across await.
|
||||
let (exec_id, target) = {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
(session.exec_id.clone(), session.target.clone())
|
||||
};
|
||||
target.resize_exec(&exec_id, cols, rows).await
|
||||
}
|
||||
|
||||
pub async fn close_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.remove(session_id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_sessions_for_container(&self, container_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let ids_to_close: Vec<String> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| s.target.id() == container_id)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids_to_close {
|
||||
if let Some(session) = sessions.remove(&id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_all_sessions(&self) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
for (_, session) in sessions.drain() {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// The container a session was opened on — reuse it for side-channel work
|
||||
/// on the same session (image paste, file drop, audio bridge) rather than
|
||||
/// rebuilding a target from an id, which would assume the host.
|
||||
pub async fn get_target(&self, session_id: &str) -> Result<ContainerTarget, String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
Ok(session.target.clone())
|
||||
}
|
||||
|
||||
pub async fn write_file_to_container(
|
||||
&self,
|
||||
target: &ContainerTarget,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, String> {
|
||||
// Owned by the container user, stamped now: a default tar header would
|
||||
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||
let ids = target.user_ids().await;
|
||||
target
|
||||
.upload_bytes_with_ids("/tmp", file_name, data, 0o644, ids)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod container_watch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_media;
|
||||
mod docker;
|
||||
mod host;
|
||||
pub mod file_viewer;
|
||||
mod install_helper;
|
||||
mod logging;
|
||||
@@ -22,7 +23,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use auth_bridge::AuthBridgeManager;
|
||||
use docker::exec::ExecSessionManager;
|
||||
use host::ExecSessionManager;
|
||||
use storage::projects_store::ProjectsStore;
|
||||
use storage::settings_store::SettingsStore;
|
||||
use tauri::async_runtime::JoinHandle;
|
||||
|
||||
@@ -5,16 +5,13 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY};
|
||||
use crate::docker::exec::{
|
||||
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
|
||||
};
|
||||
use crate::host::{AttachedExec, ContainerTarget, ExecChunk, ExecSpec};
|
||||
|
||||
pub const CODE_EVENT: &str = "marketplace-gh-login-code";
|
||||
pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output";
|
||||
@@ -32,8 +29,8 @@ const MAX_PENDING_LINE: usize = 4096;
|
||||
/// script around it (whose text contains the same words); errors are ignored.
|
||||
const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"];
|
||||
|
||||
/// Constant script; the host is `$1` (argv, never interpolated), because
|
||||
/// `create_attached_exec_as` takes no env.
|
||||
/// Constant script; the host is `$1` (argv, never interpolated); the attached
|
||||
/// exec is opened with no extra env.
|
||||
///
|
||||
/// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit,
|
||||
/// so the container is never left logged in. The `HUP INT TERM` trap turns a
|
||||
@@ -150,9 +147,9 @@ fn failure_tail(transcript: &str) -> String {
|
||||
|
||||
/// Pre-flight N9 / review fix 1: stop the in-container login after any
|
||||
/// failed attempt.
|
||||
async fn kill_container_login(container_id: &str) {
|
||||
async fn kill_container_login(target: &ContainerTarget) {
|
||||
let cmd = CANCEL_PKILL.iter().map(|s| s.to_string()).collect();
|
||||
let _ = exec_oneshot_as(container_id, "claude", cmd, vec![]).await;
|
||||
let _ = target.exec_oneshot_as("claude", cmd, vec![]).await;
|
||||
}
|
||||
|
||||
/// Hand `result` back, running `cleanup` first when it is a failure.
|
||||
@@ -187,7 +184,7 @@ async fn drive_login<S, W, E>(
|
||||
mut emit: E,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
S: Stream<Item = Result<LogOutput, bollard::errors::Error>> + Unpin,
|
||||
S: Stream<Item = Result<ExecChunk, String>> + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
E: FnMut(&'static str, serde_json::Value),
|
||||
{
|
||||
@@ -262,7 +259,7 @@ where
|
||||
pub async fn run_gh_container_login(
|
||||
app: &AppHandle,
|
||||
account_id: &str,
|
||||
container_id: &str,
|
||||
target: &ContainerTarget,
|
||||
host: &str,
|
||||
mut cancel: oneshot::Receiver<()>,
|
||||
) -> Result<String, String> {
|
||||
@@ -273,20 +270,20 @@ pub async fn run_gh_container_login(
|
||||
exec_id,
|
||||
output,
|
||||
input,
|
||||
} = create_attached_exec_as(
|
||||
container_id,
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
GH_LOGIN_SCRIPT.to_string(),
|
||||
"triple-c-gh-login".to_string(),
|
||||
host.to_string(),
|
||||
],
|
||||
true,
|
||||
"claude",
|
||||
"/home/claude",
|
||||
)
|
||||
.await?;
|
||||
} = target
|
||||
.attach(
|
||||
ExecSpec::new(vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
GH_LOGIN_SCRIPT.to_string(),
|
||||
"triple-c-gh-login".to_string(),
|
||||
host.to_string(),
|
||||
])
|
||||
.tty(true)
|
||||
.user("claude")
|
||||
.working_dir(Some("/home/claude")),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT;
|
||||
let result = async {
|
||||
@@ -305,7 +302,7 @@ pub async fn run_gh_container_login(
|
||||
if let Some(token) = extract_token(&transcript) {
|
||||
return Ok(token);
|
||||
}
|
||||
let status = wait_for_exec_exit(&exec_id).await;
|
||||
let status = target.wait_exec_exit(&exec_id).await;
|
||||
Err(format!(
|
||||
"gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}",
|
||||
status
|
||||
@@ -315,7 +312,7 @@ pub async fn run_gh_container_login(
|
||||
))
|
||||
}
|
||||
.await;
|
||||
cleanup_on_error(result, || kill_container_login(container_id)).await
|
||||
cleanup_on_error(result, || kill_container_login(target)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -496,23 +493,19 @@ mod tests {
|
||||
/// successful one does not.
|
||||
mod teardown {
|
||||
use super::super::*;
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::stream;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
type Frame = Result<LogOutput, bollard::errors::Error>;
|
||||
type Frame = Result<ExecChunk, String>;
|
||||
|
||||
fn out(s: &'static str) -> Frame {
|
||||
Ok(LogOutput::StdOut { message: s.into() })
|
||||
Ok(ExecChunk::StdOut(s.into()))
|
||||
}
|
||||
|
||||
fn lost() -> Frame {
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 500,
|
||||
message: "connection reset".to_string(),
|
||||
})
|
||||
Err("connection reset".to_string())
|
||||
}
|
||||
|
||||
/// Records every write separately; or fails every write.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use super::payload::Payload;
|
||||
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, upload_bytes_to_container};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::marketplace::{SkippedItem, SyncReport};
|
||||
|
||||
/// Where the payload and the script are uploaded. Owned by `claude`.
|
||||
@@ -57,10 +57,12 @@ fn run_script_env() -> Vec<String> {
|
||||
vec!["HOME=/home/claude".to_string()]
|
||||
}
|
||||
|
||||
async fn wait_until_ready(container_id: &str) -> Result<(), String> {
|
||||
async fn wait_until_ready(target: &ContainerTarget) -> Result<(), String> {
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let (_, code) = exec_oneshot_as(container_id, "root", ready_probe_cmd(), vec![]).await?;
|
||||
let (_, code) = target
|
||||
.exec_oneshot_as("root", ready_probe_cmd(), vec![])
|
||||
.await?;
|
||||
if code == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -90,35 +92,31 @@ 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> {
|
||||
wait_until_ready(container_id).await?;
|
||||
pub async fn sync_container(
|
||||
target: &ContainerTarget,
|
||||
payload: &Payload,
|
||||
) -> Result<SyncReport, String> {
|
||||
wait_until_ready(target).await?;
|
||||
|
||||
let (out, code) = exec_oneshot_as(container_id, "root", sh(PREPARE_SCRIPT), vec![]).await?;
|
||||
let (out, code) = target
|
||||
.exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![])
|
||||
.await?;
|
||||
if code != 0 {
|
||||
return Err(format!(
|
||||
"Could not prepare the container for the marketplace sync: {}",
|
||||
tail(&out, 500)
|
||||
));
|
||||
}
|
||||
upload_bytes_to_container(
|
||||
container_id,
|
||||
INCOMING_DIR,
|
||||
"payload.tar",
|
||||
&payload.tar,
|
||||
0o644,
|
||||
)
|
||||
.await?;
|
||||
upload_bytes_to_container(
|
||||
container_id,
|
||||
INCOMING_DIR,
|
||||
"sync.sh",
|
||||
SYNC_SCRIPT.as_bytes(),
|
||||
0o755,
|
||||
)
|
||||
.await?;
|
||||
target
|
||||
.upload_bytes(INCOMING_DIR, "payload.tar", &payload.tar, 0o644)
|
||||
.await?;
|
||||
target
|
||||
.upload_bytes(INCOMING_DIR, "sync.sh", SYNC_SCRIPT.as_bytes(), 0o755)
|
||||
.await?;
|
||||
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", run_script_cmd(), run_script_env()).await?;
|
||||
let (stdout, stderr, code) = target
|
||||
.exec_oneshot_streams_as("claude", run_script_cmd(), run_script_env())
|
||||
.await?;
|
||||
parse_report(&stdout).map_err(|e| {
|
||||
format!(
|
||||
"The marketplace sync script failed (exit {code}): {e}. {}",
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::watch;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::docker::exec::ExecSessionManager;
|
||||
use crate::host::ExecSessionManager;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
use crate::storage::settings_store::SettingsStore;
|
||||
|
||||
|
||||
@@ -295,9 +295,7 @@ async fn handle_open(
|
||||
return Err(format!("Project '{}' is not running", project.name));
|
||||
}
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let container = crate::host::ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = match session_type {
|
||||
@@ -329,7 +327,13 @@ async fn handle_open(
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.create_session(container_id, &session_id, cmd, on_output, on_exit)
|
||||
.create_session(
|
||||
&container,
|
||||
&session_id,
|
||||
cmd,
|
||||
on_output,
|
||||
on_exit,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Track this session for cleanup on disconnect
|
||||
|
||||
@@ -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,625 @@
|
||||
# Triple-C on AWS (ECS on EC2 / Fargate) — design
|
||||
|
||||
Date: 2026-11-13
|
||||
Status: **draft for review** (user decisions recorded 2026-11-13). The user set the direction (local or AWS, login like the AWS CLI,
|
||||
permission check, hosting options, secrets pushed automatically, shared accounts). Everything else
|
||||
here is a proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
A project can run its container on **AWS ECS** instead of the local Docker daemon, so it keeps
|
||||
running for days without the user's machine. The user picks the host per project. Local Docker
|
||||
keeps working exactly as it does today.
|
||||
|
||||
The AWS flow, in the user's words:
|
||||
|
||||
1. Pick **Local Docker** or **AWS** as the host.
|
||||
2. AWS starts with a **login**, by SSO or access keys, the same way the AWS CLI does.
|
||||
3. Triple-C **checks it has the permissions** it needs to set up the workspace.
|
||||
4. The user picks **how it is hosted**: ECS on EC2 or Fargate, ephemeral storage or EFS.
|
||||
5. SSH keys, certificates and similar uploads go to **Secrets Manager or straight to the
|
||||
instance**, without manual steps.
|
||||
6. **A shared AWS account used by several people is the normal case**, not an edge case.
|
||||
|
||||
### Decisions (user, 2026-11-13)
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Who provisions | **Not necessarily an admin.** Triple-C **detects** existing Triple-C infrastructure in the account and offers **join it** or **deploy a new one**. Many users have no admin, so deploying is a first-class path, not an admin-only extra |
|
||||
| WIP push before stop | **On by default** (per project, can be turned off) |
|
||||
| Claude Code login | **Unchanged.** Anthropic login (`claude login`, OAuth through the auth bridge, or the shared token) or Bedrock, exactly as for local projects. The AWS host doesn't change how Claude authenticates |
|
||||
| Tenancy | **Strict from v1, and the default.** Most people will set this up in their own accounts. Strict costs a single user nothing extra (they onboard themselves automatically), and it means the setup is already safe the day a second person joins |
|
||||
| Connection model | **C, hybrid.** A connection is a login (profile or keys) plus a **default region**. Environments are detected per region when needed; each project records `(connection, region, environment)`. See [Connection model](#connection-model) |
|
||||
|
||||
### Non-goals (v1)
|
||||
|
||||
- Kubernetes/EKS, other clouds, or remote Docker hosts. The host abstraction below allows them
|
||||
later.
|
||||
- Per-project snapshot images (`docker commit`) on AWS. See [Snapshots](#snapshots).
|
||||
- Moving an existing local project's state to AWS. A project can be switched to AWS only if its
|
||||
folders are git repos with remotes, and it starts fresh there.
|
||||
- Running the speech-to-text container on AWS. The microphone is on the user's machine, so STT
|
||||
stays local.
|
||||
- Docker Sandboxes, gVisor or Kata. That is a separate design.
|
||||
|
||||
## Terminology
|
||||
|
||||
`Project.backend` (`models/project.rs`) already means the **LLM provider** (Anthropic, Bedrock,
|
||||
Ollama, llama.cpp, OpenAI-compatible). To avoid two meanings of "backend", this design uses:
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **Host** | Where a project's container runs: `Local` (Docker today) or `Aws` |
|
||||
| **AWS connection** | Saved credentials and region (an SSO profile or access keys), stored in app settings |
|
||||
| **Environment** | Shared infrastructure in one AWS account and region: cluster, VPC wiring, ECR, EFS, KMS key, IAM roles, log group. It is provisioned once, often by an admin, and used by many people |
|
||||
| **Workspace** | One user's project inside an environment: task definition, EFS access point, secrets, and the running task |
|
||||
| **Owner** | The AWS principal that created a workspace. Every per-user resource is tagged with it |
|
||||
|
||||
Environment vs. workspace is the central decision. It is what makes shared accounts work: admins
|
||||
own the environment, users only ever create things inside their own workspaces.
|
||||
|
||||
## User flow
|
||||
|
||||
### 1. Settings → Hosts → Add AWS connection
|
||||
|
||||
- **Auth method** (same options as the AWS CLI):
|
||||
- **AWS profile**: any profile in `~/.aws/config`, including SSO profiles. SSO login reuses
|
||||
`commands/aws_commands.rs` (`is_sso_profile`, `check_sso_session`, `run_sso_login`), which
|
||||
already does this for Bedrock.
|
||||
- **Access keys**: access key ID, secret, and an optional session token. Stored in the OS
|
||||
keyring through `storage/secure.rs`, never in `settings.json`.
|
||||
- Later: the SSO device flow through the SDK (`aws-sdk-ssooidc`), so SSO works without the
|
||||
AWS CLI installed.
|
||||
- **Default region.** Used for detection and preselected for new projects. Other regions are
|
||||
scanned only when the user picks one for a project.
|
||||
- When saved, call `sts:GetCallerIdentity` and show **"Signed in as `<arn>`"**. The identity
|
||||
determines the owner ID (see [Multi-user accounts](#multi-user-accounts)).
|
||||
|
||||
AWS API calls go through the Rust SDK (`aws-config` plus the service crates). That way, access-key
|
||||
users don't need the AWS CLI. Only SSO login needs it, until the device flow lands.
|
||||
|
||||
### 2. Detect, then join or deploy
|
||||
|
||||
Triple-C looks for existing Triple-C infrastructure in the region in two ways, so it finds an
|
||||
environment even if one of them is missing:
|
||||
|
||||
- the SSM parameters `/triple-c/environments/*`, written by the environment stack, whose JSON
|
||||
value lists the environment's ARNs and IDs;
|
||||
- CloudFormation stacks tagged `triple-c:component=environment`. This catches a stack whose
|
||||
parameter was deleted, or one that is half-deployed (`*_IN_PROGRESS`, `*_FAILED`). Those are
|
||||
shown with their state and a "Repair or delete" action instead of being silently ignored.
|
||||
|
||||
Detection also reads the template version each stack was deployed from, so an environment deployed
|
||||
by an older Triple-C shows **"Update available"**.
|
||||
|
||||
What the user sees depends on what was found and what they're allowed to do:
|
||||
|
||||
| Found | Can deploy (stack permissions) | Offered |
|
||||
|---|---|---|
|
||||
| Nothing | Yes | **Deploy a new environment** (the default path for a personal account) |
|
||||
| Nothing | No | Explanation, plus the policy JSON and a pre-filled CloudFormation console link to send to whoever owns the account |
|
||||
| One or more | Yes | **Join** `<name>` (default) or **Deploy a new one** (for example a separate region or cost boundary) |
|
||||
| One or more | No | **Join** `<name>` |
|
||||
|
||||
**Deploy** uses the **CloudFormation template that ships with the app**
|
||||
(`aws/triple-c-environment.yaml`). A CloudFormation stack is auditable, an admin can deploy it
|
||||
without Triple-C, and deleting the stack removes everything. The app never creates environment
|
||||
resources one API call at a time. Deploy progress streams from the stack events into the same
|
||||
progress view used for local image pulls.
|
||||
|
||||
**Joining in strict tenancy** (the default) also needs the user's **onboarding stack**: their own
|
||||
task role, EFS access-point policy entry and, if they use EC2, a dedicated capacity provider. The
|
||||
app checks whether the caller already has one:
|
||||
|
||||
- **The caller can deploy it** (always true in their own account): Triple-C deploys
|
||||
`triple-c-user-onboard.yaml` for them as part of joining. For the person who just deployed the
|
||||
environment, this happens in the same flow, so a single user never sees "onboarding" as a step.
|
||||
- **The caller can't**: Triple-C produces an onboarding request for whoever runs the environment —
|
||||
a pre-filled CloudFormation console link plus the exact parameters — and the user's "Join" stays
|
||||
pending until the stack exists. The app re-checks whenever the user opens the dialog.
|
||||
|
||||
### 3. Permission check
|
||||
|
||||
There are three tiers, and the check reports which one the user is in:
|
||||
|
||||
| Tier | Can do |
|
||||
|---|---|
|
||||
| **Environment admin** | Deploy or update the environment stack, onboard users (strict tenancy) |
|
||||
| **Workspace user** | Create, start, stop, connect to and delete **their own** workspaces in an existing environment |
|
||||
| **Insufficient** | Lists exactly which actions are missing, and offers the IAM policy JSON to send to an admin |
|
||||
|
||||
How the check works:
|
||||
|
||||
1. Call `iam:SimulatePrincipalPolicy` for the caller's role or user ARN, against the action list
|
||||
for each tier, using the environment's real resource ARNs and the tag conditions a request
|
||||
would carry.
|
||||
2. If the user can't run the simulation (common for locked-down SSO roles), fall back to read-only
|
||||
probes (`ecs:DescribeClusters`, `ssm:GetParameter`, `secretsmanager:ListSecrets` filtered by
|
||||
tag, `elasticfilesystem:DescribeAccessPoints`). Write actions are then marked **"unverified —
|
||||
will be confirmed on first use"**, not "OK".
|
||||
3. Be honest about the gaps: the simulation does not apply SCPs, permission boundaries or session
|
||||
policies. The first real create is the final check, and its error must name the denied action.
|
||||
|
||||
The exact policies for each tier ship as files with the app (`aws/policies/*.json`). Settings
|
||||
links to them so an admin can grant them without reading this doc.
|
||||
|
||||
### 4. Hosting options (per project, defaults per environment)
|
||||
|
||||
| Option | Choices | Notes |
|
||||
|---|---|---|
|
||||
| Launch type | **Fargate** (default), **ECS on EC2** | EC2 choices are limited by the environment's tenancy mode, see below |
|
||||
| CPU / memory | Fargate sizes (0.25–16 vCPU) or EC2 reservations | The admin can cap these per environment |
|
||||
| Architecture | x86_64 / ARM64 (Graviton) | The image is already built for `linux/amd64` and `linux/arm64` |
|
||||
| Storage | **Hybrid** (default), **Ephemeral**, **EFS** | See [Storage](#storage) |
|
||||
| Ephemeral size | 21–200 GiB (Fargate) | On EC2, the instance volume |
|
||||
| Fargate Spot | off / on | Cheaper. Can be interrupted with 2 minutes' warning |
|
||||
| Idle auto-stop | off / N hours with no terminal or agent activity | The admin can set a maximum |
|
||||
| Repos | git URL + branch + folder name, one per `ProjectPath` | Replaces host paths on AWS |
|
||||
| Secrets delivery | **Secrets Manager** (default), **Copy at connect** | See [Secrets and uploads](#secrets-and-uploads) |
|
||||
|
||||
### 5. Start, connect, stop
|
||||
|
||||
- **Start** calls `ecs:RunTask` (a standalone task, not a service), tags the task with the owner,
|
||||
and polls `DescribeTasks` until the task is `RUNNING` and Triple-C's in-container agent (see
|
||||
[Connectivity](#connectivity)) reports ready. Progress appears in the sidebar the same way as for
|
||||
local projects.
|
||||
- **Connect**: terminals, file viewer, browser view, auth bridge and port mappings all work through
|
||||
the agent tunnel.
|
||||
- **Closing the app does not stop the task.** That is the point of this feature: the scheduler,
|
||||
long agent runs and so on keep going. Reopening the app reconnects to the running tasks.
|
||||
- **Stop** first checks for unsaved work (see [Storage](#storage)), then calls `ecs:StopTask`.
|
||||
- **Delete** removes the task definition revisions, the secrets, and (after confirmation) the
|
||||
workspace's EFS data.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Host abstraction
|
||||
|
||||
The app currently talks to Docker directly: `bollard` via `docker/client.rs`, about 38 exec call
|
||||
sites across terminals, file viewer, auth bridge, browser view, migration and marketplace,
|
||||
`container_watch.rs` on the Docker event stream, and `commit_container_snapshot`. Introduce a
|
||||
`ProjectHost` trait in `app/src-tauri/src/host/` with two implementations:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
trait ProjectHost {
|
||||
async fn start(&self, p: &Project) -> Result<HostHandle>;
|
||||
async fn stop(&self, h: &HostHandle) -> Result<()>;
|
||||
async fn status(&self, h: &HostHandle) -> Result<HostStatus>;
|
||||
async fn exec(&self, h: &HostHandle, cmd: ExecSpec) -> Result<ExecStream>; // pty or piped
|
||||
async fn read_file(&self, h: &HostHandle, path: &str) -> Result<Bytes>;
|
||||
async fn write_file(&self, h: &HostHandle, path: &str, data: Bytes) -> Result<()>;
|
||||
async fn tunnel(&self, h: &HostHandle, port: u16) -> Result<DuplexStream>; // auth bridge, browser view, ports
|
||||
fn events(&self) -> BoxStream<HostEvent>; // replaces container_watch's die events
|
||||
fn capabilities(&self) -> HostCaps; // see compatibility matrix
|
||||
}
|
||||
```
|
||||
|
||||
- `DockerHost` wraps today's code, with no behaviour change.
|
||||
- `AwsHost` implements the trait with the ECS/EC2/EFS/Secrets Manager SDK calls plus the agent.
|
||||
- **Phase 1 is a pure refactor**: every exec, file and tunnel call site moves onto the trait, with
|
||||
local only. The AWS work then goes behind a stable interface.
|
||||
|
||||
`HostCaps` drives the UI. Options a host can't support (VPN, Docker access, snapshots…) are shown
|
||||
disabled with the reason, instead of failing at start.
|
||||
|
||||
### Connectivity
|
||||
|
||||
ECS Exec (SSM Session Manager) gives a shell in a Fargate task with no inbound ports, no public IP
|
||||
and no SSH. Fargate injects the SSM agent, so the image doesn't need it. Two problems with using it
|
||||
directly:
|
||||
|
||||
- Each `execute-command` opens a new SSM session, which takes about 1 second. That is fine for a
|
||||
terminal, but too slow for the file viewer's polling, the auth bridge's `/proc/net` polling and
|
||||
per-connection tunnels.
|
||||
- The session protocol is a custom websocket framing that the Rust SDK doesn't implement.
|
||||
|
||||
**Design:** a small **in-container agent**, `triple-c-agent`, a static musl Rust binary added to
|
||||
the image.
|
||||
|
||||
- It listens on `127.0.0.1:<port>` inside the task.
|
||||
- It multiplexes, over one authenticated connection: pty exec, piped exec, file
|
||||
read/write/stat/list/watch, TCP tunnels to in-container loopback ports, and liveness/activity
|
||||
for idle auto-stop.
|
||||
- The app reaches it through **one SSM port-forwarding session** (`AWS-StartPortForwardingSession`,
|
||||
target `ecs:<cluster>_<taskId>_<runtimeId>`), opened when the project connects and reopened if
|
||||
it drops.
|
||||
- Authentication uses a random per-task token, delivered as a secret at task start. The SSM
|
||||
session's IAM checks (`ssm:StartSession` and `ecs:ExecuteCommand`, both scoped by the owner tag)
|
||||
are the outer gate; the token is defence in depth.
|
||||
- v1 runs AWS's `session-manager-plugin` as a child process for the port-forward (it is
|
||||
Apache-2.0, so Triple-C can bundle it). A native Rust client for the SSM protocol could replace
|
||||
it later.
|
||||
|
||||
This one channel replaces every `docker exec … socat` tunnel (auth bridge, browser view, port
|
||||
mappings) and every exec call. The web terminal and audio bridge work unchanged, because they sit
|
||||
on top of the terminal and exec layer.
|
||||
|
||||
Network: tasks run in **private subnets** under a security group with **no inbound rules at all**.
|
||||
Outbound access goes through a NAT gateway, or VPC endpoints in restricted environments (ECR, S3,
|
||||
Logs, SSM, Secrets Manager, EFS, Bedrock). Two tasks in the same environment cannot reach each
|
||||
other.
|
||||
|
||||
### Image
|
||||
|
||||
**Same image, same Dockerfile.** No AWS-specific image. Changes:
|
||||
|
||||
1. Add `triple-c-agent` to the image.
|
||||
2. The entrypoint gets an `aws` mode (`TRIPLEC_HOST=aws`) that runs before the existing steps:
|
||||
- **Write secrets to files**: each `TRIPLEC_FILE_<name>` env var injected from Secrets Manager
|
||||
is written to the path the entrypoint **already reads** (`/tmp/.host-ssh/…`,
|
||||
`/tmp/.host-ca/…`, `/tmp/.host-aws/…`). After that, `sync_host_ssh`,
|
||||
`install_corporate_ca` and the AWS config merge run unchanged.
|
||||
- **Clone repos**: clone each repo into `/workspace/<folder>` if it isn't already there
|
||||
(it's already there on EFS after a restart). Git credentials come from the injected git token
|
||||
or SSH key.
|
||||
- **Skip** the UID/GID remap (there are no bind mounts; `claude` stays UID 1000, which the EFS
|
||||
access point matches) and the Docker-socket group setup (no socket).
|
||||
- Start `triple-c-agent` as root, outside the `claude` user's reach, and keep the existing
|
||||
unkillable keepalive.
|
||||
3. **Fargate restrictions** are handled by the host, not the image: no `privileged`, and no added
|
||||
capabilities except `SYS_PTRACE`. So VPN (`NET_ADMIN`) and Docker access are unavailable. The
|
||||
bubblewrap sandbox likely can't create user namespaces on Fargate (**verify**), so the generated
|
||||
Claude Code settings use `enableWeakerNestedSandbox` or turn the sandbox off, with a visible
|
||||
notice.
|
||||
|
||||
**Getting the image into the account:**
|
||||
|
||||
- ECR pull-through cache of `ghcr.io/shadowdao/triple-c-sandbox` (GHCR as an upstream needs a
|
||||
GitHub token stored in Secrets Manager), or
|
||||
- the environment stack's ECR repo, filled by an admin or CI with `crane copy`.
|
||||
|
||||
Pulling straight from GHCR over NAT also works, and is the default when the environment has no ECR mirror. Image
|
||||
updates use the existing "new image available" flow: the next start uses the new digest.
|
||||
|
||||
### Storage
|
||||
|
||||
| Mode | `/workspace` (repos) | `/home/claude` + `.claude` | Survives stop | Speed | Cost |
|
||||
|---|---|---|---|---|---|
|
||||
| **Ephemeral** | task storage | task storage | **No** | Fast | Included (beyond 20 GiB is billed) |
|
||||
| **Hybrid** (default) | task storage | EFS | Settings, sessions, memory, history: yes. Working tree: only what's pushed | Fast builds, fast git | Small EFS footprint |
|
||||
| **EFS** | EFS | EFS | Everything | git, `node_modules` and builds are noticeably slower over NFS | Grows with repo and dependency size |
|
||||
|
||||
- **One EFS file system per environment, one access point per workspace.** Access point root:
|
||||
`/triple-c/<owner>/<project-id>`, with POSIX user and group 1000. The access point is created by
|
||||
the user and tagged with the owner (see policies).
|
||||
- **WIP push is on by default** (`wip_push_on_stop: true`, with a serde default per the
|
||||
`default_full_permissions` pattern) and can be turned off per project.
|
||||
- **Ephemeral and Hybrid have a stop guard.** Before stopping, the app asks the agent for
|
||||
`git status --porcelain` and any unpushed commits in each repo. If anything would be lost, it
|
||||
offers: **Push to a `triple-c/wip/<date>` branch** (the preselected default), **Stop anyway**, or
|
||||
**Cancel**. The same check runs before idle auto-stop. If anything is dirty, idle auto-stop
|
||||
pushes a WIP branch instead of throwing work away.
|
||||
- **Fargate task retirement and Spot interruption** send `SIGTERM` with a short deadline (2 minutes
|
||||
for Spot). The entrypoint's `SIGTERM` trap runs the same WIP push, best-effort, when the project
|
||||
has it enabled.
|
||||
|
||||
#### Snapshots
|
||||
|
||||
`docker commit` doesn't exist on ECS. On AWS:
|
||||
|
||||
- Anything installed outside `/home/claude` (apt packages, for example) is **lost on restart**.
|
||||
- A per-project **setup script** (stored with the project, run by the entrypoint on every start)
|
||||
replaces snapshotting for the AWS host. Its output streams to the start-progress view.
|
||||
- Later, the app could build a per-workspace image from base image + setup script with CodeBuild
|
||||
and push it to ECR. Out of scope for v1.
|
||||
|
||||
### Secrets and uploads
|
||||
|
||||
Things that are host files or keyring values today: SSH key (`ssh_key_path`), CA cert
|
||||
(`ca_cert_path`), git token, AWS config for Bedrock, `CLAUDE_CODE_OAUTH_TOKEN` / API keys, custom
|
||||
env vars marked secret, the agent token.
|
||||
|
||||
**Secrets Manager (default)**
|
||||
|
||||
- When the user picks an SSH key or cert for an AWS project, the app reads it on the host and
|
||||
creates or updates a secret named `triple-c/<env>/<owner>/<project-id>/<name>`. The secret is
|
||||
encrypted with the environment's KMS key and tagged `triple-c:owner`, `triple-c:project`,
|
||||
`triple-c:environment`.
|
||||
- The task definition references the secrets in `containerDefinitions[].secrets`, so **the ECS
|
||||
task execution role** fetches them at start and injects them as env vars. The entrypoint writes
|
||||
them to files as described above.
|
||||
- **The container's own task role has no Secrets Manager permission.** Code running inside a task
|
||||
cannot read another workspace's secrets, even though tasks share a task role in standard
|
||||
tenancy.
|
||||
- Updating a key in the app updates the secret. The next start picks it up, and a running task
|
||||
gets a "restart to apply" prompt.
|
||||
- Secrets Manager's 64 KB per-secret limit covers keys and cert bundles. Anything bigger is
|
||||
rejected with a clear message.
|
||||
|
||||
**Copy at connect**
|
||||
|
||||
- Nothing is stored in AWS. When the app connects, it pushes the files into the running container
|
||||
through the agent, the same way Triple-C's file upload works today, writing to tmpfs paths that
|
||||
the entrypoint re-syncs from.
|
||||
- The task can't start a fresh clone of a **private** repo until the app connects, so the clone is
|
||||
deferred until the first connect. Scheduled jobs can't use the credentials while the app is
|
||||
closed, unless the files were persisted to EFS (an extra opt-in, warned about).
|
||||
- For users or orgs that don't want keys in AWS at all.
|
||||
|
||||
**Claude Code login works the same as locally (decided).** The project's LLM backend setting
|
||||
doesn't change on the AWS host:
|
||||
|
||||
- **Anthropic login**: `claude login` in a terminal. The OAuth callback reaches the container
|
||||
through the auth bridge over the agent tunnel. The credential persists in `~/.claude`, which is
|
||||
on EFS in Hybrid and EFS modes, and lost on stop in Ephemeral mode, where the UI says so. The
|
||||
shared auth token (`use_shared_auth_token` → `CLAUDE_CODE_OAUTH_TOKEN`) is delivered as a
|
||||
Secrets Manager secret instead of an env var.
|
||||
- **Bedrock**: the same Bedrock options as today (static keys, profile, bearer token). Profiles
|
||||
and SSO are delivered as described above, `triple-c-sso-refresh` keeps working, and the
|
||||
existing post-start `sync_bedrock_credentials` moves onto the agent. One optional extra,
|
||||
**off by default**: "Use this workspace's task role for Bedrock". Under strict tenancy the task
|
||||
role is the user's own, so this is safe and needs no credentials in the container. It is an
|
||||
extra choice, not a replacement.
|
||||
|
||||
### Multi-user accounts
|
||||
|
||||
**Owner ID.** `aws:userid` from `GetCallerIdentity`:
|
||||
|
||||
- for an IAM user, the user ID;
|
||||
- for SSO and assumed roles, `AROAxxxx:<session-name>`, where the session name is the SSO
|
||||
username.
|
||||
|
||||
It is stable per person and available as a policy variable, `${aws:userid}`. Every per-user
|
||||
resource is created with `triple-c:owner = ${aws:userid}`.
|
||||
|
||||
**Control-plane isolation (ABAC).** The workspace-user policy only allows:
|
||||
|
||||
- `ecs:RunTask`, with the request tag `triple-c:owner` set to `${aws:userid}`, on the
|
||||
environment's cluster and task definition family prefix, plus `iam:PassRole` **only** for the
|
||||
environment's execution and task roles;
|
||||
- `ecs:StopTask`, `ecs:ExecuteCommand`, `ssm:StartSession` where the resource tag
|
||||
`triple-c:owner` is `${aws:userid}`. Without this, any user could open a shell in anyone's
|
||||
task, so it's the most important rule;
|
||||
- `secretsmanager:CreateSecret`, `PutSecretValue` and `DeleteSecret` with matching request or
|
||||
resource tags, on `triple-c/<env>/*`;
|
||||
- `elasticfilesystem:CreateAccessPoint` and `DeleteAccessPoint` with a matching tag;
|
||||
`kms:Encrypt` and `GenerateDataKey` through Secrets Manager only (`kms:ViaService`);
|
||||
- read-only `Describe*` / `List*`. The app filters results by owner tag for display. Listing is
|
||||
not a secret.
|
||||
|
||||
Wildcard `${aws:userid}` conditions on ARNs and tags behave differently across ECS, EFS and
|
||||
Secrets Manager (`ecs:ResourceTag` vs `aws:ResourceTag`, and support for tag-on-create). **Every
|
||||
statement in `aws/policies/workspace-user.json` needs an integration test against a real account
|
||||
before release.**
|
||||
|
||||
**Data-plane isolation: tenancy modes.** These are set on the environment by the admin.
|
||||
|
||||
| Mode | For | Task role | EC2 options | Isolation |
|
||||
|---|---|---|---|---|
|
||||
| **Strict** (**default, v1**) | Everyone, from a solo account to a team | **Per-user** task role, created by the user's onboarding stack (self-deployed, or by an admin) | Privileged and Docker allowed **only on a per-user capacity provider** (dedicated EC2 instances, never shared with another owner) | Per-user IAM all the way down. EFS file system policy allows each access point only to its owner's task role |
|
||||
| **Standard** (opt-in, later) | Large teams that want fewer IAM roles | Shared, minimal (logs, SSM messages) | **Fargate only**, or EC2 with **no** privileged mode, Docker socket or `NET_ADMIN` | Containers are unprivileged and can't mount NFS, can't read secrets, and can't reach each other over the network |
|
||||
|
||||
There is no separate "personal" mode. A solo user on strict tenancy is simply the only onboarded
|
||||
user, and the account is already safe to share.
|
||||
|
||||
Strict tenancy's per-user `iam:PassRole` is scoped by a tag on the role
|
||||
(`iam:ResourceTag/triple-c:owner = ${aws:userid}`), not by role name, because `aws:userid`
|
||||
contains a `:`, which role names don't allow. **Verify** that `iam:PassRole` evaluates
|
||||
`iam:ResourceTag` as expected before release.
|
||||
|
||||
Why EC2 privileged mode needs dedicated capacity: a privileged container, or one with the Docker
|
||||
socket, has root on its EC2 host, and so access to every other tenant's task on that host
|
||||
(including their injected secrets in `/proc/*/environ`). In standard tenancy the app
|
||||
**doesn't offer** Docker access or VPN for EC2 projects. In strict tenancy it offers them only on
|
||||
the user's dedicated capacity provider.
|
||||
|
||||
**Cost visibility.** All resources carry `triple-c:owner`, `triple-c:project` and
|
||||
`triple-c:environment`. The admin activates them as cost-allocation tags. The app shows an
|
||||
estimated hourly cost per running workspace (Fargate vCPU and GB-hour rates for the region, from
|
||||
the Pricing API, cached).
|
||||
|
||||
**Quotas.** Fargate vCPU and EFS access-point limits are per account and region. When `RunTask`
|
||||
fails because of a quota, the app says so and doesn't retry.
|
||||
|
||||
### ECS on EC2 vs Fargate
|
||||
|
||||
| | Fargate | ECS on EC2 |
|
||||
|---|---|---|
|
||||
| Start time | About 30–90 s (image pull every time; SOCI lazy-loading **to evaluate**) | Fast when the image is cached on a warm instance |
|
||||
| Docker access / privileged / VPN | No | Yes, on dedicated capacity |
|
||||
| GPU | No | Yes |
|
||||
| Ops | None | ASG + capacity provider, defined in the environment stack. The app only picks a capacity provider |
|
||||
| Cost when idle | 0 (task stopped) | Instances keep running unless the ASG scales in |
|
||||
| Max size | 16 vCPU / 120 GB | Instance type |
|
||||
|
||||
ECS on EC2 uses `awsvpc` network mode too, so security groups and the no-inbound rule work the same
|
||||
way.
|
||||
|
||||
### Lifecycle and status
|
||||
|
||||
- `container_watch.rs` listens to Docker `die` events. For AWS, `AwsHost::events()` polls
|
||||
`DescribeTasks` for the owner's running tasks every 15 s while the app is open, and maps
|
||||
`stopCode` / `stoppedReason`:
|
||||
- `EssentialContainerExited` → Stopped (exited on its own);
|
||||
- `SpotInterruption` → Stopped (Spot);
|
||||
- `TaskFailedToStart` → Error, with the reason;
|
||||
- user-initiated → Stopped.
|
||||
|
||||
EventBridge → SQS for push notifications is a later improvement.
|
||||
- **Idle auto-stop** runs **inside** the task: the agent tracks activity (terminal I/O, agent
|
||||
calls, running `claude` processes and scheduler jobs) and stops the task by exiting the
|
||||
entrypoint. It doesn't depend on the app being open. Because a running scheduler job counts as
|
||||
activity, scheduled tasks keep a workspace alive. The admin's maximum lifetime is a hard cap
|
||||
either way.
|
||||
- Logs: `awslogs` driver to the environment's log group, stream `<owner>/<project>/<task>`. The
|
||||
app shows start logs during startup and when a start fails.
|
||||
|
||||
### Feature compatibility (AWS host)
|
||||
|
||||
| Feature | Fargate | EC2 (shared) | EC2 (dedicated) |
|
||||
|---|---|---|---|
|
||||
| Terminals, web terminal, file viewer | Yes (agent) | Yes | Yes |
|
||||
| Auth bridge (OAuth callbacks) | Yes (agent tunnel) | Yes | Yes |
|
||||
| Browser view (Playwright) | Yes (agent tunnel) | Yes | Yes |
|
||||
| Port mappings | Yes, as local forwards through the agent | Yes | Yes |
|
||||
| Speech-to-text | Yes (local STT, audio over the agent) | Yes | Yes |
|
||||
| Scheduler, Mission Control | Yes, and they keep running with the app closed | Yes | Yes |
|
||||
| Marketplace installs | Yes (files copied through the agent) | Yes | Yes |
|
||||
| LLM gateway (LiteLLM) | Sidecar container in the task definition | Sidecar | Sidecar |
|
||||
| Ollama / llama.cpp on the user's machine | **No**, unless the URL is reachable from AWS | No | No |
|
||||
| Claude Code sandbox (bubblewrap) | Weaker nested mode or off (**verify**) | Weaker nested mode | Full |
|
||||
| Docker access | No | No | Yes |
|
||||
| VPN support | No | No | Yes |
|
||||
| Snapshots | Setup script instead | Setup script | Setup script |
|
||||
|
||||
## Fit with existing conventions (CLAUDE.md)
|
||||
|
||||
- **Detecting that a container needs recreating.** Locally, `container_needs_recreation()`
|
||||
compares `triple-c.*` labels. On AWS, the equivalent is the **task definition revision**: the
|
||||
app computes a fingerprint over every setting that shapes the task, stores it as a task
|
||||
definition tag, and registers a new revision when it changes. A running task on an old revision
|
||||
gets the same "restart to apply" signal. The CLAUDE.md rule "never put a secret in a label"
|
||||
applies equally to tags, task definition env vars and `overrides`. Secrets go only through
|
||||
`secrets` ARNs.
|
||||
- **Env vars are visible on EC2.** Injected secrets become container env vars, which anyone with
|
||||
`docker inspect` or `/proc` on the instance can read. This is fine for Fargate and dedicated EC2
|
||||
capacity, and is exactly why standard tenancy bans privileged containers on shared EC2
|
||||
capacity.
|
||||
- **`/home/claude` stays seed-only** in Hybrid and EFS modes: it is persistent storage, the same
|
||||
semantics as the home volume, so the existing "seed at runtime from `/opt` or
|
||||
`/usr/local/bin`" rule carries over unchanged.
|
||||
- **Marketplace sync and Bedrock credential sync** run "after every container start". They move
|
||||
onto `ProjectHost::write_file` / `exec`. Marketplace tokens still never leave the host: sync
|
||||
sends the same pinned tar.
|
||||
- **Settings export/import.** `HostConnection` access keys live in the keychain, so they go into
|
||||
`ExportedSecrets` under the same carve-out as the other keychain secrets. Importing an AWS
|
||||
connection or environment is shown as its own preview warning, alongside the custom-image
|
||||
warning, because it changes **where code runs and who is billed**.
|
||||
- **New IPC commands** (`aws_connect`, `aws_check_permissions`, `aws_list_environments`,
|
||||
`aws_deploy_environment`, …) each need a `generate_handler!` entry and an `allow-*` grant in
|
||||
`capabilities/default.json`. `build.rs` enforces this.
|
||||
- **UI.** The AWS connection goes in `components/settings/` beside the existing AWS panel. The
|
||||
per-project hosting options go in Project Home's Config tab, not a modal. Login and permission
|
||||
check results use the existing `StatusIndicator` (glyph + word).
|
||||
|
||||
## Data model
|
||||
|
||||
```rust
|
||||
// AppSettings
|
||||
pub hosts: Vec<HostConnection>, // Local is implicit and always present
|
||||
|
||||
pub struct HostConnection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: HostKind, // Aws { auth: AwsAuth, default_region: String }
|
||||
}
|
||||
pub enum AwsAuth { Profile { name: String }, Keys { keyring_ref: String } }
|
||||
|
||||
// Project
|
||||
pub host_id: Option<String>, // None = Local (all existing projects)
|
||||
pub aws_placement: Option<AwsPlacement>, // { region, environment } — set when host_id is an AWS connection
|
||||
pub aws: Option<AwsWorkspaceConfig>,
|
||||
|
||||
pub struct AwsWorkspaceConfig {
|
||||
pub launch_type: LaunchType, // Fargate | Ec2 { capacity_provider }
|
||||
pub cpu: u32, pub memory_mib: u32, pub arch: Arch,
|
||||
pub storage: StorageMode, // Ephemeral | Hybrid | Efs
|
||||
pub ephemeral_gib: u32,
|
||||
pub spot: bool,
|
||||
pub idle_stop_minutes: Option<u32>,
|
||||
pub repos: Vec<RepoSource>, // { url, branch, folder } — parallel to ProjectPath
|
||||
pub secrets_delivery: SecretsDelivery, // SecretsManager | CopyAtConnect
|
||||
pub setup_script: Option<String>,
|
||||
pub wip_push_on_stop: bool,
|
||||
// runtime state (not user-edited)
|
||||
pub task_arn: Option<String>, pub access_point_id: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Existing `projects.json` files deserialize unchanged (`host_id` missing → Local).
|
||||
`Project.container_id` stays Docker-only. AWS uses `aws.task_arn`.
|
||||
|
||||
## Shipped AWS artefacts
|
||||
|
||||
```
|
||||
aws/
|
||||
triple-c-environment.yaml # CloudFormation: cluster, capacity providers, SG, EFS + policy,
|
||||
# KMS key, ECR repo / pull-through rule, log group, exec+task roles,
|
||||
# SSM environment parameter. Params: VPC/subnets (or create),
|
||||
# tenancy mode, limits, Bedrock-on-task-role.
|
||||
triple-c-user-onboard.yaml # strict tenancy: per-user task role + capacity provider
|
||||
policies/
|
||||
environment-admin.json
|
||||
workspace-user.json
|
||||
task-execution-role.json
|
||||
task-role-standard.json
|
||||
```
|
||||
|
||||
## Phases
|
||||
|
||||
1. **Host abstraction refactor.** `ProjectHost` trait, `DockerHost`, every call site moved. No
|
||||
behaviour change, and the existing tests pass.
|
||||
2. **Agent.** `triple-c-agent` in the image. Optionally use it locally too behind a flag, to
|
||||
harden it before AWS depends on it.
|
||||
3. **AWS connection + permission check.** Settings UI, SDK wiring, `GetCallerIdentity`, the tiered
|
||||
simulation and fallback probes, policy files.
|
||||
4. **Environment + onboarding stacks.** CloudFormation templates for both, detection (SSM
|
||||
parameters + tagged stacks, including half-deployed and outdated ones), join or deploy,
|
||||
self-onboarding or an admin request, **strict tenancy**.
|
||||
5. **Fargate workspaces.** Ephemeral and Hybrid storage, Secrets Manager delivery, git bootstrap,
|
||||
start/stop/status, stop guard with WIP push on by default, idle auto-stop, logs.
|
||||
6. **EFS mode, Copy-at-connect, setup scripts, cost estimate.**
|
||||
7. **ECS on EC2.** Per-user capacity providers in the onboarding stack, privileged/Docker/VPN on
|
||||
dedicated capacity.
|
||||
8. **Standard tenancy** (opt-in), only if demand appears.
|
||||
|
||||
## To verify before or during implementation
|
||||
|
||||
- Whether Fargate allows bubblewrap's unprivileged user namespaces, and what Claude Code's sandbox
|
||||
does there.
|
||||
- ABAC condition keys for each action: ECS `RunTask` tag-on-create, `ExecuteCommand`, and
|
||||
`ssm:StartSession` against ECS targets; EFS `CreateAccessPoint` with request tags; Secrets
|
||||
Manager name-prefix plus tag conditions with `${aws:userid}` for SSO sessions.
|
||||
- That `session-manager-plugin` port forwarding to an ECS target is stable for hours-long
|
||||
sessions, and how quickly it reconnects.
|
||||
- Fargate image-pull time for the sandbox image, and whether SOCI indexes help.
|
||||
- That GHCR pull-through cache works for the public image.
|
||||
- That the `SIGTERM` deadline on Fargate retirement and Spot leaves enough time for a WIP push.
|
||||
|
||||
## Connection model
|
||||
|
||||
**Decided: C** (user, 2026-11-13). The comparison below is kept for the record.
|
||||
|
||||
The options:
|
||||
|
||||
- **A. One connection per environment.** Credentials + region + environment, bound together.
|
||||
- **B. One connection per identity.** Credentials only (a profile or keys, like an AWS CLI
|
||||
profile). Each project picks a region and environment.
|
||||
- **C. Hybrid.** A connection is credentials plus a **default** region. Environments are detected
|
||||
per region when needed, and each project records `(connection, region, environment)`.
|
||||
|
||||
| | A. Per environment | B. Per identity | C. Hybrid |
|
||||
|---|---|---|---|
|
||||
| Mental model | Simplest: "my AWS dev box" | Matches the AWS CLI (a profile is an identity; region is a parameter) | Matches the AWS CLI, with a sensible default |
|
||||
| Setup for a solo user, one region | One step | One step, plus picking a region for each project | One step |
|
||||
| Several regions or environments, same login | Duplicate connections with the same credentials. Keys are stored twice; SSO shares a token cache, so it's only clutter | One connection | One connection |
|
||||
| Permission check | Once per connection, cached | Per environment, run lazily the first time a project uses it | Per environment, run lazily |
|
||||
| Detection cost | One region | Every region the user might use (`DescribeRegions` + a scan, slow) or a hand-kept list | Default region straight away; other regions only when the user picks one |
|
||||
| Risk of deploying to the wrong place | Low: the place is in the connection's name | Higher: an easy region-picker mistake leaves a stack in a region nobody looks at | Low: default region preselected, others must be chosen deliberately |
|
||||
| Moving a project to another region or environment | Re-point the project to another connection | Change a field | Change a field |
|
||||
| Settings export/import | Each connection is self-contained | Projects reference environments that may not exist on the importing machine | Same as B |
|
||||
| Data model / UI | Smallest | Largest (environment picker and per-environment status everywhere) | Medium |
|
||||
| Multiple AWS **accounts** | One connection each | One connection each (credentials define the account) | One connection each |
|
||||
|
||||
**Why C:**
|
||||
|
||||
- It costs the solo user, one-region case nothing, because the default region carries it.
|
||||
- It avoids storing the same keys repeatedly the moment someone uses a second region.
|
||||
- It keeps detection cheap and deploying deliberate.
|
||||
|
||||
A is fine as a v1 shortcut if we want the smallest UI. Moving from A to C later is mechanical:
|
||||
connections that share credentials get merged.
|
||||
|
||||
Consequences for implementation:
|
||||
|
||||
- Permission check results are cached per `(connection, region, environment)` and re-run if a
|
||||
call is denied.
|
||||
- The project's Config tab shows the region and environment with the default region preselected.
|
||||
Choosing another region triggers detection there, and offers join or deploy as in step 2.
|
||||
- Settings export carries connections. Projects aren't exported (unchanged scope), so a dangling
|
||||
`aws_placement` can't arrive through an import.
|
||||
|
||||
## Remaining open questions
|
||||
|
||||
None. Everything in "To verify" is an implementation-time check, not a product decision.
|
||||
@@ -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