Phase 2b of the AWS host spec: the transport from the app to triple-c-agent in an ECS task. It also fixes an entrypoint problem that made Fargate starts take ~6½ minutes. Everything was tested against a real Fargate task in a throwaway account setup, since torn down.
What's in it
host/ssm.rs: one SSM port-forward (AWS-StartPortForwardingSession) per task, run through AWS's session-manager-plugin as a child process, the way the AWS CLI does it.
It is supervised: when a session drops it is reopened on the same loopback port, so an AgentEndpoint stays valid.
It ends every session it started (TerminateSession), including one whose open is cancelled partway.
Keepalive: an Up tunnel sends an unauthenticated GET /v1/health every 5 minutes. Session Manager cuts a silent tunnel at ~20 min, taking every open connection with it. The agent answers 401 before counting activity, so this won't defeat idle auto-stop.
Secrets: the session token reaches the plugin through its environment, never argv. The plugin's env is stripped of every inherited AWS_* variable, then given only the session's own credentials (for its ResumeSession), cached until 15 minutes before expiry.
EcsTask::resolve (via DescribeTasks) builds the SSM target and refuses tasks that aren't running or have no ECS Exec agent.
find_plugin looks in TRIPLEC_SSM_PLUGIN, then PATH, then the installer locations (the macOS GUI app gets no shell PATH).
Not wired into any app flow yet; phase 5 does that. The module carries a scoped allow(dead_code) until then.
Dependencies:aws-config, aws-sdk-ssm, aws-sdk-ecs. Phase 3 needs them anyway. Default features are off, so only the current TLS client is used, not the legacy rustls 0.21 / hyper 0.14 stack the defaults add. The tree keeps a single rustls.
entrypoint.sh: chown -R /home/claude became a find that chowns only what isn't already claude's.
On ECS, /home/claude is the image's own copy, so the blanket chown copied the whole tree up: ~5.5 min on 1 vCPU and ~2 GB of ephemeral storage.
With the fix, the agent answers 4 s after the container starts (83 s after RunTask).
Resulting ownership is identical to chown -R, symlinks included (checked in a container and in the task).
All of this, plus the health check and what survives the app quitting, is now in the spec (2026-11-13-aws-ecs-host-design.md: "To verify" and "Lifecycle and status").
Tests
17 unit tests drive the supervisor with a fake plugin. They cover reconnecting on the same port, close and drop ending the session, giving up after the limit, the first open moving off a taken port, the token not being in argv, AWS_* stripping, the keepalive's shape and cadence, and a dropped open still sending TerminateSession (against a fake SSM endpoint; the test fails without the fix).
Ignored live tests in host/ssm/live_tests.rs (env in the file header): the operations through a real tunnel plus a forced drop, and a long hold with a silent stretch.
Full suite: 953 passed.
Review
An independent review found three issues:
A session leaked when close raced a reconnect: fixed.
Credentials were re-fetched on every reconnect, re-prompting MFA-style profiles: fixed.
Plugins can outlive the app: documented for phase 5. Measured: an orphaned session ends within 20 minutes, because the keepalive lives in the app.
Not in this PR
Bundling session-manager-plugin with the app (packaging).
The connection UI and permission check (phase 3).
Starting and stopping tasks, and Secrets Manager token delivery (phase 5).
Phase 2b of the AWS host spec: the transport from the app to `triple-c-agent` in an ECS task. It also fixes an entrypoint problem that made Fargate starts take ~6½ minutes. Everything was tested against a real Fargate task in a throwaway account setup, since torn down.
## What's in it
**`host/ssm.rs`**: one SSM port-forward (`AWS-StartPortForwardingSession`) per task, run through AWS's `session-manager-plugin` as a child process, the way the AWS CLI does it.
- It is **supervised**: when a session drops it is reopened **on the same loopback port**, so an `AgentEndpoint` stays valid.
- It ends every session it started (`TerminateSession`), including one whose open is cancelled partway.
- **Keepalive**: an Up tunnel sends an unauthenticated `GET /v1/health` every 5 minutes. Session Manager cuts a silent tunnel at ~20 min, taking every open connection with it. The agent answers 401 before counting activity, so this won't defeat idle auto-stop.
- **Secrets**: the session token reaches the plugin through its environment, never argv. The plugin's env is stripped of every inherited `AWS_*` variable, then given only the session's own credentials (for its `ResumeSession`), cached until 15 minutes before expiry.
- `EcsTask::resolve` (via `DescribeTasks`) builds the SSM target and refuses tasks that aren't running or have no ECS Exec agent.
- `find_plugin` looks in `TRIPLEC_SSM_PLUGIN`, then `PATH`, then the installer locations (the macOS GUI app gets no shell `PATH`).
- Not wired into any app flow yet; phase 5 does that. The module carries a scoped `allow(dead_code)` until then.
**Dependencies:** `aws-config`, `aws-sdk-ssm`, `aws-sdk-ecs`. Phase 3 needs them anyway. Default features are off, so only the current TLS client is used, not the legacy rustls 0.21 / hyper 0.14 stack the defaults add. The tree keeps a single rustls.
**`entrypoint.sh`**: `chown -R /home/claude` became a `find` that chowns only what isn't already `claude`'s.
- On ECS, `/home/claude` is the image's own copy, so the blanket chown copied the whole tree up: ~5.5 min on 1 vCPU and ~2 GB of ephemeral storage.
- With the fix, the agent answers 4 s after the container starts (83 s after `RunTask`).
- Resulting ownership is identical to `chown -R`, symlinks included (checked in a container and in the task).
## Measured on Fargate (us-west-2, 1 vCPU / 2 GB, plugin 1.2.896)
| | |
|---|---|
| Open port forward | 0.6-0.7 s |
| One-shot exec round trip | 35 ms (vs ~1 s per `execute-command`) |
| Reconnect after the plugin is killed | 0.55 s, same port |
| Throughput | ~0.75 MB/s each way (SSM's limit) |
| Image pull (1.1 GB from public GHCR) | 55 s |
| Silent tunnel, no keepalive | cut at 19.7 min; held attach died |
| 44 min with 22 silent, keepalive on | no reconnect; held attach survived |
| ECS health check against the agent | `HEALTHY` 105 s after `RunTask` |
| Background job across a `kill -9`'d tunnel | kept running; a fresh tunnel found it |
All of this, plus the health check and what survives the app quitting, is now in the spec (`2026-11-13-aws-ecs-host-design.md`: "To verify" and "Lifecycle and status").
## Tests
- 17 unit tests drive the supervisor with a fake plugin. They cover reconnecting on the same port, close and drop ending the session, giving up after the limit, the first open moving off a taken port, the token not being in argv, `AWS_*` stripping, the keepalive's shape and cadence, and a dropped open still sending `TerminateSession` (against a fake SSM endpoint; the test fails without the fix).
- Ignored live tests in `host/ssm/live_tests.rs` (env in the file header): the operations through a real tunnel plus a forced drop, and a long hold with a silent stretch.
- Full suite: 953 passed.
## Review
An independent review found three issues:
- A session leaked when close raced a reconnect: fixed.
- Credentials were re-fetched on every reconnect, re-prompting MFA-style profiles: fixed.
- Plugins can outlive the app: documented for phase 5. Measured: an orphaned session ends within 20 minutes, because the keepalive lives in the app.
## Not in this PR
- Bundling `session-manager-plugin` with the app (packaging).
- The connection UI and permission check (phase 3).
- Starting and stopping tasks, and Secrets Manager token delivery (phase 5).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
host::ssm opens one AWS-StartPortForwardingSession to triple-c-agent in an
ECS task by running session-manager-plugin, supervises it, and reopens it
on the same loopback port when it drops, so an AgentEndpoint stays valid.
The StartSession token reaches the plugin through its environment, never
argv; the plugin gets the caller's credentials (and no other AWS_* vars)
so it can resume its own data channel. Unit tests drive the supervisor
with a fake plugin; ignored live tests run against a real task.
entrypoint.sh: chown only what is not already claude's. On Fargate,
/home/claude is the image's own copy, and chown -R copied all of it up:
the agent came up ~5.5 min after the container started; now 4 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes. A StartSession whose open future is dropped (a close racing a
reconnect, a caller timing out the first open) is now ended by a guard
instead of left to SSM's idle timeout. The plugin's credentials are cached
until 15 minutes before expiry, so a prompting credential_process is not
asked again on every reconnect. Fake-plugin tests run the script through
/bin/sh, so a parallel test's fork cannot fail the spawn with ETXTBSY.
CLAUDE.md: host/ssm.rs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured: a silent port forward was cut at 19.7 minutes ("timed out due to
inactivity"), taking every connection through it with it. Every 5 minutes
the supervisor now sends an unauthenticated GET /v1/health through the
tunnel; the agent answers 401 before counting activity, so this keeps the
session alive without defeating idle auto-stop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session stability, idle timeout and the keepalive that defeats it, Fargate
pull and start times with the chown fix, the ECS health check against the
agent, and what survives the app quitting. The SSM supervisor's drop reason
now carries the plugin's exit status as well as its last output line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jknapp
merged commit 95991c1309 into main2026-11-14 23:16:55 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Phase 2b of the AWS host spec: the transport from the app to
triple-c-agentin an ECS task. It also fixes an entrypoint problem that made Fargate starts take ~6½ minutes. Everything was tested against a real Fargate task in a throwaway account setup, since torn down.What's in it
host/ssm.rs: one SSM port-forward (AWS-StartPortForwardingSession) per task, run through AWS'ssession-manager-pluginas a child process, the way the AWS CLI does it.AgentEndpointstays valid.TerminateSession), including one whose open is cancelled partway.GET /v1/healthevery 5 minutes. Session Manager cuts a silent tunnel at ~20 min, taking every open connection with it. The agent answers 401 before counting activity, so this won't defeat idle auto-stop.AWS_*variable, then given only the session's own credentials (for itsResumeSession), cached until 15 minutes before expiry.EcsTask::resolve(viaDescribeTasks) builds the SSM target and refuses tasks that aren't running or have no ECS Exec agent.find_pluginlooks inTRIPLEC_SSM_PLUGIN, thenPATH, then the installer locations (the macOS GUI app gets no shellPATH).allow(dead_code)until then.Dependencies:
aws-config,aws-sdk-ssm,aws-sdk-ecs. Phase 3 needs them anyway. Default features are off, so only the current TLS client is used, not the legacy rustls 0.21 / hyper 0.14 stack the defaults add. The tree keeps a single rustls.entrypoint.sh:chown -R /home/claudebecame afindthat chowns only what isn't alreadyclaude's./home/claudeis the image's own copy, so the blanket chown copied the whole tree up: ~5.5 min on 1 vCPU and ~2 GB of ephemeral storage.RunTask).chown -R, symlinks included (checked in a container and in the task).Measured on Fargate (us-west-2, 1 vCPU / 2 GB, plugin 1.2.896)
execute-command)HEALTHY105 s afterRunTaskkill -9'd tunnelAll of this, plus the health check and what survives the app quitting, is now in the spec (
2026-11-13-aws-ecs-host-design.md: "To verify" and "Lifecycle and status").Tests
AWS_*stripping, the keepalive's shape and cadence, and a dropped open still sendingTerminateSession(against a fake SSM endpoint; the test fails without the fix).host/ssm/live_tests.rs(env in the file header): the operations through a real tunnel plus a forced drop, and a long hold with a silent stretch.Review
An independent review found three issues:
Not in this PR
session-manager-pluginwith the app (packaging).🤖 Generated with Claude Code
Measured: a silent port forward was cut at 19.7 minutes ("timed out due to inactivity"), taking every connection through it with it. Every 5 minutes the supervisor now sends an unauthenticated GET /v1/health through the tunnel; the agent answers 401 before counting activity, so this keeps the session alive without defeating idle auto-stop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>