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

Merged
jknapp merged 13 commits from feat/triple-c-agent into main 2026-11-13 22:29:38 +00:00
Owner

Phase 2a of the AWS host design: the in-container agent, the app-side client for it, and an end-to-end Docker-parity test. Specs: docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md (new) and 2026-11-13-aws-ecs-host-design.md ("Connectivity").

Stacked on #75. This branch builds on phase 1b, and until #75 merges the diff here includes its commits. Merge #75 first.

What it adds

  • container/triple-c-agent/: a new static Rust binary, about 1.3 MB, for amd64 and arm64.
    • It serves the protocol's one-shot, attached (with or without a tty), and detached execs, plus resize, exit status, file upload, archive download, TCP tunnels and activity.
    • It runs as root inside the container and only starts when a token is configured.
    • The contract is Docker parity: an ExecSpec must behave through the agent exactly as through docker exec.
  • Dockerfile: a new agent-build stage cross-compiles on $BUILDPLATFORM for $TARGETARCH, so CI's arm64 image doesn't compile Rust under QEMU. Both build paths were verified to produce static binaries: buildx arm64, and the classic builder with the app's build args.
  • entrypoint.sh:
    • The token moves to a root-only file on /dev/shm (tmpfs) before anything else runs, and is removed from the environment.
    • The exec environment snapshot is held in memory only.
    • The agent starts last, so health also means the container is ready, and runs as root under a supervisor loop that restarts it.
  • App:
    • New target variant: ContainerTarget::Agent (host/agent.rs) treats the agent as untrusted. It follows no redirects, caps the size of what it reads, puts timeouts on small calls, validates the token, and checks the 413 text before passing it through.
    • Parity test: the ignored test host::agent::live_tests runs every operation through ::Docker and ::Agent on one real container and requires identical results.
  • Local image build fixed. The app's "build locally" context was missing skills, triple-c-open and triple-c-playwright-heal, so that build couldn't succeed. It now includes them plus the agent sources, has a test that every COPY source is present, and passes BUILDPLATFORM/TARGETARCH from the daemon's own architecture, because the classic builder doesn't set them.
  • CI: the agent crate's tests run in the preview test job.

Review and fixes

Three independent reviews found and fixed:

  • Docker parity: docker exec sets only HOME, never USER/LOGNAME. The parity test caught this, and it's fixed in both the spec and the agent.
  • Signal state: execs inherited an ignored SIGHUP from the supervisor, so dropped terminals leaked processes. They now start with all signals at their defaults and nothing blocked.
  • Stdio ownership: stdio is now owned by the exec user, as with runc's fixStdioPermissions.
  • Unauthenticated connections: an accept loop now has a 10 s header-read timeout and a 256-connection cap.
  • Secrets on disk: the token and env snapshot no longer reach the writable layer, so a snapshot commit can't capture them.
  • Client hardening: redirects refused, response bodies capped, timeouts on small calls, the token header marked sensitive, and WebSocket messages capped at 1 MiB.

Accepted as is: upload symlink handling. It matches Docker's archive upload, and the claude user has passwordless sudo, so it crosses no privilege boundary. The reasoning is documented in files.rs.

Verification

  • App tests: cargo test --lib, 936 passed.

  • Agent tests: cargo test for the agent, 6 unit + 32 protocol + 2 signal-inheritance tests. Its 2 root-only tests (user switch and chown, stdio ownership) pass under sudo. Clippy is clean.

  • Live parity against Docker 29.9.3. Agent and Docker were run on the same container. These were compared and found identical:

    • output streams and exit codes;
    • the full environment as claude and as root, and the default working directory;
    • env overrides, user ids, the output-limit error and the missing-binary exit code;
    • SigIgn/SigBlk, and reopening stdio as claude;
    • upload mode, owner and size, plus archive members and their contents;
    • attach stdin/stdout and exit, tty resize, and detached exit;
    • a tty session ending when its client goes away.

    Run against the pre-fix agent, the test fails on SigIgn.

  • Not exercised: the full multi-arch image build. CI's build.yml runs it on this PR. The SSM transport is phase 2b and needs an AWS account.

🤖 Generated with Claude Code

Phase 2a of the AWS host design: **the in-container agent**, the app-side client for it, and an end-to-end Docker-parity test. Specs: `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md` (new) and `2026-11-13-aws-ecs-host-design.md` ("Connectivity"). > **Stacked on #75.** This branch builds on phase 1b, and until #75 merges the diff here includes its commits. Merge #75 first. ## What it adds - **`container/triple-c-agent/`:** a new static Rust binary, about 1.3 MB, for amd64 and arm64. - It serves the protocol's one-shot, attached (with or without a tty), and detached execs, plus resize, exit status, file upload, archive download, TCP tunnels and activity. - It runs as root inside the container and only starts when a token is configured. - The contract is **Docker parity**: an `ExecSpec` must behave through the agent exactly as through `docker exec`. - **Dockerfile:** a new `agent-build` stage cross-compiles on `$BUILDPLATFORM` for `$TARGETARCH`, so CI's arm64 image doesn't compile Rust under QEMU. Both build paths were verified to produce static binaries: buildx arm64, and the classic builder with the app's build args. - **entrypoint.sh:** - The token moves to a root-only file on `/dev/shm` (tmpfs) before anything else runs, and is removed from the environment. - The exec environment snapshot is held in memory only. - The agent starts last, so health also means the container is ready, and runs as root under a supervisor loop that restarts it. - **App:** - **New target variant:** `ContainerTarget::Agent` (`host/agent.rs`) treats the agent as untrusted. It follows no redirects, caps the size of what it reads, puts timeouts on small calls, validates the token, and checks the 413 text before passing it through. - **Parity test:** the ignored test `host::agent::live_tests` runs every operation through `::Docker` and `::Agent` on one real container and requires identical results. - **Local image build fixed.** The app's "build locally" context was missing `skills`, `triple-c-open` and `triple-c-playwright-heal`, so that build couldn't succeed. It now includes them plus the agent sources, has a test that every `COPY` source is present, and passes `BUILDPLATFORM`/`TARGETARCH` from the daemon's own architecture, because the classic builder doesn't set them. - **CI:** the agent crate's tests run in the preview `test` job. ## Review and fixes Three independent reviews found and fixed: - **Docker parity:** `docker exec` sets only `HOME`, never `USER`/`LOGNAME`. The parity test caught this, and it's fixed in both the spec and the agent. - **Signal state:** execs inherited an ignored SIGHUP from the supervisor, so dropped terminals leaked processes. They now start with all signals at their defaults and nothing blocked. - **Stdio ownership:** stdio is now owned by the exec user, as with runc's `fixStdioPermissions`. - **Unauthenticated connections:** an accept loop now has a 10 s header-read timeout and a 256-connection cap. - **Secrets on disk:** the token and env snapshot no longer reach the writable layer, so a snapshot commit can't capture them. - **Client hardening:** redirects refused, response bodies capped, timeouts on small calls, the token header marked sensitive, and WebSocket messages capped at 1 MiB. **Accepted as is:** upload symlink handling. It matches Docker's archive upload, and the `claude` user has passwordless sudo, so it crosses no privilege boundary. The reasoning is documented in `files.rs`. ## Verification - **App tests:** `cargo test --lib`, 936 passed. - **Agent tests:** `cargo test` for the agent, 6 unit + 32 protocol + 2 signal-inheritance tests. Its 2 root-only tests (user switch and chown, stdio ownership) pass under sudo. Clippy is clean. - **Live parity against Docker 29.9.3.** Agent and Docker were run on the same container. These were compared and found identical: - output streams and exit codes; - the full environment as `claude` and as root, and the default working directory; - env overrides, user ids, the output-limit error and the missing-binary exit code; - SigIgn/SigBlk, and reopening stdio as `claude`; - upload mode, owner and size, plus archive members and their contents; - attach stdin/stdout and exit, tty resize, and detached exit; - a tty session ending when its client goes away. Run against the pre-fix agent, the test fails on SigIgn. - **Not exercised:** the full multi-arch image build. CI's `build.yml` runs it on this PR. The SSM transport is phase 2b and needs an AWS account. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp added 17 commits 2026-11-13 20:35:50 +00:00
container_watch keeps all of its logic (sweep, compare-and-set settle,
backoff, cancellation) but no longer touches bollard: reachability, the
death stream and the positive-exit check come from host::watch::ExitSource
and ContainerTarget::has_exited, implemented for Docker in docker/events.rs.
An AWS host can supply deaths by polling. container_watch.rs leaves the
boundary test's allowlist.

Verified with the live end-to-end watcher test against a real daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Functions that took a bare container id and built
`ContainerTarget::docker(id)` internally now take a target, and their
callers resolve one from the project (`ContainerTarget::for_project`) or
carry the one they already hold. Long-lived holders (auth bridge poller,
browser-view proxy/session, file viewer commands) store the target.

Collapses the string/target wrapper pairs: `detect::detect` now takes a
target (`detect_in` removed) and `tunnel_connection_with_prelude` takes a
target (`tunnel_into_with_prelude` removed).

No behaviour change. Two temporary Docker constructions remain on the
container-start path, marked "phase 1b": the auth bridge start in
`start_project_container_locked` and `marketplace::spawn_project_sync`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the project container lifecycle behind host::lifecycle::ProjectHost
Secret Scan / scan (push) Successful in 7s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 3m14s
Build App (Preview) / test (pull_request) Successful in 4m24s
Build App (Preview) / build-windows (pull_request) Successful in 6m19s
Build App (Preview) / build-linux (pull_request) Successful in 8m32s
Build App (Preview) / prune-previews (pull_request) Successful in 14s
7c004fed46
Phase 1b of the AWS host work. ProjectHost (one Docker variant) owns
ensure_started (find → snapshot-and-recreate → create → start, moved
verbatim from start_project_container_locked), find_current, stop, remove,
discard_project_state and remove_legacy_leftovers. Start, stop, Reset,
remove and reconcile keep only host-neutral steps and ask the host for the
rest; the started ContainerTarget now flows to the auth bridge, marketplace
sync and Bedrock credential sync.

- create_container_for_project and default_docker_socket move to docker/
  (migration used both; its duplicate socket helper is gone).
- sync_bedrock_credentials and marketplace::spawn_project_sync take a
  ContainerTarget.
- New boundary test: no lifecycle calls or ContainerTarget::docker(id) in
  non-test code outside host/ and docker/ (migration excepted).
- Ignored live test round-trips a throwaway project container through the
  host; passes against Docker 29.9.3 and leaves nothing behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- entrypoint.sh: a TRIPLEC_AGENT_TOKEN env var is moved to a root-only file
  and unset before anything else runs; when a token is configured, the agent
  starts last (after setup, so health == ready) under a root supervisor loop,
  matching the keepalive's survival rules.
- The app's local image build was missing three COPY sources (skills,
  triple-c-open, triple-c-playwright-heal), so "build locally" could not
  succeed. Added, with a test that every Dockerfile COPY source is in the
  context.
- Local builds pass BUILDPLATFORM/TARGETARCH (the daemon's own platform):
  the classic builder does not predefine them, and the agent build stage
  will be pinned to $BUILDPLATFORM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
host/agent.rs implements every row of the protocol spec's client mapping
over reqwest (HTTP) and tokio-tungstenite (attach WebSocket): one-shot
execs rebuilt into docker/exec.rs's own OneshotOutput (413 passed through
verbatim), detached execs and exit polling with Docker's budget, attach
with stdout/stderr channel mapping, exit-frame recording and close_stdin
on shutdown, resize, uploads, streamed archive download and health.
AgentEndpoint's Debug redacts the token, and no error carries it.

docker/exec.rs shares its host-file reader, uid probe, one-shot
reassembly and exit-poll budget instead of the agent duplicating them;
Docker behaviour is unchanged. Lifecycle stop/remove refuse an agent
target, and has_exited is false for one (only the host can confirm).
Tests run the client against a small fake agent built with axum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent runs inside the container, which is not trusted with the host.
- The shared HTTP client no longer follows redirects, so an agent cannot
  point the host app's requests at another URL; a 3xx is an error.
- Error, exec-id and exec-status bodies are read through a 64 KiB cap
  before parsing, and error text passed to the UI is cut to 4 KiB.

Regression tests use a hostile fake agent; the redirect test fails with the
fix removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A static musl binary (agent/, standalone crate) implementing
docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md: one-shot,
detached and attached execs (pipes or pty), exec status and resize, file
upload, archive download, TCP tunnels and activity counters, all behind a
constant-time bearer token. Matches docker exec: same user/initgroups,
working directory, env merge (PATH/TERM defaults, HOME/USER/LOGNAME from
passwd), 126/127 for commands that cannot start, 128+n for signals, the
exact OUTPUT_LIMIT message, and a process group per exec so the limit kill
and disconnect SIGHUP reach children.

Builds statically for x86_64 and aarch64 musl with no C toolchain
(aarch64 via rust-lld); commands in agent/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Crate moves to container/triple-c-agent (inside the image build context);
  the Dockerfile's agent-build stage cross-compiles it on $BUILDPLATFORM for
  $TARGETARCH (verified: buildx arm64 and the classic builder with the app's
  build args both produce static binaries). The app's local build context
  embeds the crate's Cargo.toml, Cargo.lock and src/ only.
- entrypoint.sh starts the agent from an env/cwd snapshot taken before the
  script exports or cd's anything (minus bash's own PWD/SHLVL/_), so execs
  see what `docker exec` would.
- Agent: no TRIPLEC_AGENT_* setting reaches an exec; a token file others can
  read is refused (security review); upload symlink handling documented as
  deliberate parity (claude has passwordless sudo, so no boundary).
- New ignored live test host::agent::live_tests runs every operation through
  ContainerTarget::Docker and ::Agent on the same real container and requires
  identical results. It caught a real divergence, now fixed in agent and
  spec: docker exec sets only HOME, never USER/LOGNAME.
- CI runs the agent crate's tests in the preview test job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- entrypoint.sh: the agent token goes to a root-only file on /dev/shm
  (tmpfs) instead of /run, so it never lands in the writable layer and
  cannot ride `docker commit` into a snapshot image; the exec env snapshot
  (which holds every API key in the container env) stays in memory.
- host/agent.rs: per-request timeouts on detached start, status polls,
  resize and small-body reads; an overall bound on wait_exec_exit; uploads
  get a size-proportional timeout. One-shot exec, attach and archive stay
  unbounded on purpose (long commands, as with Docker).
- Also: Authorization marked sensitive; tokens validated as header-safe up
  front; a 413 is passed through only if it is the OUTPUT_LIMIT message;
  WebSocket messages capped at 1 MiB with stdin split into 64 KiB frames.

Tests: a hostile agent that accepts and never answers cannot hang the small
calls; bad tokens refused; a spoofed 413 is not passed through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Execs reset every catchable signal to SIG_DFL and clear the signal mask
  in pre_exec (raw rt_sigaction/rt_sigprocmask, so libc-reserved RT
  signals are covered too). The supervisor's `trap '' HUP` no longer
  leaks into execs, so a dropped tty attach hangs up its session again.
- runc's fixStdioPermissions: fds 0-2 are fchown'd to the exec user
  (except /dev/null and EINVAL/EPERM/EROFS), so /dev/stdout, /dev/stderr
  and a tty slave are usable by a non-root exec user.
- Own accept loop on hyper's http1 builder with a TokioTimer: a 10s
  header-read timeout and a 256-connection semaphore (wait for a slot,
  not drop). WebSocket upgrades still served; Config gains
  header_read_timeout and max_connections.
- Attach/tunnel WebSockets accept messages and frames of at most 1 MiB.
- The disconnect SIGHUP is sent by the pump, and only while the exec is
  unreaped, so a reused pgid is never signalled.
- Docs: removing TRIPLEC_AGENT_TOKEN does not scrub /proc/<pid>/environ;
  recommend TRIPLEC_AGENT_TOKEN_FILE.
- Tests: inherited_signals.rs (agent with SIGHUP ignored), slowloris and
  connection-cap tests, root-only stdio ownership test; fix the stale
  $USER assertion in the existing root test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parity test: signal state, stdio reopen, and tty hangup on disconnect
Secret Scan / scan (push) Successful in 6s
Secret Scan / scan (pull_request) Successful in 14s
Build Container / build-container (pull_request) Successful in 11m9s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build App (Preview) / create-release (pull_request) Successful in 7s
Build App (Preview) / test (pull_request) Successful in 6m25s
Build App (Preview) / build-macos (pull_request) Successful in 3m20s
Build App (Preview) / build-windows (pull_request) Successful in 6m30s
Build App (Preview) / build-linux (pull_request) Successful in 8m27s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
114f463bf5
Adds three checks to host::agent::live_tests against a real container:
SigIgn/SigBlk match docker exec, re-opening /dev/stdout and /dev/stderr as
claude works through both, and a tty session ends when its client goes
away. Run against the pre-fix agent, it fails on SigIgn (SIGHUP ignored).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jknapp merged commit c2aa9c3be2 into main 2026-11-13 22:29:38 +00:00
jknapp deleted branch feat/triple-c-agent 2026-11-13 22:29:38 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: CyberCoveLLC/Triple-C#76