Compare commits

...
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 8ec2cfece6 fix(aws): final review wave (incomplete badge, removable bad connections, partition regions, format chars, store modify, docs)
Secret Scan / scan (push) Successful in 11s
Build App (Preview) / compute-version (pull_request) Successful in 14s
Build App (Preview) / create-release (pull_request) Successful in 10s
Build App (Preview) / build-linux (pull_request) Successful in 11m3s
Build App (Preview) / build-macos (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 11m27s
Build App (Preview) / prune-previews (pull_request) Successful in 21s
Secret Scan / scan (pull_request) Successful in 11s
Build App (Preview) / test (pull_request) Successful in 7m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:16:19 -08:00
shadowdaoandClaude Opus 5.5 4694ae60c2 fix(aws): narrow principal inspection to SSO and triple-c roles (R18)
InspectOwnPrincipal allowed iam:GetRole and iam:SimulatePrincipalPolicy on
role/*, letting any workspace user enumerate and simulate every role. IAM
refuses ${aws:PrincipalArn} as a Resource and has no role-name variable, so
the role part is now SSO permission-set roles
(role/aws-reserved/sso.amazonaws.com/*) and triple-c-* roles; IAM users stay
limited to themselves. Callers on other roles are probed and reported
Insufficient; the README says how an admin can add their sign-in roles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:04:28 -08:00
shadowdaoandClaude Opus 5.5 b527c82400 test(aws): live IAM verification of the tier policies; docs
Real IAM contradicted the policies in five places, now fixed:
- IAM refuses ${aws:PrincipalArn} as a Resource, so neither tier policy
  could be attached. Self-inspection is user/${aws:username} for IAM users
  and role/* for role sessions (no role-name variable exists).
- ecs:ExecuteCommand is authorized against the cluster as well as the task;
  added an unconditioned cluster statement (the owner rule stays on the task).
- Secrets Manager needs kms:Decrypt with GenerateDataKey on a customer key.
- elasticfilesystem:CreateFileSystem has no resource type (Resource "*").
- ssmmessages:OpenDataChannel on the caller's own sessions (AWS docs).

The permission check now uses adaptive retries for IAM (SimulatePrincipalPolicy
throttled a full check), and the UI no longer says the simulator ignores
permission boundaries (it applies the principal's own; verified).

aws::live_tests (ignored, env-gated) repeats the verification with throwaway
triple-c-e2e-* roles, secrets and keys. New drift tests: every Resource is an
ARN or *, and every policy fits a 6,144-character managed policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 17:45:42 -08:00
shadowdaoandClaude Opus 5.5 01eab26a48 fix(aws): Hosts UI sso_expired dead end, add retry safety, focus, profile errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 17:04:26 -08:00
shadowdaoandClaude Opus 5.5 1bf9c1c959 feat(aws): Settings → Hosts with AWS connections and permission check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 17:02:26 -08:00
shadowdaoandClaude Opus 5.5 90b10d48d2 fix(aws): settings import keeps imported host connections
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:59:52 -08:00
shadowdaoandClaude Opus 5.5 1df7dfc0a8 fix(aws): host connections cannot be changed through update_settings; harden sso login and add path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:57:47 -08:00
shadowdaoandClaude Opus 5.5 1afddc0ba9 feat(aws): Tauri commands for AWS connections and permission checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:54:47 -08:00
shadowdaoandClaude Opus 5.5 c82b63f48a fix(aws): permission summary can't be claimed through errors or probes
check_permissions now returns Result: credential errors from any call,
and non-denied failures resolving the principal, are errors rather than
a report. Other per-check failures (throttling, network) make the row
Unverified with a note, and a noted row blocks its tier. A probed run
never claims EnvironmentAdmin (R15), and the own-principal check whose
denial forced probing is reported Denied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:49:34 -08:00
shadowdaoandClaude Opus 5.5 634d1f5a7e feat(aws): tiered permission check with simulation and probe fallback
check_permissions simulates every check of both tiers against the
caller's resolved principal (bounded to 4 in flight), rendering resources
and literal context values, sending OwnerId and aws:userid as the
caller's user id, and grouping repeated context keys into one StringList
entry. An unsimulatable principal, a denied iam:GetRole or a denied first
simulation falls back to four read-only probes. Results are one row per
action (worst verdict wins).

The trait returns Send futures so the check is Send for Tauri commands.
The module-wide dead_code/unused_imports allow is replaced by allows on
the Task 6 entry points and the re-exports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:43:35 -08:00
shadowdaoandClaude Opus 5.5 7fdd5dc277 fix(aws): explicitly deny Secrets Manager encryption with non-environment keys
The AWS-managed aws/secretsmanager key (or a customer key whose key policy
grants account principals) needs no identity-policy Allow, so the tagged-key
Allow alone did not keep user secrets on environment keys. An explicit Deny
on kms:Encrypt / kms:GenerateDataKey* via secretsmanager when the key lacks
triple-c:component does. README claim corrected; verify-live entry added.
Check.context doc now states the StringList rule as a requirement on the
simulation builder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:34:35 -08:00
shadowdaoandClaude Opus 5.5 9d1630eb97 fix(aws): user secrets on the environment key; no component tag from workspace users
- CreateOwnSecrets requires secretsmanager:KmsKeyId to be a key ARN, and a
  workspace user's kms:Encrypt/GenerateDataKey (via Secrets Manager) only
  works on component-tagged keys, so secrets can't land on the default
  aws/secretsmanager key that the boundary can't decrypt.
- Every workspace statement that sets the owner tag limits aws:TagKeys to
  triple-c:owner/project/environment (ForAllValues:StringEquals), so a user
  can never set triple-c:component.
- Evaluator: multi-valued context keys and ForAllValues (vacuous when
  absent); Check documents repeated keys as one StringList entry.
- README: environment-key invariant and new verify-live entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:31:38 -08:00
shadowdaoandClaude Opus 5.5 8e6983cf53 fix(aws): close secret claiming, CFN service-role and boundary-reach gaps
- workspace-user: TagOwnSecrets refuses component-tagged secrets, plus an
  explicit Deny on Tag/Untag/Put/Delete of component-tagged secrets;
  CreateAccessPoint only on the component-tagged file system.
- environment-admin: Deny PutRolePermissionsBoundary on triple-c-* roles to
  anything but the triple-c boundary; Deny Create/UpdateStack and
  CreateChangeSet on triple-c-* stacks with a service role.
- permissions boundary: kms:Decrypt and EFS client access only on
  component-tagged keys and file systems; ClientRootAccess dropped (access
  points fix the POSIX user).
- README: invariants for phase 4 templates, the actions that are on *, the
  phase-6 access-point root note, and the list to verify live.
- Tests: negated operators in the evaluator, both tiers evaluated together,
  secret claiming, service-role and boundary-reach cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:25:57 -08:00
shadowdaoandClaude Opus 5.5 02d88cb91d fix(aws): bound admin-created roles, tag-scope admin destruction, tighten workspace policy
- Ship aws/policies/permissions-boundary.json (triple-c-permissions-boundary):
  the most any Triple-C role may hold, nothing in IAM.
- environment-admin: role writes require iam:PermissionsBoundary = the
  boundary; explicit Denies keep the boundary on roles and the boundary
  policy unchanged. Security groups, EFS file systems and KMS keys need
  triple-c:component on create and to be modified or destroyed; tagging only
  at create or on already-tagged resources.
- workspace-user: RunTask needs an owner-tagged task definition; EFS access
  points create/delete by owner tag.
- Both tiers: iam:GetRole and iam:SimulatePrincipalPolicy on the caller's own
  ${aws:PrincipalArn} only, so SSO callers can be simulated.
- Checks carry the matching context; render() gains {principal}. The test
  evaluator resolves policy variables and applies explicit Denies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:15:57 -08:00
shadowdaoandClaude Opus 5.5 1c337f9198 feat(aws): shipped tier policies and the permission check table
aws/policies/{workspace-user,environment-admin}.json are the IAM policies
for the two tiers; aws::policies embeds them and holds one representative
simulator check per action. Tests keep table and JSON in lock-step and run
each check through a small IAM-style evaluator, including the isolation
rule: owner-scoped actions are denied for someone else's resource.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 16:06:33 -08:00
shadowdaoandClaude Opus 5.5 92b27a8e32 fix(aws): hand-built SdkConfig ignores env/profile endpoints and settings; revoked SSO session is SsoExpired
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:59:31 -08:00
shadowdaoandClaude Opus 5.5 4acc36187e feat(aws): SDK config from a connection, caller identity, principal resolution
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:53:47 -08:00
shadowdaoandClaude Opus 5.5 2dea30f4fb feat(aws): fail-closed overwrite check, name local connections, disclose removals in import preview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:46:05 -08:00
shadowdaoandClaude Opus 5.5 664697b151 feat(aws): disclose imported AWS connections and keys in the import preview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:43:41 -08:00
shadowdaoandClaude Opus 5.5 e42261bac8 refactor(aws): one definition of the keychain id rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:42:28 -08:00
shadowdaoandClaude Opus 5.5 60dad7b6b6 feat(aws): keyring storage for AWS access keys, carried by settings export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:40:56 -08:00
shadowdaoandClaude Opus 5.5 e8bbe90327 feat(aws): HostConnection model in app settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:37:49 -08:00
shadowdaoandClaude Opus 5.5 7e2800989f Plan: AWS host phase 3, connection and permission check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 15:27:15 -08:00
jknapp 95991c1309 Merge pull request 'AWS host phase 2b: SSM port-forward transport to the agent, and a Fargate-sized chown' (#78) from feat/ssm-transport into main
Secret Scan / scan (push) Successful in 12s
Build Container / build-container (push) Successful in 20m6s
2026-11-14 23:16:54 +00:00
jknapp b6e59154cc Merge pull request 'CI: cut releases by hand instead of on every merge to main' (#79) from ci/release-gate into main
Secret Scan / scan (push) Successful in 14s
Build STT Container / build-stt-container (push) Successful in 4m30s
Build Container / build-container (push) Successful in 15m20s
2026-11-14 23:16:38 +00:00
shadowdaoandClaude Opus 5.5 bd0a52269b CI: only main publishes :latest, and only from a tag on main
Secret Scan / scan (pull_request) Successful in 12s
Secret Scan / scan (push) Successful in 5s
Build STT Container / build-stt-container (pull_request) Successful in 2m33s
Build Container / build-container (pull_request) Successful in 18m56s
A dispatch from another branch, or a v* tag cut on a branch, could otherwise
become the image every user pulls. An older tag on main is still accepted
so a rollback works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 14:56:40 -08:00
shadowdaoandClaude Opus 5.5 8787f39a38 CI: cut releases by hand instead of on every merge to main
Secret Scan / scan (push) Successful in 25s
Secret Scan / scan (pull_request) Successful in 10s
Build Container / build-container (pull_request) Successful in 1m57s
Build STT Container / build-stt-container (pull_request) Successful in 4m12s
Every merge to main that touched app/ published a signed release, and every
merge touching container/ moved :latest, the image installed apps pull. That
made it impossible to land phased work (the AWS host) on main without
shipping each step.

- build-app.yml runs only on workflow_dispatch. Dispatched on main it
  releases; on any other branch it stays a build/sign/verify dry run.
- Release tags are pinned to the dispatched commit (target_commitish), and
  create-tag now fails if the tag points anywhere else.
- After tagging, the release dispatches build.yml and build-stt.yml with the
  tag. They build that tag and move :latest.
- Pushes to main publish containers as :edge and :<sha> only. The weekly
  security rebuild rebuilds the newest release tag, not main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 14:55:03 -08:00
shadowdaoandClaude Opus 5.5 7462cb05ec AWS spec: phase 2b measurements (SSM, Fargate start, health, re-attach)
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (push) Successful in 7s
Secret Scan / scan (pull_request) Successful in 10s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build Container / build-container (pull_request) Successful in 2m25s
Build App (Preview) / build-macos (pull_request) Successful in 4m36s
Build App (Preview) / build-windows (pull_request) Successful in 9m15s
Build App (Preview) / test (pull_request) Successful in 10m59s
Build App (Preview) / build-linux (pull_request) Successful in 15m27s
Build App (Preview) / prune-previews (pull_request) Successful in 5s
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>
2026-11-14 12:08:12 -08:00
shadowdaoandClaude Opus 5.5 e3c81fd304 SSM transport: keep an Up tunnel busy so Session Manager's idle timeout never fires
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>
2026-11-14 10:48:29 -08:00
shadowdaoandClaude Opus 5.5 50f3d51946 SSM transport: end sessions whose open is cancelled; cache plugin creds
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>
2026-11-14 10:15:04 -08:00
shadowdaoandClaude Opus 5.5 1e85b4d05f SSM port-forward transport to the agent (phase 2b), and a Fargate-sized chown
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>
2026-11-14 10:07:38 -08:00
jknapp cb9e51f08d Merge pull request 'CI: suspend needrestart instead of letting it restart the runner' (#77) from ci/needrestart-suspend into main
Build App / compute-version (push) Successful in 12s
Secret Scan / scan (push) Successful in 9s
Build App / build-macos (push) Successful in 3m25s
Build App / build-windows (push) Successful in 7m56s
Build App / build-linux (push) Successful in 8m43s
Build App / create-tag (push) Successful in 8s
Build App / sync-to-github (push) Successful in 24s
2026-11-14 03:39:13 +00:00
shadowdaoandClaude Opus 5.5 74deabd630 CI: suspend needrestart instead of letting it restart services
Secret Scan / scan (push) Successful in 11s
Build App (Preview) / compute-version (pull_request) Successful in 13s
Secret Scan / scan (pull_request) Successful in 1m15s
Build App (Preview) / create-release (pull_request) Successful in 6s
Build App (Preview) / build-macos (pull_request) Successful in 3m36s
Build App (Preview) / test (pull_request) Successful in 9m14s
Build App (Preview) / build-linux (pull_request) Successful in 6m2s
Build App (Preview) / build-windows (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
On a host-mode ephemeral runner (eph-pve3-112), NEEDRESTART_MODE=a made
needrestart restart act-runner.service during an apt-get install, which
killed the build-linux job (PR #76, run 5217). Skip needrestart entirely
with NEEDRESTART_SUSPEND=1, and fall back to list-only mode (l), which
never prompts and never restarts anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 14:31:55 -08:00
jknapp c2aa9c3be2 Merge pull request 'triple-c-agent: in-container agent, app client, and Docker-parity test (phase 2a)' (#76) from feat/triple-c-agent into main
Build App / compute-version (push) Successful in 5s
Secret Scan / scan (push) Successful in 7s
Build App / build-linux (push) Failing after 26s
Build App / build-macos (push) Successful in 3m19s
Build App / build-windows (push) Successful in 7m6s
Build App / create-tag (push) Skipped
Build App / sync-to-github (push) Skipped
Build Container / build-container (push) Successful in 19m11s
2026-11-13 22:29:38 +00:00
jknapp 36a6c869c9 Merge pull request 'Host abstraction, phase 1b: container lifecycle and exit watching behind the host layer' (#75) from refactor/host-lifecycle into main
Build App / compute-version (push) Successful in 6s
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 3m16s
Build App / build-linux (push) Successful in 6m18s
Build App / build-windows (push) Successful in 7m20s
Build App / create-tag (push) Successful in 19s
Build App / sync-to-github (push) Successful in 29s
2026-11-13 22:29:31 +00:00
shadowdaoandClaude Opus 5.5 114f463bf5 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
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>
2026-11-13 12:35:21 -08:00
shadowdao 2a46f34fca Merge commit 'fe889a7' into feat/triple-c-agent 2026-11-13 12:33:51 -08:00
shadowdaoandClaude Opus 5.5 fe889a7f16 triple-c-agent: reset exec signal state, chown stdio, bound connections
- 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>
2026-11-13 12:33:29 -08:00
shadowdaoandClaude Opus 5.5 fffc6492f1 Address agent review findings 4-5: no secrets on disk, bounded client calls
- 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>
2026-11-13 12:29:05 -08:00
shadowdaoandClaude Opus 5.5 0f638c7c4e Wire triple-c-agent into the image, harden it, and prove Docker parity
- 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>
2026-11-13 12:19:26 -08:00
shadowdao a068ce27bc Merge commit '5d03bea' into feat/triple-c-agent 2026-11-13 12:08:56 -08:00
shadowdaoandClaude Opus 5.5 5d03bea544 Add triple-c-agent: the in-container agent for the v1 wire protocol
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>
2026-11-13 12:08:00 -08:00
shadowdaoandClaude Opus 5.5 d8c5b501f3 Agent client: never follow redirects, cap every small response body
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>
2026-11-13 12:04:32 -08:00
shadowdaoandClaude Opus 5.5 ebaafd8cfc Agent protocol spec: correct the exit-poll budget, document nested upload names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:03:00 -08:00
shadowdao 55ed2d17d0 Merge branch 'worktree-agent-a8d5071f2fd1991d6' into feat/triple-c-agent 2026-11-13 12:03:00 -08:00
shadowdaoandClaude Opus 5.5 48c60ef89c Add the app-side triple-c-agent client as ContainerTarget::Agent
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>
2026-11-13 12:02:35 -08:00
shadowdaoandClaude Opus 5.5 20a25fc518 Entrypoint starts triple-c-agent when given a token; fix the local build context
- 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>
2026-11-13 11:57:03 -08:00
shadowdaoandClaude Opus 5.5 086abd5713 Add the triple-c-agent wire protocol spec (phase 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 11:53:03 -08:00
shadowdaoandClaude Opus 5.5 7c004fed46 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
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>
2026-11-13 11:22:24 -08:00
shadowdao 45e55b5f7c Merge branch 'worktree-agent-a50fe8b64e4be72dc' into refactor/host-lifecycle 2026-11-13 11:18:25 -08:00
shadowdaoandClaude Opus 5.5 83d63e756d Host phase 1a follow-up: pass ContainerTarget instead of container ids
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>
2026-11-13 11:18:03 -08:00
shadowdaoandClaude Opus 5.5 cc973c8291 Move the container exit watcher's Docker calls behind host::watch
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>
2026-11-13 11:14:58 -08:00
103 changed files with 19813 additions and 732 deletions

No files matched your search

+26 -7
View File
@@ -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 \
+76 -35
View File
@@ -1,20 +1,22 @@
name: Build App
# **Releases are cut by hand.** This runs only when someone dispatches it
# (Actions -> Build App -> Run workflow). Merging to main does not release:
# main can carry finished-but-dormant work (a feature landing in phases) and
# nothing reaches users until a release is cut on purpose.
#
# Dispatched on `main` it releases: Gitea and GitHub releases, the version tag,
# the Linux update channel, and then the container images' `:latest` (see the
# create-tag job). Dispatched on any other branch it is a dry run that builds,
# signs and verifies all three platforms and publishes nothing, which is how to
# exercise Windows signing before a merge. Every publishing step and job
# carries the same `if:` (dispatch event *and* ref main); keep them in step if
# one is ever added.
on:
push:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
- "scripts/windows-*.ps1"
workflow_dispatch:
# Deliberately **not** on pull_request. Every publishing step here is gated on
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
# the same three and publishes them. Six OS builds per push, one set of which
# was unreachable. Previews now carry the PR check; this workflow is releases.
# Deliberately **not** on pull_request: build-app-preview.yml carries the PR
# check (tests plus all three platforms), and this workflow is releases only.
env:
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
@@ -22,7 +24,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 +133,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 +155,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 \
@@ -256,7 +264,7 @@ jobs:
fi
- name: Upload to Gitea release
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
@@ -282,7 +290,7 @@ jobs:
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
;;
*)
@@ -334,7 +342,7 @@ jobs:
# from the versioned release above because the updater's URL must never
# move, and `releases/latest` does.
- name: Publish the Linux update channel
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -423,7 +431,7 @@ jobs:
ls -la artifacts/
- name: Upload to Gitea release
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
@@ -449,7 +457,7 @@ jobs:
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
;;
*)
@@ -711,7 +719,7 @@ jobs:
dir artifacts\
- name: Upload to Gitea release
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
shell: powershell
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -735,8 +743,9 @@ jobs:
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
Write-Host "Release $tag not found, creating"
$body = @{
tag_name = $tag
name = "Triple-C v$env:VERSION (Windows)"
tag_name = $tag
target_commitish = $env:COMMIT_SHA
name = "Triple-C v$env:VERSION (Windows)"
body = "Automated build from commit $env:COMMIT_SHA"
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Headers $headers `
@@ -772,7 +781,7 @@ jobs:
create-tag:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -782,24 +791,56 @@ jobs:
- name: Create version tag
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.compute-version.outputs.version }}
SHA: ${{ gitea.sha }}
run: |
VERSION="${{ needs.compute-version.outputs.version }}"
set -euo pipefail
TAG="v${VERSION}"
echo "Creating tag ${TAG}..."
# Create annotated tag via Gitea API
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
# Usually already there: creating the Linux release creates its tag,
# at this commit (`target_commitish`). Create it if not, then insist
# it points here, because the container step below builds whatever
# the tag names.
if ! curl -fsS -o tag.json -H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}"; then
echo "Creating tag ${TAG} at ${SHA}"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${SHA}\", \"message\": \"Release ${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags" > tag.json
fi
TAGGED=$(python3 -c "import json; print(json.load(open('tag.json'))['commit']['sha'])")
if [ "${TAGGED}" != "${SHA}" ]; then
echo "Tag ${TAG} points at ${TAGGED}, not the released commit ${SHA}" >&2
exit 1
fi
echo "Tag ${TAG} is at ${SHA}"
echo "Tag ${TAG} created successfully"
# The containers' `:latest` is what every installed app pulls, so it
# moves with an app release and at no other time (pushes to main only
# publish `:edge` and `:<sha>`). Each container workflow builds the tag
# it is handed, not main, so a merge landing mid-release cannot ride
# along.
- name: Release the container images
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
set -euo pipefail
for WORKFLOW in build.yml build-stt.yml; do
echo "Dispatching ${WORKFLOW} for v${VERSION}"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\": \"main\", \"inputs\": {\"release_tag\": \"v${VERSION}\"}}" \
"${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/${WORKFLOW}/dispatches"
done
sync-to-github:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
+69 -6
View File
@@ -11,6 +11,12 @@ on:
paths:
- "stt-container/**"
- ".gitea/workflows/build-stt.yml"
# Dispatched by build-app.yml when a release is cut, with the release's tag.
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to build as :latest (default: the newest vX.Y.Z)"
required: false
env:
REGISTRY: repo.anhonesthost.net
@@ -22,6 +28,65 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# **`:latest` is what installed apps pull, so only a release moves it.**
# A push to main publishes `:edge` and `:<sha>` (for testing a merged
# image by hand); a pull request publishes nothing. The weekly rebuild
# and a release dispatch build a release tag and publish it as `:latest`
# — the tag, not main, so unreleased container changes never reach users.
- name: Pick the source and the tags to publish
id: src
env:
EVENT: ${{ gitea.event_name }}
RELEASE_TAG: ${{ gitea.event.inputs.release_tag }}
REF: ${{ gitea.ref }}
run: |
set -euo pipefail
case "$EVENT" in
push|pull_request)
CHANNEL=edge
;;
schedule|workflow_dispatch)
CHANNEL=latest
# `:latest` reaches every user, so it only comes from main: a
# dispatch from another branch, or a tag cut on one, is refused.
# An older release tag is allowed on purpose — that is a rollback.
if [ "$REF" != "refs/heads/main" ]; then
echo "Refusing to publish :latest from $REF (only main)" >&2
exit 1
fi
git fetch --tags --force
git fetch origin main
if [ -z "${RELEASE_TAG:-}" ]; then
RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
fi
if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Not a release tag: '$RELEASE_TAG'" >&2
exit 1
fi
if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then
echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2
exit 1
fi
git checkout --detach "refs/tags/$RELEASE_TAG"
echo "Building release $RELEASE_TAG"
;;
*)
echo "Unexpected event $EVENT" >&2
exit 1
;;
esac
SHA=$(git rev-parse HEAD)
{
echo "tags<<EOF"
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$CHANNEL"
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$SHA"
echo "ghcr.io/shadowdao/triple-c-stt:$CHANNEL"
echo "ghcr.io/shadowdao/triple-c-stt:$SHA"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -49,12 +114,10 @@ jobs:
context: ./stt-container
file: ./stt-container/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ gitea.event_name == 'push' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-stt:latest
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
# A pull_request run only validates; see the tag step for what each
# other event publishes.
push: ${{ gitea.event_name != 'pull_request' }}
tags: ${{ steps.src.outputs.tags }}
# The gha cache is served by the runner that picks the job up, and
# is unreachable from the job on some of ours (the home box's
# firewalled per-job bridge; ephemeral runners). Import failures
+69 -8
View File
@@ -13,9 +13,15 @@ on:
- ".gitea/workflows/build.yml"
# Weekly rebuild with no commit needed, so OS security fixes published since
# the last push reach the image (see APT_REFRESH in container/Dockerfile).
# Scheduled runs use the default branch, i.e. main.
# It rebuilds the newest *release*, not main, because it moves `:latest`.
schedule:
- cron: "17 4 * * 1"
# Dispatched by build-app.yml when a release is cut, with the release's tag.
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to build as :latest (default: the newest vX.Y.Z)"
required: false
env:
REGISTRY: repo.anhonesthost.net
@@ -27,6 +33,65 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# **`:latest` is what installed apps pull, so only a release moves it.**
# A push to main publishes `:edge` and `:<sha>` (for testing a merged
# image by hand); a pull request publishes nothing. The weekly rebuild
# and a release dispatch build a release tag and publish it as `:latest`
# — the tag, not main, so unreleased container changes never reach users.
- name: Pick the source and the tags to publish
id: src
env:
EVENT: ${{ gitea.event_name }}
RELEASE_TAG: ${{ gitea.event.inputs.release_tag }}
REF: ${{ gitea.ref }}
run: |
set -euo pipefail
case "$EVENT" in
push|pull_request)
CHANNEL=edge
;;
schedule|workflow_dispatch)
CHANNEL=latest
# `:latest` reaches every user, so it only comes from main: a
# dispatch from another branch, or a tag cut on one, is refused.
# An older release tag is allowed on purpose — that is a rollback.
if [ "$REF" != "refs/heads/main" ]; then
echo "Refusing to publish :latest from $REF (only main)" >&2
exit 1
fi
git fetch --tags --force
git fetch origin main
if [ -z "${RELEASE_TAG:-}" ]; then
RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
fi
if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Not a release tag: '$RELEASE_TAG'" >&2
exit 1
fi
if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then
echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2
exit 1
fi
git checkout --detach "refs/tags/$RELEASE_TAG"
echo "Building release $RELEASE_TAG"
;;
*)
echo "Unexpected event $EVENT" >&2
exit 1
;;
esac
SHA=$(git rev-parse HEAD)
{
echo "tags<<EOF"
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$CHANNEL"
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$SHA"
echo "ghcr.io/shadowdao/triple-c-sandbox:$CHANNEL"
echo "ghcr.io/shadowdao/triple-c-sandbox:$SHA"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -81,16 +146,12 @@ jobs:
context: ./container
file: ./container/Dockerfile
platforms: linux/amd64,linux/arm64
# Publish from main pushes and the weekly scheduled rebuild (which
# runs on main); a pull_request run only validates.
# A pull_request run only validates; see the tag step for what each
# other event publishes.
push: ${{ gitea.event_name != 'pull_request' }}
build-args: |
APT_REFRESH=${{ steps.apt.outputs.stamp }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
tags: ${{ steps.src.outputs.tags }}
# `ignore-error` is what stops a cache failure failing a build that
# already succeeded. act_runner emulates the GitHub Actions cache
# service on the runner host's LAN address, and the `docker-container`
+1
View File
@@ -1,6 +1,7 @@
node_modules/
app/dist/
app/src-tauri/target/
container/triple-c-agent/target/
# Written by build.rs (tauri-build AppManifest); gen/schemas/acl-manifests.json is the
# tracked, reviewable form of the same information.
app/src-tauri/permissions/autogenerated/
+81 -9
View File
@@ -283,14 +283,48 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
users have migrated.
- **`host/`** — Where a project's container runs, and the only way into it. `ContainerTarget`
(one `Docker` variant today; AWS planned — `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`)
(`Docker`, and `Agent` — reached through the in-container `triple-c-agent` by `agent.rs`, per
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`; AWS planned —
`docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`)
wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`.
`ssm.rs` is the AWS transport to an `Agent` target: one SSM port-forward per task, run through
AWS's `session-manager-plugin` as a child process and reopened **on the same loopback port**
when it drops, so an `AgentEndpoint` stays valid. The `StartSession` token goes to the plugin
through its environment, never argv, and the plugin's environment is stripped of every
inherited `AWS_*` variable before the session's own credentials are added.
`agent.rs` reuses `docker/exec.rs`'s `OneshotOutput`, host-file reader and uid probe so the two
variants cannot drift; an `Agent` target's token never appears in `Debug` or error text.
**Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or
`get_docker()`** — `host::tests::no_container_interior_bollard_outside_the_host_layer` fails
the build on it. Its allowlist is Docker-only code (migration) plus lifecycle/`die` events,
which phase 1b moves; shrink it, never grow it. Prefer `ContainerTarget::for_project` or a
target a session already holds (`ExecSessionManager::get_target`) over
`ContainerTarget::docker(id)`, which hard-codes the host.
the build on it. `lifecycle.rs`'s `ProjectHost` owns create/start/stop/find/remove and
discarding a project's persistent state; the project commands keep only host-neutral steps
(validation, status, sessions, auth bridge, syncs). `watch.rs` gives `container_watch` its
death reports and positive-exit check. `no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer`
fails on lifecycle calls or `ContainerTarget::docker(id)` in non-test code outside `host/` and
`docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session
(`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception
(`DOCKER_ONLY`); shrink it, never grow it.
- **`aws/`** — AWS connections (Settings → Hosts) and the permission check. `connection.rs`
builds a saved `HostConnection`'s `SdkConfig` **by hand, never with
`aws_config::defaults().load()`**: a profile is read through `ProfileFileCredentialsProvider`
for credentials only, so the app's own `AWS_*` variables, `AWS_ENDPOINT_URL*` and a profile's
endpoint/service settings can't redirect or re-credential a connection's signed requests.
`identity.rs` is `GetCallerIdentity` and the principal to simulate (an assumed role's ARN is
recovered with `iam:GetRole`, because SSO roles have an `/aws-reserved/…` path the session ARN
lacks); `error.rs` classifies SDK errors (SSO expired, access denied naming the action, …);
`policies.rs` embeds `aws/policies/*.json` (the two tier policies and the permissions boundary)
and the table of checks the simulator is asked; `permissions.rs` runs it
(`SimulatePrincipalPolicy`, adaptive retries — it throttles — or read-only probes when the
caller can't simulate itself; a probed run never claims the admin tier). `policies.rs`'s tests
are the drift guard: every allowed action has a check, every check is allowed by a small IAM
evaluator, owner-scoped checks are denied for someone else, admin destruction is tag-scoped,
the boundary fences hold, every Resource is an ARN and every policy fits a 6,144-character
managed policy. **Change a policy JSON, its checks and `aws/README.md` together.** The evaluator
is not IAM: `live_tests.rs` (ignored; `TRIPLEC_AWS_E2E_PROFILE`, `TRIPLEC_AWS_E2E_REGION`,
admin credentials for a disposable account; `cargo test aws::live_tests -- --ignored
--test-threads=1`) runs the check and real calls against throwaway `triple-c-e2e-*` roles,
secrets and keys, and cleans them up. Re-run it after any policy change; the results are in
`aws/README.md` ("Live verification").
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
@@ -330,6 +364,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
@@ -767,6 +814,12 @@ a project's Docker volumes are deliberately out of scope — this is not a proje
inside a generic "settings replaced" summary. Read this as the standing example of the class of
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
like config but is actually a live credential would have the same problem.
- **AWS connection keys travel in `ExportedSecrets::aws_connection_keys`** (keyed by connection id),
exported and restored like the marketplace tokens. `hosts` are **store-owned**: `update_settings`
restores them via `restore_host_fields`, and `apply_settings_import` re-applies them through
`StoreOwned`. `validate_imported_hosts` runs before any keychain write. The import preview
discloses AWS connections, key counts, overwrites (naming the local connection), failed key
checks and removals.
- **Marketplace account tokens travel in `ExportedSecrets`, not in `AppSettings`.** `Token` and
`GhContainer` accounts' tokens live in the keychain (`triple-c-marketplace-account-<id>`), so
they follow the same "carve out of the keychain, restore before the settings replace, only
@@ -886,6 +939,25 @@ a project's Docker volumes are deliberately out of scope — this is not a proje
## Packaging
### Releases are cut by hand
**Merging to `main` does not release anything users get.** A release is cut by dispatching
`build-app.yml` on `main` (Actions -> Build App -> Run workflow). That one run publishes the Gitea
and GitHub releases for all three platforms, the `vX.Y.Z` tag pinned to the dispatched commit, and
the Linux update channel. Its last step dispatches `build.yml` and `build-stt.yml` with the new tag,
and those rebuild that tag and move `:latest`, which is what installed apps pull.
- Dispatching `build-app.yml` on any other branch is a **dry run**. It builds, signs and verifies,
and publishes nothing. Every publishing step and job is gated on
`gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'`, so give any new
publishing step the same `if:`.
- A push to `main` that touches `container/` or `stt-container/` publishes only `:edge` and
`:<sha>`, never `:latest`. The weekly scheduled sandbox rebuild (OS security fixes) rebuilds the
**newest release tag**, not main.
- This exists so that main can carry dormant work (a feature landing in phases) without each
merge shipping. Until the gate, every merge to main touching `app/` released, and every merge
touching `container/` moved `:latest`.
Linux ships as **AppImage only**, built by `build-app.yml` (releases) and
`build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to
build and publish for an audience the AppImage already serves, and neither could self-update. The
@@ -931,8 +1003,8 @@ quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml
reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month,
against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a
secret available there is available to whoever can push a branch. To exercise signing before a
merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on
`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing.
merge, dispatch `build-app.yml` on the branch. Off `main` it builds, signs and verifies
without releasing (see Releases above).
- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client
(`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace,
@@ -965,8 +1037,8 @@ app"), never by the preview workflow, never echoed, and never on a command line.
public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's
`/debug` output carries no tokens (checked against its strings). Anyone who can push to this
repo can reach the secrets through a workflow file, so repo write access is the boundary.
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed
release only ever comes from a merged, visible change. The
`main` is branch-protected (no direct or force pushes; changes land by merging a PR), and only a
dispatch on `main` publishes, so a signed release only ever comes from a merged, visible change. The
Azure side should hold the rest: an app registration with only the signer role on this one
certificate profile, and a client secret with an expiry. Four things are load-bearing:
+677 -25
View File
@@ -14,7 +14,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"generic-array",
]
@@ -275,6 +275,49 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-config"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16ec023cac0d0c0432e606a2d14f04eb70d09c71a739a406141e7be6b923ab95"
dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-sdk-sso",
"aws-sdk-ssooidc",
"aws-sdk-sts",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"hex",
"http",
"sha1",
"time",
"tokio",
"tracing",
"url",
"zeroize",
]
[[package]]
name = "aws-credential-types"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
"zeroize",
]
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
@@ -298,6 +341,471 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "aws-runtime"
version = "1.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de8930c11353c72ae54aa24dc94d692df74d59814839cbaf8069d63dbcb4c519"
dependencies = [
"aws-credential-types",
"aws-sigv4",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
"aws-types",
"bytes",
"bytes-utils",
"fastrand",
"http",
"http-body",
"percent-encoding",
"pin-project-lite",
"tracing",
"uuid",
]
[[package]]
name = "aws-sdk-cloudformation"
version = "1.139.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b77def03a5fb2442348afdd6ecd0eafbfa117243865cc78e899beb26edf7fb6d"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-query",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-smithy-xml",
"aws-types",
"fastrand",
"http",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-ecs"
version = "1.159.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecd8f39cf3e41848314347fc3b5242cbf14291258c9b8f4f61156735627726bf"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"fastrand",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-iam"
version = "1.136.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f53b6d3c4f3f04b734a5ae6eea34491e5c356be99bc677fa7fd84eba0cc671b"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-query",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-smithy-xml",
"aws-types",
"fastrand",
"http",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-kms"
version = "1.131.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6309b510ca62e64bafb147a08413d81665fdcaa2b607423d348c576b5d4a89"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"fastrand",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-secretsmanager"
version = "1.128.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d77ef7832d9e7c60fa7b8201bb2038ccc9821b6cf88746caab6dc1c8767882b5"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"fastrand",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-ssm"
version = "1.137.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5dffb9ec07fac5dbbf475396fbe1a4a319ee46528b7054662c518f18d00421"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"fastrand",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-sso"
version = "1.121.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d414ba8f00c2b4b506893ded67827e63be88b5e6cce71b7c0ac8f96ce09bc5"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"http",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-ssooidc"
version = "1.123.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db26187b124bcc637641f7963d5200c5ec2409ea5b7730dd7e014ef4e0ab6f19"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"fastrand",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-sts"
version = "1.126.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c667da7839295125cfe23280f798c07ba694a9fed191af804edb4bda09ac7f10"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-query",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-smithy-xml",
"aws-types",
"fastrand",
"http",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sigv4"
version = "1.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a4393e6addc3380632887b0998d06ad5f099ab5c478be0a3eeedecf8604161"
dependencies = [
"aws-credential-types",
"aws-smithy-http",
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
"form_urlencoded",
"hex",
"hmac",
"http",
"percent-encoding",
"sha2 0.11.1",
"time",
"tracing",
]
[[package]]
name = "aws-smithy-async"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e"
dependencies = [
"futures-util",
"pin-project-lite",
"tokio",
]
[[package]]
name = "aws-smithy-http"
version = "0.64.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
"bytes-utils",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"percent-encoding",
"pin-project-lite",
"tracing",
]
[[package]]
name = "aws-smithy-http-client"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "859a81364f8185349c584fb41cd8765a400cf982f447adada590cf008a9f3b85"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2",
"http",
"hyper",
"hyper-rustls",
"hyper-util",
"pin-project-lite",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower",
"tracing",
]
[[package]]
name = "aws-smithy-json"
version = "0.65.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
]
[[package]]
name = "aws-smithy-observability"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a416257d7b1b0f32a517a470a8c1dd354d8c4aa697ccab81a11a1e6cfb7418e3"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"tracing",
]
[[package]]
name = "aws-smithy-query"
version = "0.64.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165f6265342a313ad6f7a1ac17a0cdba30c522039d3f41d8d6871ce8a2b34ecd"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-smithy-xml",
"urlencoding",
]
[[package]]
name = "aws-smithy-runtime"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bc688673e282f42d3152dcd9cacc1a9a75734d7cd39d02e58989276fc750f3c"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-http-client",
"aws-smithy-observability",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"bytes",
"fastrand",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"tokio",
"tracing",
]
[[package]]
name = "aws-smithy-runtime-api"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0467a2414e84b1b01e37452896fb3562b93926e81c619eba6ecf27ce9e682a0e"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
"aws-smithy-types",
"bytes",
"http",
"pin-project-lite",
"tokio",
"tracing",
"zeroize",
]
[[package]]
name = "aws-smithy-runtime-api-macros"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "aws-smithy-schema"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7ef30d39e131651359b60a1c560de9a9502a4c3754172ab2d6e8734fe21bcba"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"http",
]
[[package]]
name = "aws-smithy-types"
version = "1.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aff4bf014c512840b2bba80a20417b723cbd6a0a65c02e2d81f50b4268aedfc7"
dependencies = [
"base64-simd",
"bytes",
"bytes-utils",
"futures-core",
"http",
"http-body",
"http-body-util",
"itoa",
"pin-project-lite",
"ryu",
"serde",
"time",
"tokio",
"tokio-util",
]
[[package]]
name = "aws-smithy-xml"
version = "0.64.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"xmlparser",
]
[[package]]
name = "aws-types"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"rustc_version",
"tracing",
]
[[package]]
name = "axum"
version = "0.8.8"
@@ -371,6 +879,16 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -413,7 +931,7 @@ version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -425,6 +943,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -556,6 +1083,16 @@ dependencies = [
"serde",
]
[[package]]
name = "bytes-utils"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35"
dependencies = [
"bytes",
"either",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -705,7 +1242,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"inout",
]
@@ -727,6 +1264,12 @@ dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "combine"
version = "4.6.7"
@@ -746,6 +1289,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a"
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -871,6 +1420,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.29.6"
@@ -936,6 +1494,15 @@ dependencies = [
"cipher",
]
[[package]]
name = "ctutils"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
dependencies = [
"cmov",
]
[[package]]
name = "darling"
version = "0.20.11"
@@ -1158,11 +1725,23 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -1387,7 +1966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2887,6 +3466,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.3",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -2954,6 +3542,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.8.1"
@@ -3002,6 +3599,7 @@ dependencies = [
"hyper",
"hyper-util",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
@@ -4158,6 +4756,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "pango"
version = "0.18.3"
@@ -4742,7 +5346,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -4965,6 +5569,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"
@@ -5118,7 +5728,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5176,7 +5786,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5369,9 +5979,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -5391,22 +6001,22 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn 3.0.9",
]
[[package]]
@@ -5565,7 +6175,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -5582,7 +6192,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
@@ -6013,7 +6634,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"syn 2.0.117",
"tauri-utils",
"thiserror 2.0.18",
@@ -6227,10 +6848,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6615,6 +7236,17 @@ version = "0.4.0"
dependencies = [
"aes-gcm",
"argon2",
"aws-config",
"aws-credential-types",
"aws-runtime",
"aws-sdk-cloudformation",
"aws-sdk-ecs",
"aws-sdk-iam",
"aws-sdk-kms",
"aws-sdk-secretsmanager",
"aws-sdk-ssm",
"aws-sdk-sts",
"aws-smithy-async",
"axum",
"base64 0.22.1",
"bollard",
@@ -6633,7 +7265,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"similar",
"tar",
"tauri",
@@ -6642,6 +7274,8 @@ dependencies = [
"tauri-plugin-opener",
"tempfile",
"tokio",
"tokio-tungstenite",
"tokio-util",
"tower-http",
"url",
"uuid",
@@ -6781,7 +7415,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"subtle",
]
@@ -6804,6 +7438,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -6852,6 +7492,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "vswhom"
version = "0.1.0"
@@ -7179,7 +7825,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7743,7 +8389,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2",
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.18",
@@ -7788,6 +8434,12 @@ dependencies = [
"rustix",
]
[[package]]
name = "xmlparser"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
[[package]]
name = "yoke"
version = "0.8.1"
+29
View File
@@ -33,6 +33,13 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
iana-time-zone = "0.1"
sha2 = "0.10"
axum = { version = "0.8", features = ["ws"] }
# Client side of triple-c-agent's two WebSocket endpoints (host/agent.rs). The
# same version axum's `ws` already pulls in; plain ws:// only (the transport is
# loopback or an SSM port-forward), so no TLS features.
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect"] }
# `PollSender`: the agent attach's stdin `AsyncWrite` over a bounded channel.
# Already in the tree through tokio's ecosystem.
tokio-util = "0.7"
tower-http = { version = "0.6", features = ["cors"] }
base64 = "0.22"
rand = "0.9"
@@ -48,6 +55,25 @@ similar = "2"
# Marketplace repos are fetched on the host into a bare cache (spec §3).
# Blocking client + rustls: no git binary or OpenSSL needed on the host.
gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] }
# AWS host (docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md). The
# service crates' default `rustls` feature is the *legacy* hyper 0.14 /
# rustls 0.21 client, a second TLS stack next to the one reqwest already
# brings; `default-https-client` is the current one, so defaults are off.
aws-config = { version = "1.13.2", default-features = false, features = ["default-https-client", "rt-tokio", "credentials-process", "sso", "behavior-version-latest"] }
aws-sdk-ssm = { version = "1.137.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
aws-sdk-ecs = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
aws-sdk-sts = { version = "1.126.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
aws-sdk-iam = { version = "1.136.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
aws-sdk-cloudformation = { version = "1.139.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
aws-sdk-secretsmanager = { version = "1.128.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
# Named by src/aws/connection.rs: the credentials-provider trait (its profile
# wrapper) and the profile-file set (aws-config's alias for it is deprecated).
# Both are the versions aws-config already resolves.
aws-credential-types = { version = "1.3.1", default-features = false }
aws-runtime = { version = "1.10.4", default-features = false }
# The default sleep and time source for the hand-built SdkConfig (no env/profile
# loading); the version and feature already in the tree.
aws-smithy-async = { version = "1.3.1", default-features = false, features = ["rt-tokio"] }
# Linux only: WebKitGTK refuses getUserMedia until the embedder enables media
# streams and answers the permission request (see src/linux_media.rs). Same
@@ -60,6 +86,9 @@ webkit2gtk = "2.0.2"
# their backoff schedule under a paused clock instead of in real seconds.
tokio = { version = "1", features = ["full", "test-util"] }
tempfile = "3"
# Only `aws::live_tests` (ignored): it creates a throwaway environment key to
# check that Secrets Manager and the permissions boundary see its tags.
aws-sdk-kms = { version = "1.131.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
[build-dependencies]
tauri-build = { version = "2", features = [] }
+8
View File
@@ -57,6 +57,14 @@
"allow-detect-aws-config",
"allow-inspect-ca-cert-path",
"allow-list-aws-profiles",
"allow-add-aws-profile-connection",
"allow-add-aws-keys-connection",
"allow-update-aws-connection",
"allow-remove-host-connection",
"allow-aws-connection-identity",
"allow-aws-connection-sso-login",
"allow-check-aws-permissions",
"allow-aws-policy-document",
"allow-detect-host-timezone",
"allow-export-settings",
"allow-preview-settings-import",
+1 -1
View File
@@ -1 +1 @@
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-aws-permissions":{"identifier":"allow-check-aws-permissions","description":"Enables the check_aws_permissions command without any pre-configured scope.","commands":{"allow":["check_aws_permissions"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","descriptionLine truncated
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
@@ -350,6 +350,18 @@
"const": "allow-acquire-claude-token",
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Enables the add_aws_keys_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-add-aws-keys-connection",
"markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope."
},
{
"description": "Enables the add_aws_profile_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-add-aws-profile-connection",
"markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace command without any pre-configured scope.",
"type": "string",
@@ -392,6 +404,24 @@
"const": "allow-apply-settings-import",
"markdownDescription": "Enables the apply_settings_import command without any pre-configured scope."
},
{
"description": "Enables the aws_connection_identity command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-connection-identity",
"markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope."
},
{
"description": "Enables the aws_connection_sso_login command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-connection-sso-login",
"markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope."
},
{
"description": "Enables the aws_policy_document command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-policy-document",
"markdownDescription": "Enables the aws_policy_document command without any pre-configured scope."
},
{
"description": "Enables the aws_sso_refresh command without any pre-configured scope.",
"type": "string",
@@ -428,6 +458,12 @@
"const": "allow-cancel-marketplace-gh-login",
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Enables the check_aws_permissions command without any pre-configured scope.",
"type": "string",
"const": "allow-check-aws-permissions",
"markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope."
},
{
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -866,6 +902,12 @@
"const": "allow-regenerate-web-terminal-token",
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Enables the remove_host_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-host-connection",
"markdownDescription": "Enables the remove_host_connection command without any pre-configured scope."
},
{
"description": "Enables the remove_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1082,6 +1124,12 @@
"const": "allow-uninstall-marketplace-item",
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
},
{
"description": "Enables the update_aws_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-update-aws-connection",
"markdownDescription": "Enables the update_aws_connection command without any pre-configured scope."
},
{
"description": "Enables the update_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1160,6 +1208,18 @@
"const": "deny-acquire-claude-token",
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Denies the add_aws_keys_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-add-aws-keys-connection",
"markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope."
},
{
"description": "Denies the add_aws_profile_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-add-aws-profile-connection",
"markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1202,6 +1262,24 @@
"const": "deny-apply-settings-import",
"markdownDescription": "Denies the apply_settings_import command without any pre-configured scope."
},
{
"description": "Denies the aws_connection_identity command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-connection-identity",
"markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope."
},
{
"description": "Denies the aws_connection_sso_login command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-connection-sso-login",
"markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope."
},
{
"description": "Denies the aws_policy_document command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-policy-document",
"markdownDescription": "Denies the aws_policy_document command without any pre-configured scope."
},
{
"description": "Denies the aws_sso_refresh command without any pre-configured scope.",
"type": "string",
@@ -1238,6 +1316,12 @@
"const": "deny-cancel-marketplace-gh-login",
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Denies the check_aws_permissions command without any pre-configured scope.",
"type": "string",
"const": "deny-check-aws-permissions",
"markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope."
},
{
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -1676,6 +1760,12 @@
"const": "deny-regenerate-web-terminal-token",
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Denies the remove_host_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-host-connection",
"markdownDescription": "Denies the remove_host_connection command without any pre-configured scope."
},
{
"description": "Denies the remove_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1892,6 +1982,12 @@
"const": "deny-uninstall-marketplace-item",
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
},
{
"description": "Denies the update_aws_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-update-aws-connection",
"markdownDescription": "Denies the update_aws_connection command without any pre-configured scope."
},
{
"description": "Denies the update_marketplace command without any pre-configured scope.",
"type": "string",
@@ -350,6 +350,18 @@
"const": "allow-acquire-claude-token",
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Enables the add_aws_keys_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-add-aws-keys-connection",
"markdownDescription": "Enables the add_aws_keys_connection command without any pre-configured scope."
},
{
"description": "Enables the add_aws_profile_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-add-aws-profile-connection",
"markdownDescription": "Enables the add_aws_profile_connection command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace command without any pre-configured scope.",
"type": "string",
@@ -392,6 +404,24 @@
"const": "allow-apply-settings-import",
"markdownDescription": "Enables the apply_settings_import command without any pre-configured scope."
},
{
"description": "Enables the aws_connection_identity command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-connection-identity",
"markdownDescription": "Enables the aws_connection_identity command without any pre-configured scope."
},
{
"description": "Enables the aws_connection_sso_login command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-connection-sso-login",
"markdownDescription": "Enables the aws_connection_sso_login command without any pre-configured scope."
},
{
"description": "Enables the aws_policy_document command without any pre-configured scope.",
"type": "string",
"const": "allow-aws-policy-document",
"markdownDescription": "Enables the aws_policy_document command without any pre-configured scope."
},
{
"description": "Enables the aws_sso_refresh command without any pre-configured scope.",
"type": "string",
@@ -428,6 +458,12 @@
"const": "allow-cancel-marketplace-gh-login",
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Enables the check_aws_permissions command without any pre-configured scope.",
"type": "string",
"const": "allow-check-aws-permissions",
"markdownDescription": "Enables the check_aws_permissions command without any pre-configured scope."
},
{
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -866,6 +902,12 @@
"const": "allow-regenerate-web-terminal-token",
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Enables the remove_host_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-host-connection",
"markdownDescription": "Enables the remove_host_connection command without any pre-configured scope."
},
{
"description": "Enables the remove_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1082,6 +1124,12 @@
"const": "allow-uninstall-marketplace-item",
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
},
{
"description": "Enables the update_aws_connection command without any pre-configured scope.",
"type": "string",
"const": "allow-update-aws-connection",
"markdownDescription": "Enables the update_aws_connection command without any pre-configured scope."
},
{
"description": "Enables the update_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1160,6 +1208,18 @@
"const": "deny-acquire-claude-token",
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Denies the add_aws_keys_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-add-aws-keys-connection",
"markdownDescription": "Denies the add_aws_keys_connection command without any pre-configured scope."
},
{
"description": "Denies the add_aws_profile_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-add-aws-profile-connection",
"markdownDescription": "Denies the add_aws_profile_connection command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1202,6 +1262,24 @@
"const": "deny-apply-settings-import",
"markdownDescription": "Denies the apply_settings_import command without any pre-configured scope."
},
{
"description": "Denies the aws_connection_identity command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-connection-identity",
"markdownDescription": "Denies the aws_connection_identity command without any pre-configured scope."
},
{
"description": "Denies the aws_connection_sso_login command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-connection-sso-login",
"markdownDescription": "Denies the aws_connection_sso_login command without any pre-configured scope."
},
{
"description": "Denies the aws_policy_document command without any pre-configured scope.",
"type": "string",
"const": "deny-aws-policy-document",
"markdownDescription": "Denies the aws_policy_document command without any pre-configured scope."
},
{
"description": "Denies the aws_sso_refresh command without any pre-configured scope.",
"type": "string",
@@ -1238,6 +1316,12 @@
"const": "deny-cancel-marketplace-gh-login",
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Denies the check_aws_permissions command without any pre-configured scope.",
"type": "string",
"const": "deny-check-aws-permissions",
"markdownDescription": "Denies the check_aws_permissions command without any pre-configured scope."
},
{
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -1676,6 +1760,12 @@
"const": "deny-regenerate-web-terminal-token",
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Denies the remove_host_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-host-connection",
"markdownDescription": "Denies the remove_host_connection command without any pre-configured scope."
},
{
"description": "Denies the remove_marketplace command without any pre-configured scope.",
"type": "string",
@@ -1892,6 +1982,12 @@
"const": "deny-uninstall-marketplace-item",
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
},
{
"description": "Denies the update_aws_connection command without any pre-configured scope.",
"type": "string",
"const": "deny-update-aws-connection",
"markdownDescription": "Denies the update_aws_connection command without any pre-configured scope."
},
{
"description": "Denies the update_marketplace command without any pre-configured scope.",
"type": "string",
+3 -3
View File
@@ -177,7 +177,7 @@ impl AuthBridgeManager {
pub async fn start(
&self,
project_id: String,
container_id: String,
container: ContainerTarget,
app: AppHandle,
store: Arc<ProjectsStore>,
) {
@@ -199,12 +199,12 @@ impl AuthBridgeManager {
log::info!(
"Auth bridge: starting for project {} (container {})",
project_id,
&container_id[..container_id.len().min(12)]
&container.id()[..container.id().len().min(12)]
);
let poller = tokio::spawn(poll_loop(
project_id.clone(),
ContainerTarget::docker(container_id),
container,
epoch,
app,
store,
+1 -23
View File
@@ -322,7 +322,7 @@ async fn tunnel_connection(
}
// The bytes already off the socket go back on the wire first, byte-exact.
tunnel_into_with_prelude(container, target, stream, port, head.into_buffer()).await
tunnel_connection_with_prelude(container, target, stream, port, head.into_buffer()).await
}
/// What the first bytes of an accepted connection turned out to be.
@@ -514,29 +514,7 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict {
/// head off the socket to check a token, and then has to put those same bytes
/// back on the wire. Passing them here keeps the byte stream exact, rather than
/// re-serialising a parsed request.
///
/// Takes a Docker container id because that is what the browser-view proxy
/// holds; [`tunnel_into_with_prelude`] is the same thing for a
/// [`ContainerTarget`] already in hand.
pub async fn tunnel_connection_with_prelude(
container_id: String,
target: String,
stream: TcpStream,
port: u16,
prelude: Vec<u8>,
) {
tunnel_into_with_prelude(
ContainerTarget::docker(container_id),
target,
stream,
port,
prelude,
)
.await
}
/// [`tunnel_connection_with_prelude`] for a [`ContainerTarget`].
pub async fn tunnel_into_with_prelude(
container: ContainerTarget,
target: String,
stream: TcpStream,
+300
View File
@@ -0,0 +1,300 @@
//! An `SdkConfig` built from a saved `HostConnection`, never from the
//! process's default credentials chain.
use aws_config::identity::IdentityCache;
use aws_config::profile::ProfileFileCredentialsProvider;
use aws_config::provider_config::ProviderConfig;
use aws_config::retry::RetryConfig;
use aws_config::{BehaviorVersion, Region, SdkConfig};
use aws_credential_types::provider::error::CredentialsError;
use aws_credential_types::provider::{future, ProvideCredentials, SharedCredentialsProvider};
use aws_credential_types::Credentials;
use aws_runtime::env_config::file::EnvConfigFiles;
use aws_smithy_async::rt::sleep::default_async_sleep;
use aws_smithy_async::time::SharedTimeSource;
use super::error::{AwsError, ProfileCredentialsError};
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
use crate::storage::secure;
/// The SDK config for `conn` in `region`. Credentials are resolved lazily,
/// so a missing profile or an expired SSO session surfaces on the first
/// call and is classified there (`classify_sdk_error`).
pub async fn sdk_config(conn: &HostConnection, region: &str) -> Result<SdkConfig, AwsError> {
sdk_config_with(conn, region, None).await
}
/// `sdk_config` with the profile files overridden (tests pass temp files so
/// neither the process environment nor `~/.aws` is consulted).
pub(crate) async fn sdk_config_with(
conn: &HostConnection,
region: &str,
profile_files: Option<EnvConfigFiles>,
) -> Result<SdkConfig, AwsError> {
let HostKind::Aws { auth, .. } = &conn.kind;
let region = Region::new(region.to_string());
let credentials = match auth {
AwsAuth::Profile { name } => {
// The profile is read for credentials only (static keys, SSO,
// assume-role, credential_process), never for endpoints or
// service settings.
let mut builder = ProfileFileCredentialsProvider::builder()
.configure(&ProviderConfig::default().with_region(Some(region.clone())))
.profile_name(name);
if let Some(files) = profile_files {
builder = builder.profile_files(files);
}
SharedCredentialsProvider::new(NamedProfileProvider {
profile: name.clone(),
inner: builder.build(),
})
}
AwsAuth::Keys { keyring_ref } => {
let keys = secure::get_aws_keys(keyring_ref)
.map_err(|message| AwsError::Other { message })?
.ok_or(AwsError::KeysMissing)?;
keys_credentials(&keys)
}
};
Ok(build_sdk_config(region, credentials))
}
/// The SDK config for keys not yet saved (the add-connection check runs
/// before anything touches the keychain).
pub fn sdk_config_for_keys(keys: &secure::AwsKeys, region: &str) -> SdkConfig {
build_sdk_config(Region::new(region.to_string()), keys_credentials(keys))
}
fn keys_credentials(keys: &secure::AwsKeys) -> SharedCredentialsProvider {
SharedCredentialsProvider::new(Credentials::new(
keys.access_key_id.as_str(),
keys.secret_access_key.as_str(),
keys.session_token.clone(),
None,
"triple-c-connection",
))
}
/// Built by hand rather than with `aws_config::defaults(..).load()`, which
/// would read `AWS_ENDPOINT_URL[_<SERVICE>]`, FIPS/dual-stack, retry and
/// other settings from the process environment and the default profile. A
/// stray endpoint there would send every connection's signed requests (and
/// the permission check) somewhere other than AWS. Connections talk to the
/// real regional endpoints; a profile's own `endpoint_url` is ignored too.
/// The HTTP client, timeouts and stalled-stream protection are each service
/// client's defaults (the `default-https-client` stack every AWS crate here
/// is built with).
fn build_sdk_config(region: Region, credentials: SharedCredentialsProvider) -> SdkConfig {
let mut builder = SdkConfig::builder()
.behavior_version(BehaviorVersion::latest())
.region(region)
.credentials_provider(credentials)
.identity_cache(IdentityCache::lazy().build())
.retry_config(RetryConfig::standard())
.time_source(SharedTimeSource::default());
if let Some(sleep) = default_async_sleep() {
builder = builder.sleep_impl(sleep);
}
builder.build()
}
/// Puts the profile's name into the source chain of every credentials error,
/// so a failure deep inside the SDK can be reported against the profile.
#[derive(Debug)]
struct NamedProfileProvider {
profile: String,
inner: ProfileFileCredentialsProvider,
}
impl NamedProfileProvider {
async fn load(&self) -> Result<Credentials, CredentialsError> {
self.inner.provide_credentials().await.map_err(|e| {
let not_loaded = matches!(e, CredentialsError::CredentialsNotLoaded(_));
let timed_out = matches!(e, CredentialsError::ProviderTimedOut(_));
if timed_out {
return e;
}
let invalid = matches!(e, CredentialsError::InvalidConfiguration(_));
let wrapped = ProfileCredentialsError {
profile: self.profile.clone(),
not_loaded,
source: e,
};
// Keep the error's kind; only its context changes.
if not_loaded {
CredentialsError::not_loaded(wrapped)
} else if invalid {
CredentialsError::invalid_configuration(wrapped)
} else {
CredentialsError::provider_error(wrapped)
}
})
}
}
impl ProvideCredentials for NamedProfileProvider {
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
where
Self: 'a,
{
future::ProvideCredentials::new(self.load())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aws::identity::caller_identity;
use crate::aws::AwsError;
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
use aws_runtime::env_config::file::{EnvConfigFileKind, EnvConfigFiles};
fn profile_conn(name: &str) -> HostConnection {
HostConnection {
id: "c1".into(),
name: "Test".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: name.into() },
default_region: "us-west-2".into(),
},
}
}
/// Empty config and credentials files, passed explicitly so neither the
/// process environment nor ~/.aws is consulted.
#[tokio::test]
async fn a_missing_profile_is_reported_by_name() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config");
let creds = dir.path().join("credentials");
std::fs::write(&config, "").unwrap();
std::fs::write(&creds, "").unwrap();
let files = EnvConfigFiles::builder()
.include_default_config_file(false)
.include_default_credentials_file(false)
.with_file(EnvConfigFileKind::Config, &config)
.with_file(EnvConfigFileKind::Credentials, &creds)
.build();
let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files))
.await
.unwrap();
let err = caller_identity(&cfg).await.unwrap_err();
assert_eq!(
err,
AwsError::ProfileNotFound {
profile: "nope".into()
}
);
}
/// A profile that the files do define but under another name is still
/// "not found" for the name the connection asked for.
#[tokio::test]
async fn a_profile_absent_from_populated_files_is_reported_by_name() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config");
std::fs::write(
&config,
"[profile other]\naws_access_key_id = AKIDTEST\naws_secret_access_key = s\n",
)
.unwrap();
let files = EnvConfigFiles::builder()
.include_default_config_file(false)
.include_default_credentials_file(false)
.with_file(EnvConfigFileKind::Config, &config)
.build();
let cfg = sdk_config_with(&profile_conn("nope"), "us-west-2", Some(files))
.await
.unwrap();
let err = caller_identity(&cfg).await.unwrap_err();
assert_eq!(
err,
AwsError::ProfileNotFound {
profile: "nope".into()
}
);
}
#[tokio::test]
async fn the_region_comes_from_the_argument() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config");
std::fs::write(&config, "[profile p]\nregion = eu-west-1\n").unwrap();
let files = EnvConfigFiles::builder()
.include_default_config_file(false)
.include_default_credentials_file(false)
.with_file(EnvConfigFileKind::Config, &config)
.build();
let cfg = sdk_config_with(&profile_conn("p"), "ap-south-1", Some(files))
.await
.unwrap();
assert_eq!(cfg.region().map(|r| r.as_ref()), Some("ap-south-1"));
}
/// Config files that try to redirect every call. A connection always
/// talks to the real regional AWS endpoints, so none of this may reach
/// the config: not the named profile's `endpoint_url`, not `[default]`'s,
/// not a per-service `services` section.
const REDIRECTING_CONFIG: &str = "[default]
endpoint_url = http://127.0.0.1:9
retry_mode = adaptive
max_attempts = 9
use_fips_endpoint = true
[profile p]
endpoint_url = http://127.0.0.1:9
retry_mode = adaptive
max_attempts = 9
use_dualstack_endpoint = true
services = redirect
aws_access_key_id = AKIDTEST
aws_secret_access_key = s
[services redirect]
sts =
endpoint_url = http://127.0.0.1:9
";
fn assert_isolated(cfg: &aws_config::SdkConfig) {
assert_eq!(cfg.endpoint_url(), None);
assert!(
cfg.service_config().is_none(),
"service config loaded from env/profile"
);
assert_eq!(cfg.use_fips(), None);
assert_eq!(cfg.use_dual_stack(), None);
let retry = cfg.retry_config().expect("retry config");
assert_eq!(retry.mode(), aws_config::retry::RetryMode::Standard);
assert_eq!(retry.max_attempts(), 3);
assert_eq!(cfg.region().map(|r| r.as_ref()), Some("us-west-2"));
}
#[tokio::test]
async fn a_profile_connection_ignores_endpoint_and_service_settings_in_the_profile() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config");
std::fs::write(&config, REDIRECTING_CONFIG).unwrap();
let files = EnvConfigFiles::builder()
.include_default_config_file(false)
.include_default_credentials_file(false)
.with_file(EnvConfigFileKind::Config, &config)
.build();
let cfg = sdk_config_with(&profile_conn("p"), "us-west-2", Some(files))
.await
.unwrap();
assert_isolated(&cfg);
}
/// The keys arm reads no files or environment at all: same isolation.
#[test]
fn a_keys_connection_config_is_isolated() {
let keys = crate::storage::secure::AwsKeys {
access_key_id: "AKIDTEST".into(),
secret_access_key: "s".into(),
session_token: None,
};
let cfg = build_sdk_config(Region::new("us-west-2"), keys_credentials(&keys));
assert_isolated(&cfg);
assert!(cfg.credentials_provider().is_some());
}
}
+147
View File
@@ -0,0 +1,147 @@
//! What can go wrong talking to AWS, in the terms the UI acts on.
use std::error::Error;
use aws_sdk_sts::error::{DisplayErrorContext, ErrorMetadata};
use serde::Serialize;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AwsError {
/// The UI offers "Sign in".
SsoExpired {
profile: String,
},
ProfileNotFound {
profile: String,
},
/// The keychain entry for a keys connection is gone.
KeysMissing,
InvalidCredentials {
message: String,
},
AccessDenied {
action: String,
message: String,
},
Other {
message: String,
},
}
impl std::fmt::Display for AwsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AwsError::SsoExpired { profile } if profile.is_empty() => {
write!(f, "The AWS SSO session has expired; sign in again")
}
AwsError::SsoExpired { profile } => write!(
f,
"The AWS SSO session for profile \"{profile}\" has expired; sign in again"
),
AwsError::ProfileNotFound { profile } => write!(
f,
"AWS profile \"{profile}\" was not found, or has no credentials, in the AWS config and credentials files"
),
AwsError::KeysMissing => write!(
f,
"The access keys for this connection are no longer in the system keychain; enter them again"
),
AwsError::InvalidCredentials { message } => {
write!(f, "AWS rejected the credentials: {message}")
}
AwsError::AccessDenied { action, message } => {
write!(f, "Access denied for {action}: {message}")
}
AwsError::Other { message } => f.write_str(message),
}
}
}
/// Carried in the source chain of every credentials error from a profile
/// connection, so classification knows which profile failed and whether it
/// produced no credentials at all (absent, or present without credentials).
#[derive(Debug)]
pub(crate) struct ProfileCredentialsError {
pub profile: String,
pub not_loaded: bool,
pub source: aws_credential_types::provider::error::CredentialsError,
}
impl std::fmt::Display for ProfileCredentialsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "credentials for profile `{}`", self.profile)
}
}
impl Error for ProfileCredentialsError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
const SSO_EXPIRED: &[&str] = &[
"The SSO session associated with this profile has expired",
"Token has expired",
"the SSO token has expired",
];
const SSO_REVOKED: &[&str] = &[
"UnauthorizedException",
"Session token not found or invalid",
];
const DENIED_CODES: &[&str] = &[
"AccessDeniedException",
"AccessDenied",
"UnauthorizedOperation",
];
const BAD_CREDENTIAL_CODES: &[&str] = &[
"ExpiredToken",
"InvalidClientTokenId",
"SignatureDoesNotMatch",
"UnrecognizedClientException",
];
/// Classify any SDK error (an `SdkError`, a credentials error, ...) from
/// calling `action` (e.g. `iam:GetRole`). Matching is on the full display
/// chain, which carries both provider messages and service error codes.
pub fn classify_sdk_error(action: &str, err: &(dyn Error + 'static)) -> AwsError {
let text = DisplayErrorContext(err).to_string();
let mut chain = std::iter::successors(Some(err), |e| (*e).source());
let profile = chain
.clone()
.find_map(|e| e.downcast_ref::<ProfileCredentialsError>());
// The service's own message, when one is in the chain.
let service_message = chain
.find_map(|e| e.downcast_ref::<ErrorMetadata>())
.and_then(|m| m.message())
.map(str::to_string);
let message = service_message.unwrap_or_else(|| text.clone());
if SSO_EXPIRED.iter().any(|s| text.contains(s)) {
return AwsError::SsoExpired {
profile: profile.map(|p| p.profile.clone()).unwrap_or_default(),
};
}
// SSO's GetRoleCredentials on a revoked or signed-out session. Only
// inside a profile's credentials: other services use this code too.
if let Some(p) = profile.filter(|_| SSO_REVOKED.iter().any(|s| text.contains(s))) {
return AwsError::SsoExpired {
profile: p.profile.clone(),
};
}
if let Some(p) = profile.filter(|p| p.not_loaded) {
return AwsError::ProfileNotFound {
profile: p.profile.clone(),
};
}
if DENIED_CODES.iter().any(|c| text.contains(c)) {
return AwsError::AccessDenied {
action: action.to_string(),
message,
};
}
if BAD_CREDENTIAL_CODES.iter().any(|c| text.contains(c)) {
return AwsError::InvalidCredentials { message };
}
AwsError::Other { message: text }
}
+413
View File
@@ -0,0 +1,413 @@
//! Who the connection's credentials are, and which IAM principal the
//! policy simulator should be asked about.
use aws_config::SdkConfig;
use serde::Serialize;
use super::error::{classify_sdk_error, AwsError};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct CallerIdentity {
pub account: String,
pub arn: String,
/// STS `UserId`: the principal's unique id (for a role session,
/// `<role id>:<session name>`).
pub user_id: String,
}
/// `sts:GetCallerIdentity`, which needs no IAM permission: it is the
/// connection test itself.
pub async fn caller_identity(cfg: &SdkConfig) -> Result<CallerIdentity, AwsError> {
const ACTION: &str = "sts:GetCallerIdentity";
let out = aws_sdk_sts::Client::new(cfg)
.get_caller_identity()
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
let field = |v: Option<&str>, name: &str| {
v.map(str::to_string).ok_or_else(|| AwsError::Other {
message: format!("{ACTION} returned no {name}"),
})
};
Ok(CallerIdentity {
account: field(out.account(), "Account")?,
arn: field(out.arn(), "Arn")?,
user_id: field(out.user_id(), "UserId")?,
})
}
/// The principal to simulate, as far as the caller's ARN tells us.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrincipalArn {
/// An IAM user or role ARN, usable as `PolicySourceArn` directly.
Exact(String),
/// A role session: the role's ARN carries a path the session ARN lacks,
/// so it has to be looked up (`resolve_principal`).
AssumedRole { account: String, role_name: String },
}
/// Map the caller's ARN to a simulatable principal. Root, federated users
/// and anything unrecognised cannot be simulated; the caller falls back to
/// probing instead.
pub fn principal_for_simulation(caller_arn: &str) -> Result<PrincipalArn, AwsError> {
let unsupported = || AwsError::Other {
message: format!("IAM policy simulation isn't possible for the principal {caller_arn}"),
};
// arn:<partition>:<service>:<region>:<account>:<resource>
let mut parts = caller_arn.splitn(6, ':');
let (Some("arn"), Some(partition), Some(service), Some(_region), Some(account), Some(resource)) = (
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
parts.next(),
) else {
return Err(unsupported());
};
if partition.is_empty() || account.is_empty() {
return Err(unsupported());
}
match service {
"iam" if resource.starts_with("user/") || resource.starts_with("role/") => {
Ok(PrincipalArn::Exact(caller_arn.to_string()))
}
"sts" => match resource
.strip_prefix("assumed-role/")
.and_then(|r| r.split_once('/'))
{
Some((role_name, _session)) if !role_name.is_empty() => Ok(PrincipalArn::AssumedRole {
account: account.to_string(),
role_name: role_name.to_string(),
}),
_ => Err(unsupported()),
},
_ => Err(unsupported()),
}
}
/// The full ARN to simulate. An assumed role needs `iam:GetRole` to recover
/// the role's path (SSO roles live under `/aws-reserved/...`).
pub async fn resolve_principal(
iam: &aws_sdk_iam::Client,
p: PrincipalArn,
) -> Result<String, AwsError> {
const ACTION: &str = "iam:GetRole";
let (account, role_name) = match p {
PrincipalArn::Exact(arn) => return Ok(arn),
PrincipalArn::AssumedRole { account, role_name } => (account, role_name),
};
let out = iam
.get_role()
.role_name(&role_name)
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
let arn = out
.role()
.map(|r| r.arn().to_string())
.filter(|a| !a.is_empty())
.ok_or_else(|| AwsError::Other {
message: format!("{ACTION} returned no role for {role_name}"),
})?;
// The role must be the session's own: same account.
if arn.split(':').nth(4) != Some(account.as_str()) {
return Err(AwsError::Other {
message: format!("{ACTION} for {role_name} returned {arn}, outside account {account}"),
});
}
Ok(arn)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aws::error::classify_sdk_error;
use crate::aws::AwsError;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn iam_user_arn_is_used_as_is() {
let arn = "arn:aws:iam::111122223333:user/alice";
assert_eq!(
principal_for_simulation(arn).unwrap(),
PrincipalArn::Exact(arn.into())
);
}
#[test]
fn iam_role_arn_is_used_as_is() {
let arn = "arn:aws:iam::111122223333:role/ops/deployer";
assert_eq!(
principal_for_simulation(arn).unwrap(),
PrincipalArn::Exact(arn.into())
);
}
#[test]
fn assumed_role_arn_maps_to_role_name() {
let arn = "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Admin_abc/alice@x.com";
assert_eq!(
principal_for_simulation(arn).unwrap(),
PrincipalArn::AssumedRole {
account: "111122223333".into(),
role_name: "AWSReservedSSO_Admin_abc".into(),
}
);
}
#[test]
fn root_and_federated_arns_are_rejected_for_simulation() {
for arn in [
"arn:aws:iam::1:root",
"arn:aws:sts::1:federated-user/bob",
"not-an-arn",
] {
match principal_for_simulation(arn) {
Err(AwsError::Other { message }) => {
assert!(
message.contains("simulation isn't possible"),
"{arn}: {message}"
)
}
other => panic!("{arn}: expected Other, got {other:?}"),
}
}
}
/// A one-shot-per-connection HTTP server that answers every request with
/// `status` and `body`, and reports each request it saw.
async fn fake_endpoint(
status: &'static str,
body: &'static str,
) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let tx = tx.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 64 * 1024];
let mut req = Vec::new();
loop {
let n = match sock.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
req.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&req).into_owned();
let Some(head_end) = text.find("\r\n\r\n") else {
continue;
};
let len = text[..head_end]
.lines()
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
if req.len() < head_end + 4 + len {
continue;
}
let _ = tx.send(text);
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/xml\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
req.clear();
}
});
}
});
(format!("http://127.0.0.1:{port}"), rx)
}
fn iam_client(endpoint: String) -> aws_sdk_iam::Client {
let conf = aws_sdk_iam::Config::builder()
.behavior_version(aws_sdk_iam::config::BehaviorVersion::latest())
.region(aws_sdk_iam::config::Region::new("us-east-1"))
.credentials_provider(aws_sdk_iam::config::Credentials::new(
"AKIDTEST", "secret", None, None, "test",
))
.endpoint_url(endpoint)
.build();
aws_sdk_iam::Client::from_conf(conf)
}
const ROLE_ARN: &str = "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc";
#[tokio::test]
async fn assumed_role_arn_maps_to_role_arn_with_path() {
const BODY: &str = r#"<GetRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<GetRoleResult>
<Role>
<Path>/aws-reserved/sso.amazonaws.com/us-west-2/</Path>
<RoleName>AWSReservedSSO_Admin_abc</RoleName>
<RoleId>AROAEXAMPLEID</RoleId>
<Arn>arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Admin_abc</Arn>
<CreateDate>2024-01-01T00:00:00Z</CreateDate>
</Role>
</GetRoleResult>
<ResponseMetadata><RequestId>req-1</RequestId></ResponseMetadata>
</GetRoleResponse>"#;
let (endpoint, mut seen) = fake_endpoint("200 OK", BODY).await;
let p = PrincipalArn::AssumedRole {
account: "111122223333".into(),
role_name: "AWSReservedSSO_Admin_abc".into(),
};
let arn = resolve_principal(&iam_client(endpoint), p).await.unwrap();
assert_eq!(arn, ROLE_ARN);
let req = seen.recv().await.unwrap();
assert!(req.contains("Action=GetRole"), "{req}");
assert!(req.contains("RoleName=AWSReservedSSO_Admin_abc"), "{req}");
}
#[tokio::test]
async fn an_exact_principal_needs_no_lookup() {
// No endpoint answers: any request would fail the test.
let client = iam_client("http://127.0.0.1:9".into());
let arn = "arn:aws:iam::111122223333:user/alice";
let got = resolve_principal(&client, PrincipalArn::Exact(arn.into()))
.await
.unwrap();
assert_eq!(got, arn);
}
#[tokio::test]
async fn a_denied_get_role_is_access_denied_for_get_role() {
const BODY: &str = r#"<ErrorResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/">
<Error>
<Type>Sender</Type>
<Code>AccessDenied</Code>
<Message>User: arn:aws:sts::111122223333:assumed-role/R/alice is not authorized to perform: iam:GetRole</Message>
</Error>
<RequestId>req-2</RequestId>
</ErrorResponse>"#;
let (endpoint, _seen) = fake_endpoint("403 Forbidden", BODY).await;
let p = PrincipalArn::AssumedRole {
account: "111122223333".into(),
role_name: "R".into(),
};
match resolve_principal(&iam_client(endpoint), p).await {
Err(AwsError::AccessDenied { action, message }) => {
assert_eq!(action, "iam:GetRole");
assert!(
message.contains("is not authorized to perform: iam:GetRole"),
"{message}"
);
}
other => panic!("expected AccessDenied, got {other:?}"),
}
}
#[derive(Debug)]
struct Msg(&'static str, Option<Box<dyn std::error::Error + 'static>>);
impl std::fmt::Display for Msg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Msg {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.1.as_deref()
}
}
#[test]
fn an_expired_sso_token_is_reported_as_sso_expired() {
for leaf in [
"The SSO session associated with this profile has expired or is otherwise invalid",
"Token has expired and refresh failed",
] {
// Deep in the chain, as the SDK reports it.
let err = Msg("dispatch failure", Some(Box::new(Msg(leaf, None))));
assert!(
matches!(
classify_sdk_error("sts:GetCallerIdentity", &err),
AwsError::SsoExpired { .. }
),
"{leaf}"
);
}
}
/// SSO's GetRoleCredentials on a revoked or signed-out session. Inside a
/// profile connection's credentials that means "sign in again".
#[test]
fn a_revoked_sso_session_is_reported_as_sso_expired() {
use crate::aws::error::ProfileCredentialsError;
use aws_credential_types::provider::error::CredentialsError;
let leaf = CredentialsError::provider_error(
"service error: unhandled error (UnauthorizedException): Session token not found or invalid",
);
let err = CredentialsError::provider_error(ProfileCredentialsError {
profile: "dev".into(),
not_loaded: false,
source: leaf,
});
assert_eq!(
classify_sdk_error(
"sts:GetCallerIdentity",
&Msg("dispatch failure", Some(Box::new(err)))
),
AwsError::SsoExpired {
profile: "dev".into()
}
);
}
/// The same error code from some other service, outside any profile's
/// credentials, is not an SSO problem.
#[test]
fn an_unauthorized_exception_outside_profile_credentials_is_not_sso() {
let err = Msg(
"service error: unhandled error (UnauthorizedException): Session token not found or invalid",
None,
);
assert!(!matches!(
classify_sdk_error("ecs:ListClusters", &err),
AwsError::SsoExpired { .. }
));
}
#[test]
fn service_error_codes_are_classified() {
let denied = Msg(
"service error: unhandled error (AccessDeniedException)",
None,
);
assert!(matches!(
classify_sdk_error("ecs:ListClusters", &denied),
AwsError::AccessDenied { action, .. } if action == "ecs:ListClusters"
));
for code in [
"ExpiredToken",
"InvalidClientTokenId",
"SignatureDoesNotMatch",
] {
let text: &'static str =
Box::leak(format!("service error: unhandled error ({code})").into_boxed_str());
assert!(
matches!(
classify_sdk_error("sts:GetCallerIdentity", &Msg(text, None)),
AwsError::InvalidCredentials { .. }
),
"{code}"
);
}
assert!(matches!(
classify_sdk_error(
"sts:GetCallerIdentity",
&Msg("io error: connection refused", None)
),
AwsError::Other { .. }
));
}
}
+958
View File
@@ -0,0 +1,958 @@
//! The tier policies against real IAM: `check_permissions` run against
//! throwaway roles that hold the shipped policies, and the few statements
//! whose behaviour only a real request shows (Secrets Manager's KMS calls,
//! the permissions boundary, the admin tier's role and key fences).
//!
//! Ignored; needs credentials for a **disposable** account that may create
//! and delete IAM roles and policies, KMS keys and Secrets Manager secrets:
//!
//! - `TRIPLEC_AWS_E2E_PROFILE` — a profile in the AWS config/credentials
//! files (read the way a saved connection reads it, so `AWS_*` key
//! variables are not used; `AWS_SHARED_CREDENTIALS_FILE` and
//! `AWS_CONFIG_FILE` still choose the files);
//! - `TRIPLEC_AWS_E2E_REGION` — e.g. `us-west-2`.
//!
//! ```sh
//! cd app/src-tauri
//! TRIPLEC_AWS_E2E_PROFILE=… TRIPLEC_AWS_E2E_REGION=us-west-2 \
//! cargo test aws::live_tests -- --ignored --test-threads=1
//! ```
//!
//! Every resource is named `triple-c-e2e-<id>…` (secrets
//! `triple-c/triple-c-e2e-<id>/…`, because the policies only cover
//! `triple-c/*`), tagged `triple-c-test=phase3`, and deleted when the test
//! ends, pass or fail. KMS keys can only be scheduled for deletion (7 days).
//! IAM is free; a run costs a few cents at most (secrets for minutes, keys
//! pending deletion).
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use aws_config::SdkConfig;
use futures_util::FutureExt;
use super::error::{classify_sdk_error, AwsError};
use super::permissions::{
check_permissions, simulation_request, Method, PermissionApi, PermissionReport,
SdkPermissionApi, Summary, Verdict,
};
use super::policies::{
checks, permissions_boundary_json, policy_json, Placeholders, Tier, PERMISSIONS_BOUNDARY_NAME,
};
use super::{caller_identity, sdk_config, sdk_config_for_keys, CallerIdentity};
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
use crate::storage::secure::AwsKeys;
const TEST_TAG: (&str, &str) = ("triple-c-test", "phase3");
const PATH: &str = "/triple-c-e2e/";
const SESSION: &str = "triple-c-e2e";
fn var(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)"))
}
/// What one test created, for the guard to delete.
#[derive(Default)]
struct Created {
roles: Vec<String>,
policies: Vec<String>,
secrets: Vec<String>,
keys: Vec<String>,
}
struct E2e {
cfg: SdkConfig,
iam: aws_sdk_iam::Client,
sts: aws_sdk_sts::Client,
secrets: aws_sdk_secretsmanager::Client,
kms: aws_sdk_kms::Client,
region: String,
account: String,
/// `triple-c-e2e-<random>`: the prefix of everything this test creates.
prefix: String,
created: Mutex<Created>,
}
/// A role this test created.
struct Role {
name: String,
arn: String,
/// `RoleId`: an assumed session's `aws:userid` is `<id>:<session>`.
id: String,
}
impl Role {
fn session_user_id(&self) -> String {
format!("{}:{SESSION}", self.id)
}
/// The identity `check_permissions` sees for the role itself (not a
/// session of it): an IAM role ARN, simulated as-is.
fn identity(&self, account: &str) -> CallerIdentity {
CallerIdentity {
account: account.to_string(),
arn: self.arn.clone(),
user_id: self.session_user_id(),
}
}
}
/// Runs `body` with a fresh fixture and deletes what it created afterwards,
/// whether or not `body` panicked.
async fn e2e<F, Fut>(body: F)
where
F: FnOnce(Arc<E2e>) -> Fut,
Fut: Future<Output = ()>,
{
let region = var("TRIPLEC_AWS_E2E_REGION");
let conn = HostConnection {
id: "e2e".into(),
name: "e2e".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile {
name: var("TRIPLEC_AWS_E2E_PROFILE"),
},
default_region: region.clone(),
},
};
let cfg = sdk_config(&conn, &region).await.expect("sdk config");
let me = caller_identity(&cfg)
.await
.expect("the e2e profile's identity");
let e = Arc::new(E2e {
iam: aws_sdk_iam::Client::new(&cfg),
sts: aws_sdk_sts::Client::new(&cfg),
secrets: aws_sdk_secretsmanager::Client::new(&cfg),
kms: aws_sdk_kms::Client::new(&cfg),
cfg,
region,
account: me.account,
prefix: format!(
"triple-c-e2e-{}",
&uuid::Uuid::new_v4().simple().to_string()[..12]
),
created: Mutex::default(),
});
let result = AssertUnwindSafe(body(e.clone())).catch_unwind().await;
e.cleanup().await;
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
}
fn iam_tag(k: &str, v: &str) -> aws_sdk_iam::types::Tag {
aws_sdk_iam::types::Tag::builder()
.key(k)
.value(v)
.build()
.unwrap()
}
fn sm_tag(k: &str, v: &str) -> aws_sdk_secretsmanager::types::Tag {
aws_sdk_secretsmanager::types::Tag::builder()
.key(k)
.value(v)
.build()
}
/// `Err(AccessDenied)` for a denied call; panics on anything else.
fn denied<T: std::fmt::Debug, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) {
match r {
Err(e) => match classify_sdk_error(what, &e) {
AwsError::AccessDenied { .. } => {}
other => panic!("{what}: expected AccessDenied, got {other}"),
},
Ok(v) => panic!("{what}: expected AccessDenied, but it succeeded: {v:?}"),
}
}
fn ok<T, E: std::error::Error + 'static>(what: &str, r: Result<T, E>) -> T {
r.unwrap_or_else(|e| panic!("{what}: {}", classify_sdk_error(what, &e)))
}
impl E2e {
fn name(&self, suffix: &str) -> String {
format!("{}-{suffix}", self.prefix)
}
fn secret_name(&self, suffix: &str) -> String {
format!("triple-c/{}/{suffix}", self.prefix)
}
/// A role assumable by this account, under `/triple-c-e2e/` (so its ARN
/// has a path, as an SSO role's does), optionally bounded.
async fn role(&self, suffix: &str, boundary: Option<&str>, tags: &[(&str, &str)]) -> Role {
let name = self.name(suffix);
let trust = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
self.account
);
let mut req = self
.iam
.create_role()
.role_name(&name)
.path(PATH)
.assume_role_policy_document(trust)
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
.set_permissions_boundary(boundary.map(str::to_string));
for (k, v) in tags {
req = req.tags(iam_tag(k, v));
}
let out = ok("iam:CreateRole", req.send().await);
self.created.lock().unwrap().roles.push(name.clone());
let r = out.role().unwrap();
Role {
name,
arn: r.arn().to_string(),
id: r.role_id().to_string(),
}
}
async fn tag_role(&self, role: &Role, k: &str, v: &str) {
ok(
"iam:TagRole",
self.iam
.tag_role()
.role_name(&role.name)
.tags(iam_tag(k, v))
.send()
.await,
);
}
/// A customer managed policy (the tier policies are too big to hold
/// both inline on one role).
async fn managed_policy(&self, suffix: &str, document: &str) -> String {
let out = ok(
"iam:CreatePolicy",
self.iam
.create_policy()
.policy_name(self.name(suffix))
.policy_document(document)
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
.send()
.await,
);
let arn = out.policy().unwrap().arn().unwrap().to_string();
self.created.lock().unwrap().policies.push(arn.clone());
arn
}
async fn attach(&self, role: &Role, policy_arn: &str) {
ok(
"iam:AttachRolePolicy",
self.iam
.attach_role_policy()
.role_name(&role.name)
.policy_arn(policy_arn)
.send()
.await,
);
}
async fn put_inline(&self, role: &Role, document: &str) {
ok(
"iam:PutRolePolicy",
self.iam
.put_role_policy()
.role_name(&role.name)
.policy_name("under-test")
.policy_document(document)
.send()
.await,
);
}
/// A role holding the given tier policies, tagged as owned by its own
/// sessions (the strict-tenancy task role shape `iam:PassRole` checks).
async fn tier_role(&self, suffix: &str, tiers: &[Tier]) -> Role {
let role = self.role(suffix, None, &[]).await;
self.tag_role(&role, "triple-c:owner", &role.session_user_id())
.await;
for &tier in tiers {
let name = match tier {
Tier::WorkspaceUser => "workspace",
Tier::EnvironmentAdmin => "admin",
};
let arn = self
.managed_policy(&format!("{suffix}-{name}"), policy_json(tier))
.await;
self.attach(&role, &arn).await;
}
role
}
/// An SDK config for a session of `role`, once IAM lets it be assumed
/// and its policies have propagated.
async fn assume(&self, role: &Role) -> SdkConfig {
let mut last = None;
for _ in 0..20 {
match self
.sts
.assume_role()
.role_arn(&role.arn)
.role_session_name(SESSION)
.send()
.await
{
Ok(out) => {
let c = out.credentials().unwrap();
let keys = AwsKeys {
access_key_id: c.access_key_id().to_string(),
secret_access_key: c.secret_access_key().to_string(),
session_token: Some(c.session_token().to_string()),
};
// New sessions see a just-attached policy after a few
// seconds; denials in that window would be noise.
tokio::time::sleep(Duration::from_secs(10)).await;
return sdk_config_for_keys(&keys, &self.region);
}
Err(e) => {
last = Some(classify_sdk_error("sts:AssumeRole", &e).to_string());
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
}
panic!("could not assume {}: {last:?}", role.arn);
}
/// `check_permissions` as the e2e profile, simulating `identity`'s
/// principal. Retried once if a check failed (IAM is eventually
/// consistent for a role created a moment ago).
async fn check_as(&self, identity: &CallerIdentity) -> PermissionReport {
let api = SdkPermissionApi::new(&self.cfg);
for attempt in 0..3 {
let report = check_permissions(&api, identity, &self.region)
.await
.expect("check_permissions");
let failed = report
.tiers
.iter()
.flat_map(|t| &t.results)
.any(|r| r.note.is_some());
if !failed || attempt == 2 {
return report;
}
tokio::time::sleep(Duration::from_secs(10)).await;
}
unreachable!()
}
async fn environment_key(&self, kms: &aws_sdk_kms::Client, suffix: &str) -> String {
let tag = |k: &str, v: &str| {
aws_sdk_kms::types::Tag::builder()
.tag_key(k)
.tag_value(v)
.build()
.unwrap()
};
let r = kms
.create_key()
.description(self.name(suffix))
.tags(tag("triple-c:component", "environment"))
.tags(tag(TEST_TAG.0, TEST_TAG.1))
.send()
.await;
let arn = ok("kms:CreateKey", r)
.key_metadata()
.unwrap()
.arn()
.unwrap()
.to_string();
self.created.lock().unwrap().keys.push(arn.clone());
arn
}
fn record_secret(&self, arn: &str) {
self.created.lock().unwrap().secrets.push(arn.to_string());
}
/// An admin-created secret encrypted with `key`.
async fn admin_secret(&self, suffix: &str, key: &str, tag: Option<(&str, &str)>) -> String {
let mut req = self
.secrets
.create_secret()
.name(self.secret_name(suffix))
.kms_key_id(key)
.secret_string("e2e")
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1));
if let Some((k, v)) = tag {
req = req.tags(sm_tag(k, v));
}
let arn = ok("secretsmanager:CreateSecret", req.send().await)
.arn()
.unwrap()
.to_string();
self.record_secret(&arn);
arn
}
/// Deletes everything recorded, logging (not failing on) errors so one
/// failure doesn't strand the rest.
async fn cleanup(&self) {
let created = std::mem::take(&mut *self.created.lock().unwrap());
let warn = |what: &str, e: &dyn std::fmt::Display| eprintln!("cleanup: {what}: {e}");
for arn in &created.secrets {
if let Err(e) = self
.secrets
.delete_secret()
.secret_id(arn)
.force_delete_without_recovery(true)
.send()
.await
{
let e = classify_sdk_error("secretsmanager:DeleteSecret", &e);
if !e.to_string().contains("ResourceNotFound") {
warn(arn, &e);
}
}
}
for arn in &created.keys {
if let Err(e) = self
.kms
.schedule_key_deletion()
.key_id(arn)
.pending_window_in_days(7)
.send()
.await
{
let e = classify_sdk_error("kms:ScheduleKeyDeletion", &e);
if !e.to_string().contains("pending") {
warn(arn, &e);
}
}
}
for name in &created.roles {
if let Ok(out) = self.iam.list_role_policies().role_name(name).send().await {
for p in out.policy_names() {
let _ = self
.iam
.delete_role_policy()
.role_name(name)
.policy_name(p)
.send()
.await;
}
}
if let Ok(out) = self
.iam
.list_attached_role_policies()
.role_name(name)
.send()
.await
{
for p in out.attached_policies() {
let _ = self
.iam
.detach_role_policy()
.role_name(name)
.policy_arn(p.policy_arn().unwrap_or_default())
.send()
.await;
}
}
if let Err(e) = self.iam.delete_role().role_name(name).send().await {
warn(name, &classify_sdk_error("iam:DeleteRole", &e));
}
}
for arn in &created.policies {
if let Err(e) = self.iam.delete_policy().policy_arn(arn).send().await {
warn(arn, &classify_sdk_error("iam:DeletePolicy", &e));
}
}
}
}
fn verdicts(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
report
.tiers
.iter()
.find(|t| t.tier == tier)
.unwrap()
.results
.iter()
.map(|r| (r.action.clone(), r.verdict.clone()))
.collect()
}
fn not_allowed(report: &PermissionReport, tier: Tier) -> Vec<(String, Verdict)> {
verdicts(report, tier)
.into_iter()
.filter(|(_, v)| *v != Verdict::Allowed)
.collect()
}
#[tokio::test]
#[ignore]
async fn a_role_with_the_workspace_policy_checks_as_workspace_user() {
e2e(|e| async move {
let role = e.tier_role("ws", &[Tier::WorkspaceUser]).await;
let report = e.check_as(&role.identity(&e.account)).await;
assert_eq!(report.method, Method::Simulated);
assert_eq!(
not_allowed(&report, Tier::WorkspaceUser),
[],
"every workspace check must be allowed"
);
assert_eq!(report.summary, Summary::WorkspaceUser);
assert!(
verdicts(&report, Tier::EnvironmentAdmin)
.iter()
.any(|(_, v)| *v == Verdict::Denied),
"the workspace policy alone grants admin actions"
);
})
.await;
}
#[tokio::test]
#[ignore]
async fn a_role_with_both_policies_checks_as_environment_admin() {
e2e(|e| async move {
let role = e
.tier_role("admin", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
.await;
let report = e.check_as(&role.identity(&e.account)).await;
assert_eq!(report.method, Method::Simulated);
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
assert_eq!(not_allowed(&report, Tier::EnvironmentAdmin), []);
assert_eq!(report.summary, Summary::EnvironmentAdmin);
})
.await;
}
#[tokio::test]
#[ignore]
async fn a_role_with_no_policy_checks_as_insufficient_and_lists_every_action() {
e2e(|e| async move {
let role = e.role("none", None, &[]).await;
let report = e.check_as(&role.identity(&e.account)).await;
assert_eq!(report.method, Method::Simulated);
assert_eq!(report.summary, Summary::Insufficient);
for tier in [Tier::WorkspaceUser, Tier::EnvironmentAdmin] {
let rows = verdicts(&report, tier);
for c in checks(tier) {
assert!(
rows.iter().any(|(a, _)| a == c.action),
"{tier:?}: no row for {}",
c.action
);
}
for (action, v) in rows {
assert_eq!(v, Verdict::Denied, "{tier:?} {action}");
}
}
})
.await;
}
/// The workspace policy without `InspectOwnPrincipal`, assumed and checked
/// *as* itself: `iam:GetRole` is denied, so the check probes.
#[tokio::test]
#[ignore]
async fn a_role_without_simulate_permission_falls_back_to_probes() {
e2e(|e| async move {
let mut doc: serde_json::Value =
serde_json::from_str(policy_json(Tier::WorkspaceUser)).unwrap();
doc["Statement"]
.as_array_mut()
.unwrap()
.retain(|s| s["Sid"] != "InspectOwnPrincipal");
let role = e.role("nosim", None, &[]).await;
let arn = e.managed_policy("nosim", &doc.to_string()).await;
e.attach(&role, &arn).await;
let cfg = e.assume(&role).await;
let me = caller_identity(&cfg).await.expect("identity");
assert_eq!(me.user_id, role.session_user_id());
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
.await
.expect("check_permissions");
let Method::Probed { reason } = &report.method else {
panic!("expected probes, got {:?}", report.method);
};
assert!(reason.contains("iam:GetRole"), "{reason}");
let rows = verdicts(&report, Tier::WorkspaceUser);
let verdict = |a: &str| rows.iter().find(|(x, _)| x == a).unwrap().1.clone();
for probed in [
"ecs:ListClusters",
"ssm:GetParametersByPath",
"cloudformation:DescribeStacks",
"secretsmanager:ListSecrets",
] {
assert_eq!(verdict(probed), Verdict::Allowed, "{probed}");
}
assert_eq!(verdict("iam:GetRole"), Verdict::Denied);
assert_eq!(verdict("ecs:RunTask"), Verdict::Unverified);
// A probed run never claims the admin tier, and the denied
// own-principal check keeps it from claiming the workspace tier.
assert_eq!(report.summary, Summary::Insufficient);
})
.await;
}
/// The whole workspace policy, assumed: the real `iam:GetRole` on its own
/// role (whose ARN has a path) and `SimulatePrincipalPolicy` of itself.
#[tokio::test]
#[ignore]
async fn an_assumed_workspace_role_checks_itself_by_simulation() {
e2e(|e| async move {
let role = e.tier_role("self", &[Tier::WorkspaceUser]).await;
let cfg = e.assume(&role).await;
let me = caller_identity(&cfg).await.expect("identity");
assert!(me.arn.contains(":assumed-role/"), "{}", me.arn);
let report = check_permissions(&SdkPermissionApi::new(&cfg), &me, &e.region)
.await
.expect("check_permissions");
assert_eq!(report.method, Method::Simulated);
assert_eq!(not_allowed(&report, Tier::WorkspaceUser), []);
assert_eq!(report.summary, Summary::WorkspaceUser);
})
.await;
}
/// The e2e profile itself (an account administrator): every check allowed.
/// For an SSO profile this resolves the `/aws-reserved/sso.amazonaws.com/`
/// role path through `iam:GetRole`.
#[tokio::test]
#[ignore]
async fn the_e2e_profile_checks_as_environment_admin() {
e2e(|e| async move {
let me = caller_identity(&e.cfg).await.expect("identity");
let report = e.check_as(&me).await;
assert_eq!(report.method, Method::Simulated);
let failed: Vec<_> = report
.tiers
.iter()
.flat_map(|t| &t.results)
.filter(|r| r.verdict != Verdict::Allowed)
.collect();
assert!(failed.is_empty(), "{failed:#?}");
assert_eq!(report.summary, Summary::EnvironmentAdmin);
})
.await;
}
/// The spec's most important rule: no shell, stop or session in someone
/// else's task.
#[tokio::test]
#[ignore]
async fn the_owner_condition_denies_another_owners_task() {
e2e(|e| async move {
let role = e.tier_role("owner", &[Tier::WorkspaceUser]).await;
let me = role.session_user_id();
let api = SdkPermissionApi::new(&e.cfg);
let p = Placeholders {
region: &e.region,
account: &e.account,
principal: &role.arn,
};
let task_checks = checks(Tier::WorkspaceUser).iter().filter(|c| {
["ecs:StopTask", "ecs:ExecuteCommand", "ssm:StartSession"].contains(&c.action)
&& c.resource.contains(":task/")
});
let mut n = 0;
for c in task_checks {
n += 1;
let mine = simulation_request(c, &p, &me);
assert!(
api.simulate(&mine).await.unwrap(),
"{} on my own task",
c.action
);
let mut theirs = mine.clone();
for entry in &mut theirs.context {
if entry.key == "aws:ResourceTag/triple-c:owner" {
entry.values = vec!["AROAEXAMPLEOTHER:bob@example.com".into()];
}
}
assert!(
!api.simulate(&theirs).await.unwrap(),
"{} allowed on another owner's task",
c.action
);
theirs
.context
.retain(|x| x.key != "aws:ResourceTag/triple-c:owner");
assert!(
!api.simulate(&theirs).await.unwrap(),
"{} allowed on an untagged task",
c.action
);
}
assert_eq!(n, 3);
})
.await;
}
/// SimulatePrincipalPolicy *does* apply the principal's own permissions
/// boundary (it does not apply SCPs or session policies).
#[tokio::test]
#[ignore]
async fn the_simulator_applies_the_principals_permissions_boundary() {
e2e(|e| async move {
let boundary = e.managed_policy("boundary", permissions_boundary_json()).await;
let role = e.role("bounded", Some(&boundary), &[]).await;
e.put_inline(
&role,
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"secretsmanager:GetSecretValue","Resource":"*"}]}"#,
)
.await;
let sim = |name: &str| {
e.iam
.simulate_principal_policy()
.policy_source_arn(&role.arn)
.action_names("secretsmanager:GetSecretValue")
.resource_arns(format!(
"arn:aws:secretsmanager:{}:{}:secret:{name}-AbCdEf",
e.region, e.account
))
.send()
};
let decision = |out: aws_sdk_iam::operation::simulate_principal_policy::SimulatePrincipalPolicyOutput| {
out.evaluation_results()[0].eval_decision().as_str().to_string()
};
assert_eq!(decision(ok("simulate", sim("triple-c/x").await)), "allowed");
assert_eq!(decision(ok("simulate", sim("other/x").await)), "implicitDeny");
})
.await;
}
/// The secret statements and the KMS rules behind them, with real calls:
/// tag-on-create, the environment key by ARN (and `kms:Decrypt` with it),
/// the explicit Deny on the AWS-managed key, owner and component fences,
/// and the permissions boundary reading `aws:ResourceTag` of the key.
#[tokio::test]
#[ignore]
async fn secrets_use_the_environment_key_and_only_their_owner_can_change_them() {
e2e(|e| async move {
let key = e.environment_key(&e.kms, "env-key").await;
let aws_managed = ok(
"kms:DescribeKey",
e.kms.describe_key().key_id("alias/aws/secretsmanager").send().await,
)
.key_metadata()
.unwrap()
.arn()
.unwrap()
.to_string();
let user = e.tier_role("secrets-user", &[Tier::WorkspaceUser]).await;
let boundary = e.managed_policy("secrets-boundary", permissions_boundary_json()).await;
let task = e.role("secrets-task", Some(&boundary), &[]).await;
e.put_inline(
&task,
r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:GetSecretValue","kms:Decrypt"],"Resource":"*"}]}"#,
)
.await;
let environment = e
.admin_secret("environment", &key, Some(("triple-c:component", "environment")))
.await;
let someone_elses = e
.admin_secret("someone-else", &key, Some(("triple-c:owner", "AROAEXAMPLEOTHER:bob")))
.await;
let as_user = aws_sdk_secretsmanager::Client::new(&e.assume(&user).await);
let me = user.session_user_id();
let create = |suffix: &str, key: Option<&str>| {
as_user
.create_secret()
.name(e.secret_name(suffix))
.set_kms_key_id(key.map(str::to_string))
.secret_string("v1")
.tags(sm_tag("triple-c:owner", &me))
.send()
};
// Own secret on the environment key, named by ARN.
let own = ok("create own secret", create("own", Some(&key)).await)
.arn()
.unwrap()
.to_string();
e.record_secret(&own);
ok(
"tag for cleanup",
e.secrets
.tag_resource()
.secret_id(&own)
.tags(sm_tag(TEST_TAG.0, TEST_TAG.1))
.send()
.await,
);
ok(
"put own",
as_user.put_secret_value().secret_id(&own).secret_string("v2").send().await,
);
// Any other key is refused.
for (i, (what, k)) in [("no KmsKeyId", None), ("aws/secretsmanager", Some(aws_managed.as_str()))]
.into_iter()
.enumerate()
{
let r = create(&format!("refused-{i}"), k).await;
if let Ok(out) = &r {
e.record_secret(out.arn().unwrap());
}
denied(what, r);
}
// Only the ordinary tag keys, and never claiming or changing others'.
denied(
"tag own with triple-c:component",
as_user
.tag_resource()
.secret_id(&own)
.tags(sm_tag("triple-c:owner", &me))
.tags(sm_tag("triple-c:component", "x"))
.send()
.await,
);
for (what, arn) in [("environment", &environment), ("someone else's", &someone_elses)] {
denied(
&format!("put {what}"),
as_user.put_secret_value().secret_id(arn).secret_string("x").send().await,
);
denied(
&format!("delete {what}"),
as_user.delete_secret().secret_id(arn).send().await,
);
denied(
&format!("claim {what}"),
as_user
.tag_resource()
.secret_id(arn)
.tags(sm_tag("triple-c:owner", &me))
.send()
.await,
);
}
// A task role under the boundary decrypts through Secrets Manager
// only with the component-tagged key.
let as_task = aws_sdk_secretsmanager::Client::new(&e.assume(&task).await);
let value = ok(
"task reads own",
as_task.get_secret_value().secret_id(&own).send().await,
);
assert_eq!(value.secret_string(), Some("v2"));
ok(
"delete own",
as_user
.delete_secret()
.secret_id(&own)
.force_delete_without_recovery(true)
.send()
.await,
);
})
.await;
}
/// The admin tier's fences, for real: roles only with the Triple-C
/// boundary, the boundary never removed or swapped or edited, and keys
/// created only with `triple-c:component` (tag-on-create passes the
/// `kms:TagResource` rule) and destroyed only if they carry it.
///
/// Needs the exact `triple-c-permissions-boundary` ARN: the test creates
/// that policy and deletes it again, unless the account already has one, in
/// which case it uses it and leaves it alone.
#[tokio::test]
#[ignore]
async fn the_admin_tier_creates_only_bounded_roles_and_tagged_keys() {
e2e(|e| async move {
let boundary = format!("arn:aws:iam::{}:policy/{PERMISSIONS_BOUNDARY_NAME}", e.account);
if e.iam.get_policy().policy_arn(&boundary).send().await.is_err() {
ok(
"create the boundary",
e.iam
.create_policy()
.policy_name(PERMISSIONS_BOUNDARY_NAME)
.policy_document(permissions_boundary_json())
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
.send()
.await,
);
e.created.lock().unwrap().policies.push(boundary.clone());
}
let other_boundary = e.managed_policy("other-boundary", permissions_boundary_json()).await;
let admin = e
.tier_role("fence", &[Tier::WorkspaceUser, Tier::EnvironmentAdmin])
.await;
let cfg = e.assume(&admin).await;
let iam = aws_sdk_iam::Client::new(&cfg);
let kms = aws_sdk_kms::Client::new(&cfg);
let trust = format!(
r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow","Principal":{{"AWS":"arn:aws:iam::{}:root"}},"Action":"sts:AssumeRole"}}]}}"#,
e.account
);
let create = |suffix: &str, b: Option<&str>| {
iam.create_role()
.role_name(e.name(suffix))
.assume_role_policy_document(&trust)
.tags(iam_tag(TEST_TAG.0, TEST_TAG.1))
.set_permissions_boundary(b.map(str::to_string))
.send()
};
for (suffix, b) in [("unbounded", None), ("other-bounded", Some(other_boundary.as_str()))] {
let r = create(suffix, b).await;
if r.is_ok() {
e.created.lock().unwrap().roles.push(e.name(suffix));
}
denied(&format!("create {suffix} role"), r);
}
let child = e.name("bounded");
ok("create bounded role", create("bounded", Some(&boundary)).await);
e.created.lock().unwrap().roles.push(child.clone());
ok(
"put a policy on the bounded role",
iam.put_role_policy()
.role_name(&child)
.policy_name("p")
.policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:GetCallerIdentity","Resource":"*"}]}"#)
.send()
.await,
);
denied(
"remove the boundary",
iam.delete_role_permissions_boundary().role_name(&child).send().await,
);
denied(
"swap the boundary",
iam.put_role_permissions_boundary()
.role_name(&child)
.permissions_boundary(&other_boundary)
.send()
.await,
);
denied(
"edit the boundary",
iam.create_policy_version()
.policy_arn(&boundary)
.policy_document(permissions_boundary_json())
.send()
.await,
);
// Keys: tagged create passes (tag-on-create authorizes
// kms:TagResource against the new key's requested tags).
let untagged = kms.create_key().description(e.name("untagged")).send().await;
if let Ok(out) = &untagged {
e.created
.lock()
.unwrap()
.keys
.push(out.key_metadata().unwrap().arn().unwrap().to_string());
}
denied("create an untagged key", untagged);
let key = e.environment_key(&kms, "admin-key").await;
ok(
"schedule the environment key's deletion",
kms.schedule_key_deletion()
.key_id(&key)
.pending_window_in_days(7)
.send()
.await,
);
})
.await;
}
+16
View File
@@ -0,0 +1,16 @@
//! AWS connection plumbing: an SDK config built from a saved
//! `HostConnection`, the caller's identity, the tier policies and the
//! permission check.
//!
mod connection;
mod error;
mod identity;
#[cfg(test)]
mod live_tests;
pub mod permissions;
pub mod policies;
pub use connection::{sdk_config, sdk_config_for_keys};
pub use error::AwsError;
pub use identity::{caller_identity, CallerIdentity};
+557
View File
@@ -0,0 +1,557 @@
//! The permission check: which Triple-C tier the connection's credentials
//! can act as, by simulating each tier's checks (`policies::checks`) with
//! IAM's policy simulator, or — when the caller may not simulate itself —
//! by a few read-only probes.
use std::future::Future;
use futures_util::stream::{self, StreamExt, TryStreamExt};
use serde::Serialize;
use super::error::{classify_sdk_error, AwsError};
use super::identity::{self, principal_for_simulation, CallerIdentity, PrincipalArn};
use super::policies::{checks, render, Check, ContextValue, Placeholders, Tier};
/// How many simulations are in flight at once.
const CONCURRENCY: usize = 2;
/// One `SimulatePrincipalPolicy` request, fully rendered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimulationRequest {
pub principal: String,
pub action: String,
pub resource: String,
pub context: Vec<ContextEntry>,
}
/// A context key with every value it carries in the request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextEntry {
pub key: String,
pub values: Vec<String>,
}
impl ContextEntry {
/// Sent as `StringList` when the key has more than one value, `String`
/// otherwise.
pub fn is_list(&self) -> bool {
self.values.len() > 1
}
}
/// Builds the simulation for `check`: the resource and every literal context
/// value rendered with `p`, `OwnerId` as `user_id`, and `aws:userid` =
/// `user_id` added. A key appearing more than once becomes one entry holding
/// all its values (in order, without duplicates).
pub fn simulation_request(check: &Check, p: &Placeholders, user_id: &str) -> SimulationRequest {
let mut context: Vec<ContextEntry> = Vec::new();
let mut add = |key: &str, value: String| match context.iter_mut().find(|e| e.key == key) {
Some(e) if e.values.contains(&value) => {}
Some(e) => e.values.push(value),
None => context.push(ContextEntry {
key: key.to_string(),
values: vec![value],
}),
};
for (key, value) in check.context {
let value = match value {
ContextValue::OwnerId => user_id.to_string(),
ContextValue::Literal(template) => render(template, p),
};
add(key, value);
}
add("aws:userid", user_id.to_string());
SimulationRequest {
principal: p.principal.to_string(),
action: check.action.to_string(),
resource: render(check.resource, p),
context,
}
}
/// The read-only calls used when simulation isn't possible.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Probe {
EcsListClusters,
SsmGetParametersByPath,
CfnDescribeStacks,
SecretsListSecrets,
}
impl Probe {
const ALL: [Probe; 4] = [
Probe::EcsListClusters,
Probe::SsmGetParametersByPath,
Probe::CfnDescribeStacks,
Probe::SecretsListSecrets,
];
/// The probe standing in for a read check's service, if there is one.
fn for_action(action: &str) -> Option<Probe> {
match action.split_once(':')?.0 {
"ecs" => Some(Probe::EcsListClusters),
"ssm" => Some(Probe::SsmGetParametersByPath),
"cloudformation" => Some(Probe::CfnDescribeStacks),
"secretsmanager" => Some(Probe::SecretsListSecrets),
_ => None,
}
}
fn action(self) -> &'static str {
match self {
Probe::EcsListClusters => "ecs:ListClusters",
Probe::SsmGetParametersByPath => "ssm:GetParametersByPath",
Probe::CfnDescribeStacks => "cloudformation:DescribeStacks",
Probe::SecretsListSecrets => "secretsmanager:ListSecrets",
}
}
}
/// What the permission check needs from AWS. The methods return `Send`
/// futures so `check_permissions` is `Send` (a Tauri command needs that) for
/// any implementation; an impl may still write them as `async fn`.
pub trait PermissionApi: Send + Sync {
/// The ARN to simulate for the caller's principal (`iam:GetRole` for a
/// role session).
fn resolve_principal(
&self,
principal: PrincipalArn,
) -> impl Future<Output = Result<String, AwsError>> + Send;
/// Whether the simulator says `allowed`.
fn simulate(
&self,
request: &SimulationRequest,
) -> impl Future<Output = Result<bool, AwsError>> + Send;
fn probe(&self, probe: Probe) -> impl Future<Output = Result<(), AwsError>> + Send;
}
/// The real thing, from a connection's `SdkConfig`.
pub struct SdkPermissionApi {
iam: aws_sdk_iam::Client,
ecs: aws_sdk_ecs::Client,
ssm: aws_sdk_ssm::Client,
cfn: aws_sdk_cloudformation::Client,
secrets: aws_sdk_secretsmanager::Client,
}
impl SdkPermissionApi {
pub fn new(cfg: &aws_config::SdkConfig) -> Self {
// IAM throttles SimulatePrincipalPolicy hard: a full check (about a
// hundred simulations) failed a third of its rows with "Rate
// exceeded" under standard retries (verified live). Adaptive retry
// backs off client-side once throttled, with room to retry.
let iam = aws_sdk_iam::config::Builder::from(cfg)
.retry_config(aws_config::retry::RetryConfig::adaptive().with_max_attempts(10))
.build();
SdkPermissionApi {
iam: aws_sdk_iam::Client::from_conf(iam),
ecs: aws_sdk_ecs::Client::new(cfg),
ssm: aws_sdk_ssm::Client::new(cfg),
cfn: aws_sdk_cloudformation::Client::new(cfg),
secrets: aws_sdk_secretsmanager::Client::new(cfg),
}
}
}
impl PermissionApi for SdkPermissionApi {
async fn resolve_principal(&self, principal: PrincipalArn) -> Result<String, AwsError> {
identity::resolve_principal(&self.iam, principal).await
}
async fn simulate(&self, request: &SimulationRequest) -> Result<bool, AwsError> {
use aws_sdk_iam::types::{ContextEntry as SdkEntry, ContextKeyTypeEnum};
const ACTION: &str = "iam:SimulatePrincipalPolicy";
let entries = request.context.iter().map(|e| {
SdkEntry::builder()
.context_key_name(&e.key)
.set_context_key_values(Some(e.values.clone()))
.context_key_type(if e.is_list() {
ContextKeyTypeEnum::StringList
} else {
ContextKeyTypeEnum::String
})
.build()
});
let out = self
.iam
.simulate_principal_policy()
.policy_source_arn(&request.principal)
.action_names(&request.action)
.resource_arns(&request.resource)
.set_context_entries(Some(entries.collect()))
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
let decision = out
.evaluation_results()
.first()
.map(|r| r.eval_decision().as_str())
.ok_or_else(|| AwsError::Other {
message: format!("{ACTION} returned no result for {}", request.action),
})?;
Ok(decision == "allowed")
}
async fn probe(&self, probe: Probe) -> Result<(), AwsError> {
let action = probe.action();
let classify = |e: &(dyn std::error::Error + 'static)| classify_sdk_error(action, e);
match probe {
Probe::EcsListClusters => self
.ecs
.list_clusters()
.max_results(1)
.send()
.await
.map(drop)
.map_err(|e| classify(&e)),
Probe::SsmGetParametersByPath => self
.ssm
.get_parameters_by_path()
.path("/triple-c/environments")
.max_results(1)
.send()
.await
.map(drop)
.map_err(|e| classify(&e)),
Probe::CfnDescribeStacks => self
.cfn
.describe_stacks()
.send()
.await
.map(drop)
.map_err(|e| classify(&e)),
Probe::SecretsListSecrets => self
.secrets
.list_secrets()
.max_results(1)
.send()
.await
.map(drop)
.map_err(|e| classify(&e)),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Allowed,
Denied,
Unverified,
}
/// One row per action. An action checked against several resources (or
/// with several contexts) is `Denied` if any of them is, else `Unverified`
/// if any is, else `Allowed`.
#[derive(Debug, Clone, Serialize)]
pub struct ActionResult {
pub action: String,
pub verdict: Verdict,
/// Set when the row is `Unverified` because a call failed (throttling,
/// network, ...): the error, as `AwsError` displays it. Such a row
/// blocks its tier's claim; an `Unverified` row without a note (a write
/// in probe mode, an unprobed read) does not.
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TierResult {
pub tier: Tier,
pub results: Vec<ActionResult>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Method {
Simulated,
/// Simulation wasn't possible; `reason` says why.
Probed {
reason: String,
},
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Summary {
EnvironmentAdmin,
WorkspaceUser,
Insufficient,
}
#[derive(Debug, Clone, Serialize)]
pub struct PermissionReport {
pub identity: CallerIdentity,
pub region: String,
pub method: Method,
pub summary: Summary,
/// `[WorkspaceUser, EnvironmentAdmin]`.
pub tiers: Vec<TierResult>,
}
const TIERS: [Tier; 2] = [Tier::WorkspaceUser, Tier::EnvironmentAdmin];
/// One check's result, before checks are folded into per-action rows.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Outcome {
Allowed,
Denied,
/// Not checkable by design (a write in probe mode, an unprobed read).
Unverified,
/// The call failed for a reason that says nothing about the permission.
Failed(String),
}
/// The worst of `outcomes`: Denied, then Failed (as `Unverified` with the
/// first failure as its note), then Unverified, then Allowed.
fn aggregate(outcomes: &[Outcome]) -> (Verdict, Option<String>) {
if outcomes.contains(&Outcome::Denied) {
return (Verdict::Denied, None);
}
if let Some(note) = outcomes.iter().find_map(|o| match o {
Outcome::Failed(m) => Some(m.clone()),
_ => None,
}) {
return (Verdict::Unverified, Some(note));
}
if outcomes.contains(&Outcome::Unverified) {
return (Verdict::Unverified, None);
}
(Verdict::Allowed, None)
}
/// Folds per-check outcomes (in `checks(tier)` order) into one row per
/// action, in first-appearance order.
fn tier_result(tier: Tier, outcomes: Vec<Outcome>) -> TierResult {
let mut rows: Vec<(&'static str, Vec<Outcome>)> = Vec::new();
for (check, outcome) in checks(tier).iter().zip(outcomes) {
match rows.iter_mut().find(|(a, _)| *a == check.action) {
Some((_, os)) => os.push(outcome),
None => rows.push((check.action, vec![outcome])),
}
}
TierResult {
tier,
results: rows
.into_iter()
.map(|(action, os)| {
let (verdict, note) = aggregate(&os);
ActionResult {
action: action.to_string(),
verdict,
note,
}
})
.collect(),
}
}
/// A tier is claimed when none of its rows is `Denied` or failed (has a
/// note). Design-level `Unverified` rows don't block. A probed run never
/// claims the admin tier (R15): it is nearly all writes, which probes can't
/// see, so "nothing denied" would mean nothing.
fn summary(tiers: &[TierResult], probed: bool) -> Summary {
let clear = |t: Tier| {
tiers.iter().filter(|r| r.tier == t).all(|r| {
r.results
.iter()
.all(|a| a.verdict != Verdict::Denied && a.note.is_none())
})
};
if !clear(Tier::WorkspaceUser) {
Summary::Insufficient
} else if !probed && clear(Tier::EnvironmentAdmin) {
Summary::EnvironmentAdmin
} else {
Summary::WorkspaceUser
}
}
/// Errors that mean the credentials themselves don't work: the UI acts on
/// these (sign in, re-enter keys) rather than showing a report.
fn is_credential_error(e: &AwsError) -> bool {
matches!(
e,
AwsError::SsoExpired { .. }
| AwsError::InvalidCredentials { .. }
| AwsError::ProfileNotFound { .. }
| AwsError::KeysMissing
)
}
/// Which tiers the caller's credentials can act as, in `region`.
///
/// Simulates every check of both tiers against the caller's principal. It
/// falls back to probes when the principal can't be simulated (root,
/// federated user), resolving it is denied (`iam:GetRole`), or the first
/// simulation is denied. In probe mode a read check whose service has a
/// probe takes that probe's result; the check behind the fallback (the
/// caller's own `iam:GetRole` / `iam:SimulatePrincipalPolicy`) is `Denied`;
/// every other check is `Unverified`.
///
/// A credential error (SSO expired, invalid or missing credentials) from
/// any call is returned as `Err`, as is any other failure resolving the
/// principal. Other failures of individual checks (throttling, network)
/// make that row `Unverified` with a note, which blocks its tier's claim.
/// See [`summary`] for the claim rules.
pub async fn check_permissions<A: PermissionApi>(
api: &A,
identity: &CallerIdentity,
region: &str,
) -> Result<PermissionReport, AwsError> {
let (method, outcomes) = match simulate_all(api, identity, region).await? {
Simulation::Done(outcomes) => (Method::Simulated, outcomes),
Simulation::Fallback { reason, denied } => (
Method::Probed { reason },
probe_all(api, denied.as_deref()).await?,
),
};
let failed: Vec<&str> = outcomes
.iter()
.flatten()
.filter_map(|o| match o {
Outcome::Failed(m) => Some(m.as_str()),
_ => None,
})
.collect();
if let Some(first) = failed.first() {
let total: usize = outcomes.iter().map(Vec::len).sum();
log::warn!(
"AWS permission check: {} of {total} checks could not be evaluated (first: {first})",
failed.len()
);
}
let probed = matches!(method, Method::Probed { .. });
let tiers: Vec<TierResult> = TIERS
.into_iter()
.zip(outcomes)
.map(|(tier, os)| tier_result(tier, os))
.collect();
Ok(PermissionReport {
identity: identity.clone(),
region: region.to_string(),
method,
summary: summary(&tiers, probed),
tiers,
})
}
enum Simulation {
Done([Vec<Outcome>; 2]),
/// Simulation isn't possible. `denied` is the action whose denial
/// caused it, if one did.
Fallback {
reason: String,
denied: Option<String>,
},
}
impl Simulation {
fn fallback(e: AwsError) -> Self {
let denied = match &e {
AwsError::AccessDenied { action, .. } => Some(action.clone()),
_ => None,
};
Simulation::Fallback {
reason: e.to_string(),
denied,
}
}
}
fn simulated_outcome(r: Result<bool, AwsError>) -> Result<Outcome, AwsError> {
match r {
Ok(true) => Ok(Outcome::Allowed),
Ok(false) => Ok(Outcome::Denied),
Err(e) if is_credential_error(&e) => Err(e),
Err(e) => Ok(Outcome::Failed(e.to_string())),
}
}
/// Per-tier outcomes by simulation, or why simulation isn't possible.
async fn simulate_all<A: PermissionApi>(
api: &A,
identity: &CallerIdentity,
region: &str,
) -> Result<Simulation, AwsError> {
let principal = match principal_for_simulation(&identity.arn) {
Ok(p) => p,
Err(e) => return Ok(Simulation::fallback(e)),
};
let principal = match api.resolve_principal(principal).await {
Ok(p) => p,
Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)),
Err(e) => return Err(e),
};
let p = Placeholders {
region,
account: &identity.account,
principal: &principal,
};
let mut requests = TIERS
.into_iter()
.flat_map(|t| checks(t).iter())
.map(|c| simulation_request(c, &p, &identity.user_id))
.collect::<Vec<_>>()
.into_iter();
let Some(first) = requests.next() else {
return Ok(Simulation::Done([Vec::new(), Vec::new()]));
};
// The first simulation alone: if the caller may not simulate at all,
// that's the moment to fall back, before spending the rest.
let first = match api.simulate(&first).await {
Err(e @ AwsError::AccessDenied { .. }) => return Ok(Simulation::fallback(e)),
r => simulated_outcome(r)?,
};
// Owned requests: a closure over borrowed ones makes the future's
// `Send` unprovable (rustc's higher-ranked closure limitation).
let rest: Vec<Outcome> = stream::iter(requests)
.map(|r| async move { simulated_outcome(api.simulate(&r).await) })
.buffered(CONCURRENCY)
.try_collect()
.await?;
let mut all = std::iter::once(first).chain(rest);
Ok(Simulation::Done(
TIERS.map(|t| all.by_ref().take(checks(t).len()).collect()),
))
}
/// Per-tier outcomes from the four probes. `denied` is the action whose
/// denial forced probing; the caller's own-principal checks of it are
/// `Denied`.
async fn probe_all<A: PermissionApi>(
api: &A,
denied: Option<&str>,
) -> Result<[Vec<Outcome>; 2], AwsError> {
let results =
futures_util::future::join_all(Probe::ALL.map(|p| async move { (p, api.probe(p).await) }))
.await;
if let Some((_, Err(e))) = results
.iter()
.find(|(_, r)| matches!(r, Err(e) if is_credential_error(e)))
{
return Err(e.clone());
}
let probe_outcome = |probe: Probe| match results.iter().find(|(p, _)| *p == probe) {
Some((_, Ok(()))) => Outcome::Allowed,
Some((_, Err(AwsError::AccessDenied { .. }))) => Outcome::Denied,
Some((_, Err(e))) => Outcome::Failed(e.to_string()),
None => Outcome::Unverified,
};
Ok(TIERS.map(|t| {
checks(t)
.iter()
.map(|c| {
if denied == Some(c.action) && c.resource == "{principal}" {
return Outcome::Denied;
}
match Probe::for_action(c.action) {
Some(probe) if !c.write => probe_outcome(probe),
_ => Outcome::Unverified,
}
})
.collect()
}))
}
#[cfg(test)]
mod tests;
+632
View File
@@ -0,0 +1,632 @@
use std::collections::HashSet;
use std::sync::Mutex;
use super::*;
use crate::aws::policies::{checks, Check, ContextValue, Placeholders, Tier};
const USER_ID: &str = "AROAEXAMPLEID:alice@example.com";
const ROLE_ARN: &str =
"arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Ws_abc";
fn identity() -> CallerIdentity {
CallerIdentity {
account: "111122223333".into(),
arn: "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_Ws_abc/alice@example.com"
.into(),
user_id: USER_ID.into(),
}
}
const ALL_PROBES: [Probe; 4] = [
Probe::EcsListClusters,
Probe::SsmGetParametersByPath,
Probe::CfnDescribeStacks,
Probe::SecretsListSecrets,
];
/// A simulation call as the fake saw it.
#[derive(Debug, Clone)]
struct Simulated {
principal: String,
action: String,
resource: String,
context: Vec<ContextEntry>,
}
struct FakeApi {
allow: HashSet<&'static str>,
simulate_denied: bool,
get_role_denied: bool,
/// Returned by `resolve_principal` (after `get_role_denied`).
resolve_error: Option<AwsError>,
/// Returned by `simulate` for the named action.
simulate_errors: Vec<(&'static str, AwsError)>,
probes_ok: HashSet<Probe>,
/// Returned by every probe not in `probes_ok` (default: AccessDenied).
probe_error: Option<AwsError>,
simulated: Mutex<Vec<Simulated>>,
probed: Mutex<Vec<Probe>>,
}
impl FakeApi {
fn allowing(tiers: &[Tier]) -> Self {
let allow = tiers
.iter()
.flat_map(|t| checks(*t).iter().map(|c| c.action))
.collect();
FakeApi {
allow,
simulate_denied: false,
get_role_denied: false,
resolve_error: None,
simulate_errors: Vec::new(),
probes_ok: ALL_PROBES.into_iter().collect(),
probe_error: None,
simulated: Mutex::new(Vec::new()),
probed: Mutex::new(Vec::new()),
}
}
fn simulated(&self) -> Vec<Simulated> {
self.simulated.lock().unwrap().clone()
}
}
impl PermissionApi for FakeApi {
async fn resolve_principal(&self, principal: PrincipalArn) -> Result<String, AwsError> {
if self.get_role_denied {
return Err(AwsError::AccessDenied {
action: "iam:GetRole".into(),
message: "not authorized to perform: iam:GetRole".into(),
});
}
if let Some(e) = &self.resolve_error {
return Err(e.clone());
}
Ok(match principal {
PrincipalArn::Exact(arn) => arn,
PrincipalArn::AssumedRole { .. } => ROLE_ARN.into(),
})
}
async fn simulate(&self, request: &SimulationRequest) -> Result<bool, AwsError> {
self.simulated.lock().unwrap().push(Simulated {
principal: request.principal.clone(),
action: request.action.clone(),
resource: request.resource.clone(),
context: request.context.clone(),
});
if self.simulate_denied {
return Err(AwsError::AccessDenied {
action: "iam:SimulatePrincipalPolicy".into(),
message: "not authorized to perform: iam:SimulatePrincipalPolicy".into(),
});
}
if let Some((_, e)) = self
.simulate_errors
.iter()
.find(|(a, _)| *a == request.action)
{
return Err(e.clone());
}
Ok(self.allow.contains(request.action.as_str()))
}
async fn probe(&self, probe: Probe) -> Result<(), AwsError> {
self.probed.lock().unwrap().push(probe);
if self.probes_ok.contains(&probe) {
Ok(())
} else if let Some(e) = &self.probe_error {
Err(e.clone())
} else {
Err(AwsError::AccessDenied {
action: format!("{probe:?}"),
message: "denied".into(),
})
}
}
}
fn try_run(api: &FakeApi, id: &CallerIdentity) -> Result<PermissionReport, AwsError> {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(check_permissions(api, id, "eu-west-1"))
}
fn run(api: &FakeApi) -> PermissionReport {
try_run(api, &identity()).unwrap()
}
fn row<'a>(report: &'a PermissionReport, t: Tier, action: &str) -> &'a ActionResult {
tier(report, t)
.results
.iter()
.find(|r| r.action == action)
.unwrap_or_else(|| panic!("{action} not in {t:?}"))
}
fn throttled() -> AwsError {
AwsError::Other {
message: "Rate exceeded (Throttling)".into(),
}
}
fn tier(report: &PermissionReport, t: Tier) -> &TierResult {
report.tiers.iter().find(|r| r.tier == t).unwrap()
}
fn verdict(report: &PermissionReport, t: Tier, action: &str) -> Verdict {
tier(report, t)
.results
.iter()
.find(|r| r.action == action)
.unwrap_or_else(|| panic!("{action} not in {t:?}"))
.verdict
.clone()
}
#[test]
fn a_full_workspace_policy_reports_workspace_user() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let report = run(&api);
assert_eq!(report.method, Method::Simulated);
assert_eq!(report.summary, Summary::WorkspaceUser);
assert_eq!(report.region, "eu-west-1");
assert_eq!(report.identity, identity());
let tiers: Vec<Tier> = report.tiers.iter().map(|t| t.tier).collect();
assert_eq!(tiers, [Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
assert!(tier(&report, Tier::WorkspaceUser)
.results
.iter()
.all(|r| r.verdict == Verdict::Allowed));
// Every check was simulated against the resolved role, not the session.
let sims = api.simulated();
let total = checks(Tier::WorkspaceUser).len() + checks(Tier::EnvironmentAdmin).len();
assert_eq!(sims.len(), total);
assert!(sims.iter().all(|s| s.principal == ROLE_ARN));
}
#[test]
fn admin_and_workspace_policies_report_environment_admin() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
let report = run(&api);
assert_eq!(report.method, Method::Simulated);
assert_eq!(report.summary, Summary::EnvironmentAdmin);
}
#[test]
fn a_missing_action_reports_insufficient_and_names_it() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.allow.remove("iam:PassRole");
let report = run(&api);
assert_eq!(report.summary, Summary::Insufficient);
assert_eq!(
verdict(&report, Tier::WorkspaceUser, "iam:PassRole"),
Verdict::Denied
);
assert_eq!(
verdict(&report, Tier::WorkspaceUser, "ecs:RunTask"),
Verdict::Allowed
);
}
#[test]
fn an_admin_gap_still_reports_workspace_user() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.allow.remove("cloudformation:CreateStack");
let report = run(&api);
assert_eq!(report.summary, Summary::WorkspaceUser);
assert_eq!(
verdict(
&report,
Tier::EnvironmentAdmin,
"cloudformation:CreateStack"
),
Verdict::Denied
);
}
/// An action checked against several resources is one row; any denial wins.
#[test]
fn an_action_checked_twice_is_one_row_and_denied_if_either_is() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let report = run(&api);
let rows = tier(&report, Tier::WorkspaceUser)
.results
.iter()
.filter(|r| r.action == "ssm:StartSession")
.count();
assert_eq!(rows, 1);
let failed = || Outcome::Failed("Throttling".into());
assert_eq!(
aggregate(&[Outcome::Allowed, Outcome::Denied, failed()]),
(Verdict::Denied, None)
);
assert_eq!(
aggregate(&[Outcome::Allowed, Outcome::Unverified, failed()]),
(Verdict::Unverified, Some("Throttling".into()))
);
assert_eq!(
aggregate(&[Outcome::Allowed, Outcome::Unverified]),
(Verdict::Unverified, None)
);
assert_eq!(aggregate(&[Outcome::Allowed]), (Verdict::Allowed, None));
}
fn context_value<'a>(s: &'a Simulated, key: &str) -> Option<&'a ContextEntry> {
s.context.iter().find(|e| e.key == key)
}
#[test]
fn owner_context_is_sent_with_owner_scoped_checks() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
run(&api);
let sims = api.simulated();
let run_task = sims.iter().find(|s| s.action == "ecs:RunTask").unwrap();
let owner = context_value(run_task, "aws:RequestTag/triple-c:owner").unwrap();
assert_eq!(owner.values, [USER_ID]);
// Literals are rendered like resources.
let cluster = context_value(run_task, "ecs:cluster").unwrap();
assert_eq!(
cluster.values,
["arn:aws:ecs:eu-west-1:111122223333:cluster/triple-c-probe"]
);
assert_eq!(
run_task.resource,
"arn:aws:ecs:eu-west-1:111122223333:task-definition/triple-c-probe:1"
);
// aws:userid on every simulation.
for s in &sims {
let userid = context_value(s, "aws:userid")
.unwrap_or_else(|| panic!("{} has no aws:userid", s.action));
assert_eq!(userid.values, [USER_ID], "{}", s.action);
}
}
#[test]
fn the_principal_placeholder_is_the_resolved_role() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
run(&api);
let sims = api.simulated();
let get_role = sims
.iter()
.find(|s| s.action == "iam:GetRole" && s.resource == ROLE_ARN)
.expect("iam:GetRole on the caller's own role");
// The policies name a user's own ARN by `${aws:username}`.
assert_eq!(
context_value(get_role, "aws:username").unwrap().values,
[ROLE_ARN.rsplit('/').next().unwrap()]
);
}
#[test]
fn a_repeated_context_key_is_one_string_list_entry() {
static CTX: &[(&str, ContextValue)] = &[
("aws:TagKeys", ContextValue::Literal("triple-c:owner")),
(
"ecs:cluster",
ContextValue::Literal("arn:{region}:{account}"),
),
("aws:TagKeys", ContextValue::Literal("triple-c:component")),
("aws:TagKeys", ContextValue::OwnerId),
];
let check = Check {
action: "ecs:TagResource",
resource: "arn:aws:ecs:{region}:{account}:task/x",
context: CTX,
write: true,
};
let p = Placeholders {
region: "eu-west-1",
account: "111122223333",
principal: ROLE_ARN,
};
let req = simulation_request(&check, &p, USER_ID);
assert_eq!(req.principal, ROLE_ARN);
assert_eq!(req.action, "ecs:TagResource");
assert_eq!(req.resource, "arn:aws:ecs:eu-west-1:111122223333:task/x");
assert_eq!(
req.context,
[
ContextEntry {
key: "aws:TagKeys".into(),
values: vec![
"triple-c:owner".into(),
"triple-c:component".into(),
USER_ID.into()
],
},
ContextEntry {
key: "ecs:cluster".into(),
values: vec!["arn:eu-west-1:111122223333".into()],
},
ContextEntry {
key: "aws:userid".into(),
values: vec![USER_ID.into()],
},
]
);
assert!(req.context[0].is_list());
assert!(!req.context[1].is_list());
}
#[test]
fn a_check_naming_aws_userid_itself_is_not_sent_it_twice() {
static CTX: &[(&str, ContextValue)] = &[("aws:userid", ContextValue::OwnerId)];
let check = Check {
action: "sts:GetCallerIdentity",
resource: "*",
context: CTX,
write: false,
};
let p = Placeholders {
region: "r",
account: "a",
principal: "p",
};
let req = simulation_request(&check, &p, USER_ID);
assert_eq!(
req.context,
[ContextEntry {
key: "aws:userid".into(),
values: vec![USER_ID.into()],
}]
);
}
#[test]
fn a_denied_simulation_falls_back_to_probes_and_marks_writes_unverified() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.simulate_denied = true;
let report = run(&api);
match &report.method {
Method::Probed { reason } => {
assert!(reason.contains("iam:SimulatePrincipalPolicy"), "{reason}")
}
m => panic!("expected Probed, got {m:?}"),
}
// Only the first simulation was tried.
assert_eq!(api.simulated().len(), 1);
let mut probed = api.probed.lock().unwrap().clone();
probed.sort_by_key(|p| format!("{p:?}"));
let mut all = ALL_PROBES.to_vec();
all.sort_by_key(|p| format!("{p:?}"));
assert_eq!(probed, all);
let ws = Tier::WorkspaceUser;
assert_eq!(verdict(&report, ws, "ecs:RunTask"), Verdict::Unverified);
assert_eq!(verdict(&report, ws, "iam:PassRole"), Verdict::Unverified);
// Reads with a probe get its result.
assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Allowed);
assert_eq!(
verdict(&report, ws, "ecs:DescribeClusters"),
Verdict::Allowed
);
assert_eq!(
verdict(&report, ws, "ssm:GetParametersByPath"),
Verdict::Allowed
);
assert_eq!(
verdict(&report, ws, "cloudformation:DescribeStacks"),
Verdict::Allowed
);
assert_eq!(
verdict(&report, ws, "secretsmanager:ListSecrets"),
Verdict::Allowed
);
// Reads with no probe are unverified.
assert_eq!(
verdict(&report, ws, "logs:FilterLogEvents"),
Verdict::Unverified
);
assert_eq!(verdict(&report, ws, "iam:GetRole"), Verdict::Unverified);
assert_eq!(
verdict(&report, ws, "sts:GetCallerIdentity"),
Verdict::Unverified
);
// The fallback itself is evidence: the caller may not simulate itself,
// which both tiers need.
for t in [ws, Tier::EnvironmentAdmin] {
assert_eq!(
verdict(&report, t, "iam:SimulatePrincipalPolicy"),
Verdict::Denied
);
}
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn a_denied_get_role_falls_back_to_probes() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.get_role_denied = true;
let report = run(&api);
match &report.method {
Method::Probed { reason } => assert!(reason.contains("iam:GetRole"), "{reason}"),
m => panic!("expected Probed, got {m:?}"),
}
assert!(api.simulated().is_empty());
assert_eq!(api.probed.lock().unwrap().len(), 4);
// Denied on the caller's own role: direct evidence.
assert_eq!(
verdict(&report, Tier::WorkspaceUser, "iam:GetRole"),
Verdict::Denied
);
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn an_unsimulatable_principal_falls_back_to_probes() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let mut id = identity();
id.arn = "arn:aws:iam::111122223333:root".into();
let report = try_run(&api, &id).unwrap();
match &report.method {
Method::Probed { reason } => assert!(reason.contains("simulation"), "{reason}"),
m => panic!("expected Probed, got {m:?}"),
}
assert!(api.simulated().is_empty());
}
/// R15: the admin tier is nearly all writes, so a probed run can't vouch
/// for it — every probe passing still caps at WorkspaceUser.
#[test]
fn a_probed_run_never_claims_environment_admin() {
let api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
let mut id = identity();
id.arn = "arn:aws:iam::111122223333:root".into();
let report = try_run(&api, &id).unwrap();
assert!(matches!(report.method, Method::Probed { .. }));
assert!(tier(&report, Tier::EnvironmentAdmin)
.results
.iter()
.all(|r| r.verdict != Verdict::Denied));
assert_eq!(report.summary, Summary::WorkspaceUser);
}
#[test]
fn throttled_simulations_block_the_tier_and_carry_notes() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.simulate_errors = vec![("cloudformation:CreateStack", throttled())];
let report = run(&api);
assert_eq!(report.method, Method::Simulated);
let r = row(
&report,
Tier::EnvironmentAdmin,
"cloudformation:CreateStack",
);
assert_eq!(r.verdict, Verdict::Unverified);
assert!(r.note.as_deref().unwrap().contains("Throttling"), "{r:?}");
// Allowed rows carry no note.
assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None);
assert_eq!(report.summary, Summary::WorkspaceUser);
// In the workspace tier, it blocks everything.
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
api.simulate_errors = vec![("ecs:RunTask", throttled())];
let report = run(&api);
assert_eq!(report.summary, Summary::Insufficient);
assert!(row(&report, Tier::WorkspaceUser, "ecs:RunTask")
.note
.is_some());
}
#[test]
fn a_throttled_first_simulation_is_a_note_not_a_fallback() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
let first = checks(Tier::WorkspaceUser)[0].action;
api.simulate_errors = vec![(first, throttled())];
let report = run(&api);
assert_eq!(report.method, Method::Simulated);
assert!(row(&report, Tier::WorkspaceUser, first).note.is_some());
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn an_expired_sso_session_mid_check_is_an_error() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser, Tier::EnvironmentAdmin]);
let sso = AwsError::SsoExpired {
profile: "dev".into(),
};
api.simulate_errors = vec![("logs:FilterLogEvents", sso.clone())];
assert_eq!(try_run(&api, &identity()).unwrap_err(), sso);
}
#[test]
fn credential_errors_anywhere_are_errors() {
let bad = AwsError::InvalidCredentials {
message: "ExpiredToken".into(),
};
// From resolving the principal.
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.resolve_error = Some(bad.clone());
assert_eq!(try_run(&api, &identity()).unwrap_err(), bad);
// From the first simulation.
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.simulate_errors = vec![(checks(Tier::WorkspaceUser)[0].action, bad.clone())];
assert_eq!(try_run(&api, &identity()).unwrap_err(), bad);
// From a probe.
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.get_role_denied = true;
api.probes_ok.remove(&Probe::CfnDescribeStacks);
api.probe_error = Some(AwsError::KeysMissing);
assert_eq!(
try_run(&api, &identity()).unwrap_err(),
AwsError::KeysMissing
);
}
#[test]
fn a_transport_error_resolving_the_principal_is_an_error() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let net = AwsError::Other {
message: "dispatch failure: connection refused".into(),
};
api.resolve_error = Some(net.clone());
assert_eq!(try_run(&api, &identity()).unwrap_err(), net);
assert!(api.probed.lock().unwrap().is_empty());
}
#[test]
fn a_failed_probe_marks_its_reads_denied() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.simulate_denied = true;
api.probes_ok.remove(&Probe::EcsListClusters);
let report = run(&api);
let ws = Tier::WorkspaceUser;
assert_eq!(verdict(&report, ws, "ecs:ListClusters"), Verdict::Denied);
assert_eq!(
verdict(&report, ws, "ecs:DescribeClusters"),
Verdict::Denied
);
assert_eq!(
verdict(&report, ws, "secretsmanager:ListSecrets"),
Verdict::Allowed
);
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn a_probe_failing_for_another_reason_leaves_its_reads_unverified_and_blocks() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
let mut id = identity();
id.arn = "arn:aws:iam::111122223333:root".into();
api.probes_ok.remove(&Probe::EcsListClusters);
api.probe_error = Some(AwsError::Other {
message: "connection reset".into(),
});
let report = try_run(&api, &id).unwrap();
let r = row(&report, Tier::WorkspaceUser, "ecs:ListClusters");
assert_eq!(r.verdict, Verdict::Unverified);
assert!(r.note.as_deref().unwrap().contains("connection reset"));
// Design-level unverified rows carry no note.
assert_eq!(row(&report, Tier::WorkspaceUser, "ecs:RunTask").note, None);
assert_eq!(report.summary, Summary::Insufficient);
}
#[test]
fn the_report_serializes_for_the_ui() {
let mut api = FakeApi::allowing(&[Tier::WorkspaceUser]);
api.get_role_denied = true;
let v = serde_json::to_value(run(&api)).unwrap();
// Probed because iam:GetRole is denied, which the workspace tier needs.
assert_eq!(v["summary"], "insufficient");
assert!(v["tiers"][0]["results"][0]
.as_object()
.unwrap()
.contains_key("note"));
assert_eq!(v["tiers"][0]["tier"], "workspace_user");
assert!(v["method"]["probed"]["reason"].is_string(), "{v}");
assert!(["allowed", "denied", "unverified"]
.contains(&v["tiers"][0]["results"][0]["verdict"].as_str().unwrap()));
}
/// Tauri commands need a `Send` future.
#[allow(dead_code)]
fn check_permissions_is_send() {
fn assert_send<T: Send>(_: T) {}
fn with<A: PermissionApi>(api: &A, id: &CallerIdentity) {
assert_send(check_permissions(api, id, "r"));
}
let _ = with::<SdkPermissionApi>;
}
File diff suppressed because it is too large. Load diff
+25 -27
View File
@@ -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)]
+1 -6
View File
@@ -277,12 +277,7 @@ impl PlaywrightDetection {
/// Playwright installed with `npm i -g`, or merely *run* once through
/// `npx @playwright/mcp`, is found as readily as one in
/// `/workspace/node_modules`.
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
detect_in(&ContainerTarget::docker(container_id)).await
}
/// [`detect`] against a target already in hand.
pub async fn detect_in(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
pub async fn detect(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
let output = container
.exec_oneshot(vec![
"node".to_string(),
+12 -14
View File
@@ -238,16 +238,15 @@ pub struct BrowserSetupOutcome {
pub async fn install_packages(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
) -> Result<BrowserSetupOutcome, String> {
let container = ContainerTarget::docker(container_id);
emit_progress(
app,
project_id,
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
);
let mut step = npm_install(app, project_id, &container, &[VIEWER_PACKAGE]).await?;
let mut step = npm_install(app, project_id, container, &[VIEWER_PACKAGE]).await?;
if step.exit_code != 0 {
return Err(format!(
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
@@ -264,9 +263,9 @@ pub async fn install_packages(
// see the note on `npm_install`. The pin can only be read after the first
// install has written the manifest, which is why this stays two commands
// rather than one.
let spec = pinned_playwright_spec(&container).await;
let spec = pinned_playwright_spec(container).await;
emit_progress(app, project_id, &format!("Installing {}…", spec));
let second = npm_install(app, project_id, &container, &[VIEWER_PACKAGE, &spec]).await?;
let second = npm_install(app, project_id, container, &[VIEWER_PACKAGE, &spec]).await?;
if second.exit_code != 0 {
return Err(format!(
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
@@ -278,7 +277,7 @@ pub async fn install_packages(
step.log = merge_logs(step.log, second.log);
emit_progress(app, project_id, "Re-checking what the container has…");
let detection = detect::detect_in(&container).await?;
let detection = detect::detect(container).await?;
// A probe that still finds something missing after a successful npm run is
// the interesting case, so it is surfaced rather than swallowed. And a
@@ -410,11 +409,10 @@ fn merge_logs(first: String, second: String) -> String {
pub async fn install_browser(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
target: BrowserTarget,
) -> Result<BrowserSetupOutcome, String> {
let container = ContainerTarget::docker(container_id);
let before = detect::detect_in(&container).await?;
let before = detect::detect(container).await?;
let Some(cli) = before.playwright_cli.clone() else {
return Err(
"Playwright isn't installed in this container yet, so there is nothing to install a \
@@ -458,7 +456,7 @@ pub async fn install_browser(
project_id,
"Step 1/3 — checking whether this container already has the browser system libraries…",
);
let state = check_libraries(&container, &cli, target).await;
let state = check_libraries(container, &cli, target).await;
match &state {
LibraryState::Present => {
emit_progress(
@@ -482,7 +480,7 @@ pub async fn install_browser(
let deps = run_step(
app,
project_id,
&container,
container,
"root",
"/tmp",
vec![
@@ -530,7 +528,7 @@ pub async fn install_browser(
let dl = run_step(
app,
project_id,
&container,
container,
user,
workdir,
vec![
@@ -559,7 +557,7 @@ pub async fn install_browser(
project_id,
&format!("Step 3/3 — checking that {} actually launches…", target.cli_name()),
);
let verdict = verify_launch(&container, &before, target).await;
let verdict = verify_launch(container, &before, target).await;
push_section(&mut log, &verdict.detail);
if !verdict.ok {
warning = merge(
@@ -590,7 +588,7 @@ pub async fn install_browser(
}
emit_progress(app, project_id, "Re-checking what the container has…");
let detection = detect::detect_in(&container).await?;
let detection = detect::detect(container).await?;
Ok(BrowserSetupOutcome {
detection,
+3 -4
View File
@@ -281,7 +281,7 @@ impl BrowserViewManager {
pub async fn start(
&self,
project_id: String,
container_id: String,
container: ContainerTarget,
app: AppHandle,
store: Arc<ProjectsStore>,
) -> Result<BrowserViewStatus, String> {
@@ -299,8 +299,7 @@ impl BrowserViewManager {
return Ok(self.status(&project_id, true).await);
}
let container = ContainerTarget::docker(container_id.as_str());
let detection = detect::detect_in(&container).await?;
let detection = detect::detect(&container).await?;
if !detection.is_usable() {
let blocker = detection.blocker().unwrap_or_else(|| {
"Playwright is present but incomplete in this container.".to_string()
@@ -328,7 +327,7 @@ impl BrowserViewManager {
// `--host 127.0.0.1` is ours to set, so the family is known and there is
// no need to go back to /proc/net to work it out.
let proxy = match BrowserViewProxy::bind(
container_id.clone(),
container.clone(),
container_port,
PortFamily::V4,
token.clone(),
+22 -22
View File
@@ -91,7 +91,7 @@ pub struct PageState {
pub async fn open(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
detection: &PlaywrightDetection,
url: &str,
viewport: Viewport,
@@ -115,17 +115,17 @@ pub async fn open(
// Reuse a helper that is already up. Relaunching would throw away the
// browser's cookies and storage — which for the auth case means signing in
// again to reach the second page, having just signed in on the first.
if state(container_id).await.ready {
if state(container).await.ready {
emit_progress(app, project_id, "Navigating the container's browser…");
set_viewport(container_id, viewport).await?;
navigate(container_id, url).await?;
if let Some(state) = wait_for_url(container_id, url).await {
set_viewport(container, viewport).await?;
navigate(container, url).await?;
if let Some(state) = wait_for_url(container, url).await {
return Ok(state);
}
// It stopped answering; fall through and start a fresh one.
}
close(container_id).await;
close(container).await;
// Cold start: a browser launch plus a page load, which is the several
// seconds the user would otherwise spend wondering whether the click
// registered.
@@ -150,7 +150,7 @@ pub async fn open(
shell_quote(&script),
HELPER_LOG
);
ContainerTarget::docker(container_id)
container
.exec_oneshot_as(
"claude",
vec!["sh".to_string(), "-c".to_string(), launcher],
@@ -160,36 +160,36 @@ pub async fn open(
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
emit_progress(app, project_id, "Waiting for the page to load…");
wait_until_ready(container_id).await
wait_until_ready(container).await
}
/// Resize the open page. Cheap enough to call from a window-resize handler.
pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> {
pub async fn set_viewport(container: &ContainerTarget, viewport: Viewport) -> Result<(), String> {
write_control(
container_id,
container,
serde_json::json!({ "viewport": viewport }).to_string(),
)
.await
}
/// Navigate the open page without relaunching the browser.
pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> {
write_control(container_id, serde_json::json!({ "url": url }).to_string()).await
pub async fn navigate(container: &ContainerTarget, url: &str) -> Result<(), String> {
write_control(container, serde_json::json!({ "url": url }).to_string()).await
}
/// Ask the helper to shut down. Best effort: a container that has none is the
/// normal case, and the caller is usually about to start one anyway.
pub async fn close(container_id: &str) {
let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await;
pub async fn close(container: &ContainerTarget) {
let _ = write_control(container, serde_json::json!({ "close": true }).to_string()).await;
}
/// Current state, or a default when no helper has ever run here.
pub async fn state(container_id: &str) -> PageState {
pub async fn state(container: &ContainerTarget) -> PageState {
let script = format!(
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
STATE_PATH
);
let Ok((out, _)) = ContainerTarget::docker(container_id)
let Ok((out, _)) = container
.exec_oneshot_as(
"claude",
vec!["node".to_string(), "-e".to_string(), script],
@@ -204,12 +204,12 @@ pub async fn state(container_id: &str) -> PageState {
/// Write the control file through Node rather than a shell redirect, so a URL
/// is never interpreted by `sh`.
async fn write_control(container_id: &str, json: String) -> Result<(), String> {
async fn write_control(container: &ContainerTarget, json: String) -> Result<(), String> {
let script = format!(
"require('fs').writeFileSync('{}',process.argv[1]);",
CONTROL_PATH
);
ContainerTarget::docker(container_id)
container
.exec_oneshot_as(
"claude",
vec!["node".to_string(), "-e".to_string(), script, json],
@@ -225,10 +225,10 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> {
/// Bounded much tighter than a cold start: the browser is already up, so this
/// is one navigation. `None` means it stopped answering, and the caller starts
/// a fresh helper rather than reporting a page that isn't there.
async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
async fn wait_for_url(container: &ContainerTarget, url: &str) -> Option<PageState> {
let deadline = std::time::Instant::now() + REUSE_TIMEOUT;
loop {
let state = state(container_id).await;
let state = state(container).await;
if state.ready && state.url.as_deref() == Some(url) {
return Some(state);
}
@@ -240,10 +240,10 @@ async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
}
/// Poll the state file until the helper says the page is up, or says why not.
async fn wait_until_ready(container_id: &str) -> Result<PageState, String> {
async fn wait_until_ready(container: &ContainerTarget) -> Result<PageState, String> {
let deadline = std::time::Instant::now() + READY_TIMEOUT;
loop {
let state = state(container_id).await;
let state = state(container).await;
if let Some(error) = state.error.clone() {
return Err(error);
}
+3 -3
View File
@@ -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
+8 -7
View File
@@ -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(),
)
+2 -1
View File
@@ -52,7 +52,8 @@ pub async fn run_sso_login(profile: &str) -> Result<(), String> {
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
let status = tokio::process::Command::new("aws")
.args(["sso", "login", "--profile", profile])
// One argv element, so a name starting with `-` is never an option.
.args(["sso", "login", &format!("--profile={profile}")])
.status()
.await
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
+11 -14
View File
@@ -1202,15 +1202,12 @@ pub(crate) struct FetchedFile {
/// file — or the whole *directory tree*, since the type check happens after the
/// read — landed in host RAM twice. This function buffers, so every caller of
/// it must name a ceiling.
///
/// Takes a container id rather than a [`ContainerTarget`] because the file
/// viewer's commands call it with the id they already hold.
pub(crate) async fn fetch_container_file(
container_id: &str,
container: &ContainerTarget,
container_path: &str,
max_bytes: u64,
) -> Result<FetchedFile, String> {
let mut stream = ContainerTarget::docker(container_id).download_archive(container_path);
let mut stream = container.download_archive(container_path);
// A tar member is a 512-byte header plus payload padded to 512. 8 KiB of
// slack past the payload cap guarantees the header and the whole capped
@@ -1297,7 +1294,7 @@ pub async fn read_container_file(
.ok_or_else(|| "Container not running".to_string())?;
let cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES);
let fetched = fetch_container_file(target.id(), &path, cap).await?;
let fetched = fetch_container_file(&target, &path, cap).await?;
Ok(FileContents {
contents_base64: BASE64.encode(&fetched.bytes),
@@ -1451,14 +1448,14 @@ pub(crate) fn not_running_message(action: &str, why: &str) -> String {
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
/// is the first thing to touch the container, so a stopped project fails inside
/// path *validation* and reads like the path was the problem.
///
/// Takes a container id rather than a [`ContainerTarget`] because the file
/// viewer's commands call it with the id they already hold.
pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
pub(crate) async fn require_running(
container: &ContainerTarget,
action: &str,
) -> Result<(), String> {
// An unreachable daemon is an error; an inspect failure (container gone)
// reads as "not running" — `is_running`'s contract, and what this did
// when it inspected the container itself.
let running = ContainerTarget::docker(container_id).is_running().await?;
let running = container.is_running().await?;
if running {
return Ok(());
}
@@ -1532,7 +1529,7 @@ pub async fn download_container_file(
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
require_running(target.id(), "saving a file to the host").await?;
require_running(&target, "saving a file to the host").await?;
let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else {
// Dismissed. Not an error, and deliberately distinguishable from one:
@@ -1887,7 +1884,7 @@ pub async fn upload_files_to_container(
// Both checks before the dialog, so a project that cannot receive files
// says so instead of asking the user to choose some first.
require_running(target.id(), "uploading files").await?;
require_running(&target, "uploading files").await?;
resolve_container_dir(&target, "Upload", &container_dir).await?;
let Some(picked) = pick_files_to_upload(&window).await else {
@@ -2241,7 +2238,7 @@ pub async fn download_container_backup(
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
require_running(target.id(), "backing up").await?;
require_running(&target, "backing up").await?;
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
// Read-only source: `tar -C` it, so absoluteness and `..` are what matter.
@@ -18,6 +18,7 @@ use crate::file_viewer::registry::{
use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates};
use crate::file_viewer::window::open_viewer_window;
use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES};
use crate::host::ContainerTarget;
use crate::models::Project;
use crate::AppState;
@@ -113,13 +114,11 @@ fn project_of(state: &AppState, project_id: &str) -> Result<Project, String> {
}
/// `action` completes "Start the project before …", e.g. "saving this file".
async fn running_container_of(project: &Project, action: &str) -> Result<String, String> {
let container_id = project
.container_id
.clone()
async fn running_container_of(project: &Project, action: &str) -> Result<ContainerTarget, String> {
let container = ContainerTarget::for_project(project)
.ok_or_else(|| not_running_message(action, "files live in its container"))?;
require_running(&container_id, action).await?;
Ok(container_id)
require_running(&container, action).await?;
Ok(container)
}
/// The container of the project a viewer window belongs to, if it is running.
@@ -127,7 +126,7 @@ async fn running_container_for(
state: &AppState,
target: &ViewerTarget,
action: &str,
) -> Result<String, String> {
) -> Result<ContainerTarget, String> {
running_container_of(&project_of(state, &target.project_id)?, action).await
}
@@ -156,17 +155,17 @@ pub async fn open_file_viewer(
) -> Result<(), String> {
require_main(window.label())?;
let project = project_of(&state, &project_id)?;
let container_id = running_container_of(&project, "opening files").await?;
let container = running_container_of(&project, "opening files").await?;
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
let mut candidates = candidate_paths(&path, &mounts)?;
let mut matches = probe_candidates(&container_id, &candidates).await?;
let mut matches = probe_candidates(&container, &candidates).await?;
// Not in the project: try home and /tmp, as a second exec so a project file
// never becomes a chooser because a namesake sits in /tmp.
if matches.is_empty() {
let fallbacks = fallback_candidate_paths(&path);
if !fallbacks.is_empty() {
matches = probe_candidates(&container_id, &fallbacks).await?;
matches = probe_candidates(&container, &fallbacks).await?;
candidates.extend(fallbacks);
}
}
@@ -253,9 +252,9 @@ pub async fn viewer_read_file(
) -> Result<ViewerFile, String> {
let (_label, target) = own_target(&window, &registry)?;
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, &registry)?;
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)]
+525
View File
@@ -0,0 +1,525 @@
//! Commands for host connections (AWS): add, update, remove, identity,
//! sign-in and the permission check.
use std::future::Future;
use tauri::State;
use crate::aws::permissions::{check_permissions, PermissionReport, SdkPermissionApi};
use crate::aws::policies::{policy_json, Tier};
use crate::aws::{caller_identity, sdk_config, sdk_config_for_keys, AwsError, CallerIdentity};
use crate::models::{
validate_connection_name, validate_profile_name, validate_supported_region, AppSettings, AwsAuth,
HostConnection, HostKind,
};
use crate::storage::secure::{self, AwsKeys};
use crate::storage::settings_store::SettingsStore;
use crate::AppState;
fn other(message: impl Into<String>) -> AwsError {
AwsError::Other { message: message.into() }
}
fn check_id(id: &str) -> Result<(), String> {
if secure::is_valid_connection_id(id) {
Ok(())
} else {
Err("Invalid connection id".to_string())
}
}
/// What the identity check is asked to authenticate with.
pub(crate) enum Credentials {
/// A named host profile (the connection itself).
Profile(HostConnection),
/// Keys not yet in the keychain, and the region to call in.
Keys(AwsKeys, String),
}
/// Validate-free core of both add commands: `GetCallerIdentity` first, and
/// only on success keys are stored and the connection saved. An expired SSO
/// session on a profile still saves (the UI then offers "Sign in").
pub(crate) async fn add_connection_with<I, Fut>(
conn: HostConnection,
keys: Option<AwsKeys>,
identity: I,
store_keys: impl Fn(&str, &AwsKeys) -> Result<(), String>,
delete_keys: impl Fn(&str) -> Result<(), String>,
settings: &SettingsStore,
) -> Result<HostConnection, AwsError>
where
I: FnOnce(Credentials) -> Fut,
Fut: Future<Output = Result<CallerIdentity, AwsError>>,
{
let HostKind::Aws { auth, default_region } = &conn.kind;
let credentials = match (&keys, auth) {
(Some(k), AwsAuth::Keys { .. }) => Credentials::Keys(k.clone(), default_region.clone()),
(None, AwsAuth::Profile { .. }) => Credentials::Profile(conn.clone()),
_ => return Err(other("Connection credentials do not match its auth method")),
};
match identity(credentials).await {
Ok(_) => {}
Err(AwsError::SsoExpired { .. }) if keys.is_none() => {}
Err(e) => return Err(e),
}
if let Some(k) = &keys {
store_keys(&conn.id, k).map_err(other)?;
}
if let Err(e) = settings.modify(|all| {
all.hosts.push(conn.clone());
Ok(())
}) {
if keys.is_some() {
let _ = delete_keys(&conn.id);
}
return Err(other(e));
}
Ok(conn)
}
/// Removes the stored connection with this id, however malformed its other
/// fields are (a broken entry must stay removable). Its keychain entry is
/// deleted only for a `Keys` connection whose `keyring_ref` is its own valid id.
pub(crate) fn remove_connection_with(
id: &str,
delete_keys: impl Fn(&str) -> Result<(), String>,
settings: &SettingsStore,
) -> Result<AppSettings, String> {
check_id(id)?;
let existing = settings
.get()
.hosts
.into_iter()
.find(|h| h.id == id)
.ok_or_else(|| "Connection not found".to_string())?;
let HostKind::Aws { auth, .. } = &existing.kind;
if let AwsAuth::Keys { keyring_ref } = auth {
if keyring_ref == id && secure::is_valid_connection_id(keyring_ref) {
delete_keys(keyring_ref)?;
}
}
settings
.modify(|s| {
s.hosts.retain(|h| h.id != id);
Ok(())
})
.map(|(saved, ())| saved)
}
async fn real_identity(c: Credentials) -> Result<CallerIdentity, AwsError> {
let cfg = match c {
Credentials::Profile(conn) => {
let HostKind::Aws { default_region, .. } = &conn.kind;
sdk_config(&conn, default_region).await?
}
Credentials::Keys(keys, region) => sdk_config_for_keys(&keys, &region),
};
caller_identity(&cfg).await
}
fn aws_conn(id: &str, settings: &AppSettings) -> Result<HostConnection, String> {
check_id(id)?;
let conn = settings
.hosts
.iter()
.find(|h| h.id == id)
.cloned()
.ok_or_else(|| "Connection not found".to_string())?;
// Defense in depth: stored values are re-checked before they reach the
// keychain, the SDK or a subprocess.
let HostKind::Aws { auth, default_region } = &conn.kind;
validate_supported_region(default_region)?;
match auth {
AwsAuth::Profile { name } => {
validate_profile_name(name)?;
}
AwsAuth::Keys { keyring_ref } if keyring_ref != id => {
return Err("Connection's keychain reference does not match its id".to_string());
}
AwsAuth::Keys { .. } => {}
}
Ok(conn)
}
fn validated(name: &str, region: &str) -> Result<(String, String), AwsError> {
Ok((
validate_connection_name(name).map_err(other)?,
validate_supported_region(region).map_err(other)?,
))
}
#[tauri::command]
pub async fn add_aws_profile_connection(
name: String,
profile: String,
default_region: String,
state: State<'_, AppState>,
) -> Result<HostConnection, AwsError> {
let (name, default_region) = validated(&name, &default_region)?;
let profile = validate_profile_name(&profile).map_err(other)?;
let conn = HostConnection {
id: uuid::Uuid::new_v4().to_string(),
name,
kind: HostKind::Aws { auth: AwsAuth::Profile { name: profile }, default_region },
};
add_connection_with(
conn,
None,
real_identity,
secure::store_aws_keys,
secure::delete_aws_keys,
&state.settings_store,
)
.await
}
#[tauri::command]
pub async fn add_aws_keys_connection(
name: String,
access_key_id: String,
secret_access_key: String,
session_token: Option<String>,
default_region: String,
state: State<'_, AppState>,
) -> Result<HostConnection, AwsError> {
let (name, default_region) = validated(&name, &default_region)?;
let access_key_id = access_key_id.trim().to_string();
let secret_access_key = secret_access_key.trim().to_string();
let session_token = session_token.map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
if access_key_id.is_empty() || secret_access_key.is_empty() {
return Err(other("Access key ID and secret access key are required"));
}
let id = uuid::Uuid::new_v4().to_string();
let conn = HostConnection {
id: id.clone(),
name,
kind: HostKind::Aws { auth: AwsAuth::Keys { keyring_ref: id }, default_region },
};
let keys = AwsKeys { access_key_id, secret_access_key, session_token };
add_connection_with(
conn,
Some(keys),
real_identity,
secure::store_aws_keys,
secure::delete_aws_keys,
&state.settings_store,
)
.await
}
#[tauri::command]
pub async fn update_aws_connection(
id: String,
name: String,
default_region: String,
state: State<'_, AppState>,
) -> Result<HostConnection, AwsError> {
let (name, region) = validated(&name, &default_region)?;
let (_, updated) = state
.settings_store
.modify(|settings| {
aws_conn(&id, settings)?;
let conn = settings.hosts.iter_mut().find(|h| h.id == id).expect("checked above");
conn.name = name;
let HostKind::Aws { default_region, .. } = &mut conn.kind;
*default_region = region;
Ok(conn.clone())
})
.map_err(other)?;
Ok(updated)
}
#[tauri::command]
pub async fn remove_host_connection(
id: String,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
remove_connection_with(&id, secure::delete_aws_keys, &state.settings_store)
}
#[tauri::command]
pub async fn aws_connection_identity(
id: String,
state: State<'_, AppState>,
) -> Result<CallerIdentity, AwsError> {
let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?;
let HostKind::Aws { default_region, .. } = &conn.kind;
let cfg = sdk_config(&conn, default_region).await?;
caller_identity(&cfg).await
}
#[tauri::command]
pub async fn aws_connection_sso_login(
id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let conn = aws_conn(&id, &state.settings_store.get())?;
let HostKind::Aws { auth, .. } = &conn.kind;
let AwsAuth::Profile { name } = auth else {
return Err("Only profile connections use SSO".to_string());
};
let name = validate_profile_name(name)?;
super::aws_commands::run_sso_login(&name).await
}
#[tauri::command]
pub async fn check_aws_permissions(
id: String,
region: Option<String>,
state: State<'_, AppState>,
) -> Result<PermissionReport, AwsError> {
let conn = aws_conn(&id, &state.settings_store.get()).map_err(other)?;
let HostKind::Aws { default_region, .. } = &conn.kind;
let region = match region {
Some(r) => validate_supported_region(&r).map_err(other)?,
None => default_region.clone(),
};
let cfg = sdk_config(&conn, &region).await?;
let identity = caller_identity(&cfg).await?;
let api = SdkPermissionApi::new(&cfg);
check_permissions(&api, &identity, &region).await
}
#[tauri::command]
pub fn aws_policy_document(tier: Tier) -> String {
policy_json(tier).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn profile_conn() -> HostConnection {
HostConnection {
id: "c1".into(),
name: "Prod".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "dev".into() },
default_region: "us-west-2".into(),
},
}
}
fn keys_conn() -> HostConnection {
HostConnection {
id: "c2".into(),
name: "K".into(),
kind: HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "c2".into() },
default_region: "us-west-2".into(),
},
}
}
fn keys() -> AwsKeys {
AwsKeys { access_key_id: "AKIA".into(), secret_access_key: "s".into(), session_token: None }
}
fn ident() -> CallerIdentity {
CallerIdentity { account: "1".into(), arn: "arn:aws:iam::1:user/u".into(), user_id: "U".into() }
}
fn store_in(dir: &tempfile::TempDir) -> SettingsStore {
SettingsStore::at_path(dir.path().join("settings.json"))
}
#[tokio::test]
async fn adding_saves_nothing_when_identity_fails() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let calls = RefCell::new(Vec::<String>::new());
let err = add_connection_with(
keys_conn(),
Some(keys()),
|_| async { Err(AwsError::InvalidCredentials { message: "bad".into() }) },
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
&settings,
)
.await
.unwrap_err();
assert!(matches!(err, AwsError::InvalidCredentials { .. }));
assert!(settings.get().hosts.is_empty());
assert!(!dir.path().join("settings.json").exists());
assert!(calls.borrow().is_empty(), "{:?}", calls.borrow());
}
#[tokio::test]
async fn an_sso_expired_profile_is_still_saved() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let saved = add_connection_with(
profile_conn(),
None,
|_| async { Err(AwsError::SsoExpired { profile: "dev".into() }) },
|_, _| Ok(()),
|_| Ok(()),
&settings,
)
.await
.unwrap();
assert_eq!(settings.get().hosts, vec![saved]);
}
#[tokio::test]
async fn a_successful_keys_add_stores_keys_then_settings() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let calls = RefCell::new(Vec::<String>::new());
let saved = add_connection_with(
keys_conn(),
Some(keys()),
|c| async move {
assert!(matches!(c, Credentials::Keys(..)));
Ok(ident())
},
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
&settings,
)
.await
.unwrap();
assert_eq!(*calls.borrow(), vec!["store c2".to_string()]);
assert_eq!(settings.get().hosts, vec![saved]);
}
#[tokio::test]
async fn keys_are_removed_if_saving_settings_fails() {
let dir = tempfile::tempdir().unwrap();
// The parent directory does not exist, so saving fails.
let settings = SettingsStore::at_path(dir.path().join("missing/settings.json"));
let calls = RefCell::new(Vec::<String>::new());
let err = add_connection_with(
keys_conn(),
Some(keys()),
|_| async { Ok(ident()) },
|id, _| { calls.borrow_mut().push(format!("store {id}")); Ok(()) },
|id| { calls.borrow_mut().push(format!("delete {id}")); Ok(()) },
&settings,
)
.await
.unwrap_err();
assert!(matches!(err, AwsError::Other { .. }));
assert_eq!(*calls.borrow(), vec!["store c2".to_string(), "delete c2".to_string()]);
assert!(settings.get().hosts.is_empty(), "no unsaved connection in memory");
}
#[tokio::test]
async fn a_non_sso_identity_error_on_a_profile_saves_nothing() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let err = add_connection_with(
profile_conn(),
None,
|_| async { Err(AwsError::ProfileNotFound { profile: "dev".into() }) },
|_, _| Ok(()),
|_| Ok(()),
&settings,
)
.await
.unwrap_err();
assert!(matches!(err, AwsError::ProfileNotFound { .. }));
assert!(settings.get().hosts.is_empty());
assert!(!dir.path().join("settings.json").exists());
}
#[tokio::test]
async fn a_failing_store_keys_saves_nothing() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let deletes = RefCell::new(0);
let err = add_connection_with(
keys_conn(),
Some(keys()),
|_| async { Ok(ident()) },
|_, _| Err("keychain locked".to_string()),
|_| { *deletes.borrow_mut() += 1; Ok(()) },
&settings,
)
.await
.unwrap_err();
assert!(matches!(err, AwsError::Other { .. }));
assert!(settings.get().hosts.is_empty());
assert!(!dir.path().join("settings.json").exists());
}
#[test]
fn a_keys_connection_whose_keyring_ref_differs_from_its_id_is_refused() {
let mut s = AppSettings::default();
let mut c = keys_conn();
c.kind = HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "other".into() },
default_region: "us-west-2".into(),
};
s.hosts.push(c);
assert!(aws_conn("c2", &s).is_err());
s.hosts = vec![keys_conn()];
assert!(aws_conn("c2", &s).is_ok());
}
#[test]
fn stored_regions_and_profiles_are_revalidated() {
let with = |profile: &str, region: &str| {
let mut c = profile_conn();
c.kind = HostKind::Aws {
auth: AwsAuth::Profile { name: profile.into() },
default_region: region.into(),
};
let mut s = AppSettings::default();
s.hosts.push(c);
s
};
assert!(aws_conn("c1", &with("dev", "us-west-2")).is_ok());
assert!(aws_conn("c1", &with("dev", "not a region")).is_err());
assert!(aws_conn("c1", &with("a b", "us-west-2")).is_err());
}
#[test]
fn a_connection_with_an_invalid_stored_region_can_still_be_removed() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let mut c = keys_conn();
c.kind = HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "c2".into() },
default_region: "not a region".into(),
};
settings.update(AppSettings { hosts: vec![c, profile_conn()], ..AppSettings::default() }).unwrap();
let deleted = RefCell::new(Vec::<String>::new());
let after = remove_connection_with(
"c2",
|id| { deleted.borrow_mut().push(id.to_string()); Ok(()) },
&settings,
)
.unwrap();
assert_eq!(after.hosts, vec![profile_conn()]);
assert_eq!(settings.get().hosts, vec![profile_conn()]);
assert_eq!(*deleted.borrow(), vec!["c2".to_string()]);
}
#[test]
fn removal_leaves_the_keychain_alone_when_keyring_ref_is_not_the_id() {
let dir = tempfile::tempdir().unwrap();
let settings = store_in(&dir);
let mut c = keys_conn();
c.kind = HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "someone-else".into() },
default_region: "us-west-2".into(),
};
settings.update(AppSettings { hosts: vec![c], ..AppSettings::default() }).unwrap();
remove_connection_with("c2", |_| panic!("must not touch the keychain"), &settings).unwrap();
assert!(settings.get().hosts.is_empty());
assert!(remove_connection_with("c2", |_| Ok(()), &settings).is_err());
assert!(remove_connection_with("bad id!", |_| Ok(()), &settings).is_err());
}
#[test]
fn china_and_govcloud_regions_are_refused_for_connections() {
for r in ["cn-north-1", "us-gov-west-1"] {
let e = validated("Prod", r).unwrap_err();
assert!(matches!(&e, AwsError::Other { message } if message == "China and GovCloud regions aren't supported yet."), "{e:?}");
}
assert!(validated("Prod", "eu-west-1").is_ok());
}
}
@@ -173,10 +173,7 @@ pub(crate) mod ops {
/// directories, so an id from outside (an import) must be the shape the
/// commands mint: a UUID-like `[A-Za-z0-9-]{1,64}`.
pub fn validate_id(id: &str) -> Result<(), String> {
let ok = !id.is_empty()
&& id.len() <= 64
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
if ok {
if crate::storage::secure::is_valid_connection_id(id) {
Ok(())
} else {
Err("An account or marketplace id is malformed.".to_string())
@@ -945,10 +942,10 @@ pub async fn apply_marketplace_now(
let mut results = Vec::new();
for p in projects {
let running = match ContainerTarget::for_project(&p) {
Some(target) => target.is_running().await.unwrap_or(false),
None => false,
Some(target) if target.is_running().await.unwrap_or(false) => Some(target),
_ => None,
};
if !running {
let Some(target) = running else {
if project_id.is_some() {
return Err(format!(
"\"{}\" is not running. Its marketplace items are applied when it starts.",
@@ -956,9 +953,8 @@ pub async fn apply_marketplace_now(
));
}
continue;
}
let cid = p.container_id.clone().unwrap_or_default();
let report = mk::sync_project(&state.marketplace, &settings, &p, &cid).await;
};
let report = mk::sync_project(&state.marketplace, &settings, &p, &target).await;
let _ = app_handle.emit(
mk::SYNC_FINISHED_EVENT,
serde_json::json!({ "project_id": p.id, "report": report }),
@@ -55,7 +55,8 @@
use tauri::State;
use crate::commands::project_commands::{create_container_for_project, emit_progress};
use crate::commands::project_commands::emit_progress;
use crate::docker::{create_container_for_project, default_docker_socket};
use crate::docker;
use crate::docker::migration::{self as mig, Recovery};
use crate::models::{
@@ -1810,14 +1811,6 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
migration_store::clear_ownerless_for_project(project_id);
}
fn default_docker_socket() -> String {
if cfg!(target_os = "windows") {
"//./pipe/docker_engine".to_string()
} else {
"/var/run/docker.sock".to_string()
}
}
fn human_bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut v = n as f64;
+1
View File
@@ -6,6 +6,7 @@ pub mod file_commands;
pub mod file_viewer_commands;
pub mod gateway_commands;
pub mod help_commands;
pub mod host_commands;
pub mod inspect_commands;
pub mod install_helper_commands;
pub mod marketplace_commands;
+47 -220
View File
@@ -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 {
@@ -78,6 +78,13 @@ pub(crate) fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &Ap
incoming.global_marketplace_installs = stored.global_marketplace_installs.clone();
}
/// Host connections change only through `host_commands` (which validates
/// them and owns their keychain entries) and settings import, never through
/// a settings save the frontend posts back.
pub(crate) fn restore_host_fields(incoming: &mut AppSettings, stored: &AppSettings) {
incoming.hosts = stored.hosts.clone();
}
#[tauri::command]
pub async fn update_settings(
mut settings: AppSettings,
@@ -87,6 +94,7 @@ pub async fn update_settings(
validate_settings_update(&before, &settings)?;
restore_marketplace_fields(&mut settings, &before);
restore_host_fields(&mut settings, &before);
let saved = state.settings_store.update(settings)?;
@@ -466,4 +474,27 @@ mod tests {
"the edit the save was for still applies"
);
}
#[test]
fn update_settings_cannot_change_hosts() {
use crate::models::{AwsAuth, HostConnection, HostKind};
let conn = |id: &str, region: &str| HostConnection {
id: id.into(),
name: "Prod".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "dev".into() },
default_region: region.into(),
},
};
let mut stored = AppSettings::default();
stored.hosts.push(conn("c1", "us-west-2"));
let mut incoming = AppSettings::default();
incoming.hosts = vec![conn("c1", "not a region"), conn("evil", "us-east-1")];
incoming.auto_check_updates = false;
restore_host_fields(&mut incoming, &stored);
assert_eq!(incoming.hosts, stored.hosts);
assert!(!incoming.auto_check_updates);
}
}
@@ -43,6 +43,7 @@ use zeroize::Zeroizing;
use std::collections::BTreeMap;
use crate::models::host_connection::{AwsAuth, HostConnection, HostKind};
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
use crate::models::{
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
@@ -138,6 +139,7 @@ fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSec
&settings.marketplace_accounts,
secure::get_marketplace_token,
),
aws_connection_keys: exported_aws_connection_keys(&settings.hosts, secure::get_aws_keys),
};
(settings, secrets)
@@ -184,6 +186,107 @@ fn restore_marketplace_tokens(
warnings
}
/// The stored keys of every `Keys` host connection, by connection id, as the
/// `AwsKeys` JSON. A `Profile` connection holds none. A missing or unreadable
/// entry is left out, like the other keychain secrets above.
fn exported_aws_connection_keys(
hosts: &[HostConnection],
get: impl Fn(&str) -> Result<Option<secure::AwsKeys>, String>,
) -> BTreeMap<String, String> {
hosts
.iter()
.filter(|h| matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. }))
.filter_map(|h| {
let keys = get(&h.id).unwrap_or_default()?;
Some((h.id.clone(), serde_json::to_string(&keys).ok()?))
})
.collect()
}
/// Write each imported set of AWS keys to the keychain, returning a warning
/// (naming the connection id, never the value) for each that could not be.
fn restore_aws_connection_keys(
keys: &BTreeMap<String, String>,
mut store: impl FnMut(&str, &secure::AwsKeys) -> Result<(), String>,
) -> Vec<String> {
let mut warnings = Vec::new();
for (id, json) in keys {
if json.trim().is_empty() {
continue;
}
let Ok(parsed) = serde_json::from_str::<secure::AwsKeys>(json) else {
log::warn!("Settings import: the AWS keys of connection {} are malformed", id);
warnings.push(format!(
"The AWS access keys of connection {} in the file are malformed; enter them again.",
id
));
continue;
};
if let Err(e) = store(id, &parsed) {
log::warn!(
"Settings import: could not restore the AWS keys of connection {}: {}",
id,
e
);
warnings.push(format!(
"Could not restore the AWS access keys of connection {} ({}); enter them again.",
id, e
));
}
}
warnings
}
/// Checks the imported host connections with the rules the connection
/// commands use, before anything is written; refuses the import (like
/// `validate_imported_marketplace_state`) rather than applying a bad entry.
/// A `Keys` connection's `keyring_ref` must be its own (valid) id, and keys
/// may only be carried for a `Keys` connection in the file.
fn validate_imported_hosts(
settings: &mut AppSettings,
keys: &BTreeMap<String, String>,
) -> Result<(), String> {
use crate::models::host_connection::{
validate_connection_name, validate_profile_name, validate_supported_region,
};
let wrap = |e: String| format!("The file's host connections were refused: {e}");
let mut seen = std::collections::HashSet::new();
for h in &mut settings.hosts {
if !secure::is_valid_connection_id(&h.id) {
return Err(wrap("a connection has an invalid id.".into()));
}
if !seen.insert(h.id.clone()) {
return Err(wrap("a connection appears twice.".into()));
}
h.name = validate_connection_name(&h.name).map_err(wrap)?;
let HostKind::Aws { auth, default_region } = &h.kind;
validate_supported_region(default_region).map_err(wrap)?;
match auth {
AwsAuth::Profile { name } => {
validate_profile_name(name).map_err(wrap)?;
}
AwsAuth::Keys { keyring_ref } => {
if !secure::is_valid_connection_id(keyring_ref) || *keyring_ref != h.id {
return Err(wrap(format!(
"the connection \"{}\" has an invalid key reference.",
h.name
)));
}
}
}
}
for id in keys.keys() {
let is_keys_connection = settings.hosts.iter().any(|h| {
h.id == *id && matches!(&h.kind, HostKind::Aws { auth: AwsAuth::Keys { .. }, .. })
});
if !is_keys_connection {
return Err(wrap("access keys are present for an unknown connection.".into()));
}
}
Ok(())
}
/// Export the current global settings and secrets to a password-encrypted
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
/// deliberately distinguishable from one so the frontend shows nothing
@@ -260,7 +363,8 @@ pub async fn preview_settings_import(
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
let preview = SettingsImportPreview::from_payload(&payload);
let local_hosts = state.settings_store.get().hosts;
let preview = SettingsImportPreview::from_payload_against(&payload, &local_hosts, secure::aws_keys_entry_state);
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
path,
@@ -376,6 +480,9 @@ pub async fn apply_settings_import(
&marketplace_tokens,
)?;
let aws_keys = payload.secrets.aws_connection_keys;
validate_imported_hosts(&mut settings, &aws_keys)?;
let mut secret_restore_warnings = Vec::new();
let mut gateway_secret_changed = false;
@@ -423,24 +530,20 @@ pub async fn apply_settings_import(
secure::store_marketplace_token,
));
let imported_marketplace = (
settings.marketplace_accounts.clone(),
settings.marketplaces.clone(),
settings.global_marketplace_installs.clone(),
);
secret_restore_warnings.extend(restore_aws_connection_keys(
&aws_keys,
secure::store_aws_keys,
));
let imported_owned = StoreOwned::of(&settings);
let saved =
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
// `update_settings` keeps marketplace state store-owned. An import is the
// one caller entitled to replace it wholesale.
let saved = {
let mut s = saved;
(
s.marketplace_accounts,
s.marketplaces,
s.global_marketplace_installs,
) = imported_marketplace;
state.settings_store.update(s)?
};
// `update_settings` keeps marketplace state and host connections
// store-owned. An import is the one caller entitled to replace them
// wholesale.
let saved = state
.settings_store
.update(imported_owned.applied_to(saved))?;
// Caches of marketplaces the import dropped are dead weight now, and
// the pins must match the imported installs.
use crate::commands::marketplace_commands as mc;
@@ -481,6 +584,34 @@ pub async fn apply_settings_import(
})
}
/// The settings `update_settings` keeps store-owned (marketplace state and
/// host connections), which an import re-applies after that call.
struct StoreOwned {
marketplace_accounts: Vec<crate::models::marketplace::MarketplaceAccount>,
marketplaces: Vec<crate::models::marketplace::Marketplace>,
global_marketplace_installs: Vec<crate::models::marketplace::MarketplaceInstall>,
hosts: Vec<crate::models::HostConnection>,
}
impl StoreOwned {
fn of(s: &AppSettings) -> Self {
Self {
marketplace_accounts: s.marketplace_accounts.clone(),
marketplaces: s.marketplaces.clone(),
global_marketplace_installs: s.global_marketplace_installs.clone(),
hosts: s.hosts.clone(),
}
}
fn applied_to(self, mut s: AppSettings) -> AppSettings {
s.marketplace_accounts = self.marketplace_accounts;
s.marketplaces = self.marketplaces;
s.global_marketplace_installs = self.global_marketplace_installs;
s.hosts = self.hosts;
s
}
}
fn non_blank(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
@@ -827,4 +958,141 @@ mod tests {
assert_eq!(warnings.len(), 1);
assert!(!warnings[0].contains("test-token-not-real"));
}
fn keys_host(id: &str) -> HostConnection {
HostConnection {
id: id.into(),
name: "Prod".into(),
kind: HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: id.into() },
default_region: "us-west-2".into(),
},
}
}
fn profile_host(id: &str) -> HostConnection {
HostConnection {
id: id.into(),
name: "Dev".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "default".into() },
default_region: "us-west-2".into(),
},
}
}
fn test_keys() -> secure::AwsKeys {
secure::AwsKeys {
access_key_id: "AKIATESTNOTREAL".into(),
secret_access_key: "test-secret-not-real".into(),
session_token: None,
}
}
#[test]
fn aws_connection_keys_round_trip_through_export() {
let hosts = vec![keys_host("k1"), profile_host("p1")];
let exported = exported_aws_connection_keys(&hosts, |id| match id {
"k1" => Ok(Some(test_keys())),
other => panic!("only a Keys connection is read, got {other}"),
});
assert_eq!(exported.keys().collect::<Vec<_>>(), vec!["k1"]);
assert_eq!(serde_json::from_str::<secure::AwsKeys>(&exported["k1"]).unwrap(), test_keys());
let mut stored = Vec::new();
let warnings = restore_aws_connection_keys(&exported, |id, keys| {
stored.push((id.to_string(), keys.clone()));
Ok(())
});
assert!(warnings.is_empty());
assert_eq!(stored, vec![("k1".to_string(), test_keys())]);
}
#[test]
fn aws_connection_keys_restore_failures_name_the_id_not_the_value() {
let keys = BTreeMap::from([
("bad".to_string(), "not json test-secret-not-real".to_string()),
("locked".to_string(), serde_json::to_string(&test_keys()).unwrap()),
]);
let warnings = restore_aws_connection_keys(&keys, |_, _| Err("keychain locked".into()));
assert_eq!(warnings.len(), 2);
assert!(warnings[0].contains("bad"));
assert!(warnings[1].contains("locked"));
assert!(warnings.iter().all(|w| !w.contains("test-secret-not-real")));
}
#[test]
fn imported_hosts_are_validated() {
let check = |hosts: Vec<HostConnection>, keys: BTreeMap<String, String>| {
let mut s = AppSettings::default();
s.hosts = hosts;
validate_imported_hosts(&mut s, &keys)
};
let none = BTreeMap::new();
assert!(check(vec![keys_host("k1"), profile_host("p1")], none.clone()).is_ok());
assert!(check(vec![keys_host("k1")], BTreeMap::from([("k1".into(), "{}".into())])).is_ok());
let mut bad_name = profile_host("p1");
bad_name.name = " ".into();
assert!(check(vec![bad_name], none.clone()).is_err());
let mut bad_region = profile_host("p1");
bad_region.kind = HostKind::Aws {
auth: AwsAuth::Profile { name: "default".into() },
default_region: "nowhere".into(),
};
assert!(check(vec![bad_region], none.clone()).is_err());
let mut cn = profile_host("p1");
cn.kind = HostKind::Aws {
auth: AwsAuth::Profile { name: "default".into() },
default_region: "cn-north-1".into(),
};
let err = check(vec![cn], none.clone()).unwrap_err();
assert!(err.contains("China and GovCloud regions aren't supported yet."), "{err}");
let mut bad_profile = profile_host("p1");
bad_profile.kind = HostKind::Aws {
auth: AwsAuth::Profile { name: "a b".into() },
default_region: "us-west-2".into(),
};
assert!(check(vec![bad_profile], none.clone()).is_err());
let mut other_ref = keys_host("k1");
other_ref.kind = HostKind::Aws {
auth: AwsAuth::Keys { keyring_ref: "someone-else".into() },
default_region: "us-west-2".into(),
};
assert!(check(vec![other_ref], none.clone()).is_err());
assert!(check(vec![profile_host("../x")], none.clone()).is_err());
assert!(check(vec![profile_host("p1"), profile_host("p1")], none.clone()).is_err());
// Keys carried for a connection that is not a Keys connection.
assert!(check(vec![profile_host("p1")], BTreeMap::from([("p1".into(), "{}".into())])).is_err());
assert!(check(vec![], BTreeMap::from([("ghost".into(), "{}".into())])).is_err());
}
#[test]
fn imported_hosts_survive_apply() {
use crate::models::{AwsAuth, HostConnection, HostKind};
let host = HostConnection {
id: "h1".into(),
name: "Imported".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "dev".into() },
default_region: "us-west-2".into(),
},
};
let stored = AppSettings::default();
let mut imported = AppSettings::default();
imported.hosts.push(host.clone());
let owned = StoreOwned::of(&imported);
// What `update_settings` does to the incoming settings.
let mut after_update = imported.clone();
crate::commands::settings_commands::restore_host_fields(&mut after_update, &stored);
assert!(after_update.hosts.is_empty(), "update_settings drops hosts");
assert_eq!(owned.applied_to(after_update).hosts, vec![host]);
}
}
+28 -96
View File
@@ -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() {
+10
View File
@@ -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()
}
}
+49 -5
View File
@@ -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::*;
+114
View File
@@ -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);
}
}
+107 -76
View File
@@ -206,31 +206,8 @@ pub async fn upload_host_file_with_ids(
let mtime = now_epoch_secs();
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
// The caller resolved this path (`resolve_host_read_path`); opening it
// is a second trip through the same directories, so the descriptor is
// checked against the path that was validated before its bytes are
// packed into anything. Two paths reach here: the terminal's drop
// target, and the Files pane's upload via `upload_host_file_with_ids`.
// Between them they are how host bytes enter a container.
let file = std::fs::File::open(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
crate::commands::file_commands::verify_opened_path(
&file,
std::path::Path::new(&host_path),
)?;
let mut data = Vec::new();
std::io::Read::read_to_end(
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
&mut data,
)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
if data.len() as u64 > MAX_DROP_BYTES {
return Err(format!(
"File too large to upload (limit {} MB)",
MAX_DROP_BYTES / (1024 * 1024)
));
}
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
let data = read_host_file_for_upload(&host_path)?;
build_single_file_tar(&dest_for_blk, &data[..], HOST_FILE_UPLOAD_MODE, uid, gid, mtime)
})
.await
.map_err(|e| format!("Upload task panicked: {}", e))??;
@@ -251,13 +228,46 @@ pub async fn upload_host_file_with_ids(
Ok(container_join(dest_dir, &dest_name))
}
/// The mode a host file lands with in the container, on every host.
pub(crate) const HOST_FILE_UPLOAD_MODE: u32 = 0o644;
/// Read a host file that is about to be copied into a container. Blocking:
/// call it from `spawn_blocking`. Shared by every host's upload so the checks
/// cannot drift apart.
///
/// The caller resolved this path (`resolve_host_read_path`); opening it is a
/// second trip through the same directories, so the descriptor is checked
/// against the path that was validated before its bytes are packed into
/// anything. Two paths reach here: the terminal's drop target, and the Files
/// pane's upload via `upload_host_file_with_ids`. Between them they are how
/// host bytes enter a container. [`MAX_DROP_BYTES`] is applied to what the
/// open descriptor actually yields, not to an earlier `stat`.
pub(crate) fn read_host_file_for_upload(host_path: &str) -> Result<Vec<u8>, String> {
let file = std::fs::File::open(host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
crate::commands::file_commands::verify_opened_path(&file, std::path::Path::new(host_path))?;
let mut data = Vec::new();
std::io::Read::read_to_end(
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
&mut data,
)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
if data.len() as u64 > MAX_DROP_BYTES {
return Err(format!(
"File too large to upload (limit {} MB)",
MAX_DROP_BYTES / (1024 * 1024)
));
}
Ok(data)
}
/// Join a container directory to a name that may itself carry separators.
///
/// Only the *reported* path — the bytes have already landed by the time this is
/// called — but that path is what the terminal echoes and what the Files pane
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
fn container_join(dir: &str, name: &str) -> String {
pub(crate) fn container_join(dir: &str, name: &str) -> String {
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
}
@@ -362,12 +372,26 @@ pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
let (out, _) = exec_oneshot_limited(
container_id,
"claude",
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
256,
user_ids_cmd(),
USER_IDS_OUTPUT_LIMIT,
)
.await
.unwrap_or_default();
parse_user_ids(&out)
}
/// The command [`container_user_ids`] runs (as `claude`, under
/// [`USER_IDS_OUTPUT_LIMIT`]); every host asks the same question.
pub(crate) fn user_ids_cmd() -> Vec<String> {
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()]
}
/// Output ceiling for the uid probe: two short numbers.
pub(crate) const USER_IDS_OUTPUT_LIMIT: usize = 256;
/// `id -u; id -g` output -> `(uid, gid)`, falling back to the image's
/// build-time `(1000, 1000)`.
pub(crate) fn parse_user_ids(out: &str) -> (u64, u64) {
let mut ids = out.lines().filter_map(|l| l.trim().parse::<u64>().ok());
match (ids.next(), ids.next()) {
(Some(uid), Some(gid)) => (uid, gid),
@@ -495,21 +519,54 @@ pub async fn exec_oneshot_as(
/// fail". `stdout_ranges` indexes the parts of it that came from stdout, so a
/// caller that is *parsing* output can have just that without the buffer being
/// held twice.
struct OneshotOutput {
pub(crate) struct OneshotOutput {
combined: Vec<u8>,
stdout_ranges: Vec<(usize, usize)>,
exit_code: i64,
pub(crate) exit_code: i64,
}
/// The refusal for output past `limit`, starting with [`OUTPUT_LIMIT_MARKER`].
pub(crate) fn output_limit_error(limit: usize) -> String {
format!(
"{}: Command output exceeded {} bytes and was abandoned",
OUTPUT_LIMIT_MARKER, limit
)
}
impl OneshotOutput {
pub(crate) fn new() -> Self {
Self {
combined: Vec::new(),
stdout_ranges: Vec::new(),
exit_code: 0,
}
}
/// Append one chunk in arrival order, or refuse it (appending nothing)
/// once the combined output would pass `limit`. The caller must stop
/// reading on an error rather than truncate silently: every caller parses
/// this output, and a half-read manifest or JSON array is worse than an
/// error.
pub(crate) fn push(&mut self, from_stdout: bool, bytes: &[u8], limit: usize) -> Result<(), String> {
match push_capped(&mut self.combined, bytes, limit) {
Some(range) => {
if from_stdout {
self.stdout_ranges.push(range);
}
Ok(())
}
None => Err(output_limit_error(limit)),
}
}
/// Everything the command printed, in the order it printed it.
fn text(&self) -> String {
pub(crate) fn text(&self) -> String {
String::from_utf8_lossy(&self.combined).into_owned()
}
/// stdout alone — for callers that parse it, where a diagnostic spliced in
/// mid-record is a parse error at best.
fn stdout(&self) -> String {
pub(crate) fn stdout(&self) -> String {
let mut out = Vec::with_capacity(self.combined.len());
for (start, end) in &self.stdout_ranges {
out.extend_from_slice(&self.combined[*start..*end]);
@@ -518,7 +575,7 @@ impl OneshotOutput {
}
/// stderr alone — the complement of [`Self::stdout`], i.e. the diagnostics.
fn stderr(&self) -> String {
pub(crate) fn stderr(&self) -> String {
let mut out = Vec::with_capacity(self.combined.len());
let mut cursor = 0usize;
for (start, end) in &self.stdout_ranges {
@@ -587,8 +644,7 @@ async fn exec_oneshot_raw(
.await
.map_err(|e| format!("Failed to start exec: {}", e))?;
let mut combined: Vec<u8> = Vec::new();
let mut stdout_ranges: Vec<(usize, usize)> = Vec::new();
let mut out = OneshotOutput::new();
match result {
StartExecResults::Attached { mut output, .. } => {
while let Some(msg) = output.next().await {
@@ -596,23 +652,9 @@ async fn exec_oneshot_raw(
Ok(data) => {
let from_stdout = matches!(data, LogOutput::StdOut { .. });
let bytes = data.into_bytes();
match push_capped(&mut combined, &bytes, limit) {
Some(range) => {
if from_stdout {
stdout_ranges.push(range);
}
}
// Stop reading rather than truncate silently: every
// caller parses this output, and a half-read
// manifest or JSON array is worse than an error.
// Dropping `output` kills the exec's stream.
None => {
return Err(format!(
"{}: Command output exceeded {} bytes and was abandoned",
OUTPUT_LIMIT_MARKER, limit
))
}
}
// Over the limit: returning drops `output`, which
// kills the exec's stream.
out.push(from_stdout, &bytes, limit)?;
}
Err(e) => return Err(format!("Exec output error: {}", e)),
}
@@ -623,13 +665,8 @@ async fn exec_oneshot_raw(
// The output stream draining doesn't strictly guarantee inspect_exec has the
// final exit_code populated yet, so poll until the exec reports finished.
let exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
Ok(OneshotOutput {
combined,
stdout_ranges,
exit_code,
})
out.exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
Ok(out)
}
/// Turn "the exit code could not be determined" into an error rather than a 0.
@@ -662,7 +699,7 @@ fn require_exit_code(code: Option<i64>) -> Result<i64, String> {
/// spurious "the rename failed" and a real one.
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
let docker = get_docker().ok()?;
for _ in 0..200 {
for _ in 0..EXEC_EXIT_POLLS {
match docker.inspect_exec(exec_id).await {
Ok(info) => {
if info.running != Some(true) {
@@ -681,30 +718,27 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
}
Err(_) => return None,
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
tokio::time::sleep(EXEC_EXIT_POLL_INTERVAL).await;
}
None
}
/// [`wait_for_exec_exit`]'s budget: polls, and the pause between them. Every
/// host's `wait_exec_exit` uses the same numbers.
pub(crate) const EXEC_EXIT_POLLS: usize = 200;
pub(crate) const EXEC_EXIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
#[cfg(test)]
mod tests {
use super::*;
/// The frames a demultiplexed exec hands back, as `(is_stdout, bytes)`.
fn collect(frames: &[(bool, &[u8])]) -> OneshotOutput {
let mut combined = Vec::new();
let mut stdout_ranges = Vec::new();
let mut out = OneshotOutput::new();
for (from_stdout, bytes) in frames {
let range = push_capped(&mut combined, bytes, usize::MAX).unwrap();
if *from_stdout {
stdout_ranges.push(range);
}
}
OneshotOutput {
combined,
stdout_ranges,
exit_code: 0,
out.push(*from_stdout, bytes, usize::MAX).unwrap();
}
out
}
#[test]
@@ -763,10 +797,7 @@ mod tests {
// The marker is what lets `list_container_files` say "too many entries"
// instead, which is the thing that actually happened.
assert!(!OUTPUT_LIMIT_MARKER.is_empty());
let refusal = format!(
"{}: Command output exceeded {} bytes and was abandoned",
OUTPUT_LIMIT_MARKER, MAX_ONESHOT_OUTPUT
);
let refusal = output_limit_error(MAX_ONESHOT_OUTPUT);
assert!(refusal.starts_with(OUTPUT_LIMIT_MARKER));
}
+86
View File
@@ -15,8 +15,16 @@ const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runn
const OSC52_CLIPBOARD: &str = include_str!("../../../../container/osc52-clipboard");
const AUDIO_SHIM: &str = include_str!("../../../../container/audio-shim");
const SSO_REFRESH: &str = include_str!("../../../../container/triple-c-sso-refresh");
const TRIPLE_C_OPEN: &str = include_str!("../../../../container/triple-c-open");
const PLAYWRIGHT_HEAL: &str = include_str!("../../../../container/triple-c-playwright-heal");
static MISSION_CONTROL_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/mission-control");
static SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/skills");
// The agent crate's sources only — never the whole crate directory, which can
// hold a multi-hundred-megabyte `target/` from a local build.
const AGENT_CARGO_TOML: &str = include_str!("../../../../container/triple-c-agent/Cargo.toml");
const AGENT_CARGO_LOCK: &str = include_str!("../../../../container/triple-c-agent/Cargo.lock");
static AGENT_SRC_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/triple-c-agent/src");
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
let docker = get_docker()?;
@@ -137,10 +145,28 @@ where
let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?;
// The Dockerfile builds `triple-c-agent` in a stage pinned to
// `$BUILDPLATFORM` and cross-compiles for `$TARGETARCH`, so CI's arm64
// image compiles natively instead of under QEMU. BuildKit predefines both;
// the classic builder this API call uses does not, and an empty
// `--platform=` fails the build outright (verified, Engine 29.9). So pass
// them, describing the daemon's own platform: a local build is always
// native.
let arch = docker
.version()
.await
.ok()
.and_then(|v| v.arch)
.filter(|a| !a.is_empty())
.unwrap_or_else(|| native_docker_arch().to_string());
let build_platform = format!("linux/{}", arch);
let buildargs = local_build_args(&build_platform, &arch);
let options = BuildImageOptions {
t: full_name.as_str(),
rm: true,
forcerm: true,
buildargs,
..Default::default()
};
@@ -191,6 +217,20 @@ fn append_embedded_dir(
Ok(())
}
/// Docker's name for this machine's architecture, for when the daemon does
/// not say.
fn native_docker_arch() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "amd64",
other => other,
}
}
fn local_build_args<'a>(build_platform: &'a str, arch: &'a str) -> HashMap<&'a str, &'a str> {
HashMap::from([("BUILDPLATFORM", build_platform), ("TARGETARCH", arch)])
}
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
let mut buf = Vec::new();
{
@@ -203,8 +243,14 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
append_file_to_archive(&mut archive, "osc52-clipboard", OSC52_CLIPBOARD.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "audio-shim", AUDIO_SHIM.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "triple-c-sso-refresh", SSO_REFRESH.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "triple-c-open", TRIPLE_C_OPEN.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "triple-c-playwright-heal", PLAYWRIGHT_HEAL.as_bytes(), 0o755)?;
append_embedded_dir(&mut archive, &MISSION_CONTROL_DIR, "mission-control")?;
append_embedded_dir(&mut archive, &SKILLS_DIR, "skills")?;
append_file_to_archive(&mut archive, "triple-c-agent/Cargo.toml", AGENT_CARGO_TOML.as_bytes(), 0o644)?;
append_file_to_archive(&mut archive, "triple-c-agent/Cargo.lock", AGENT_CARGO_LOCK.as_bytes(), 0o644)?;
append_embedded_dir(&mut archive, &AGENT_SRC_DIR, "triple-c-agent/src")?;
archive.finish()?;
}
@@ -213,6 +259,46 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
Ok(buf)
}
#[cfg(test)]
mod build_context_tests {
use super::{create_build_context, DOCKERFILE};
/// The local build (`ImageSource::LocalBuild`) ships the Dockerfile with a
/// hand-assembled context, and nothing else checks the two agree: CI builds
/// from the real `container/` directory, so a `COPY` of something missing
/// here only fails for users who build locally. That went unnoticed for
/// `skills`, `triple-c-open` and `triple-c-playwright-heal`. Every `COPY`
/// source (not `--from=` stages) must be present as a file or directory
/// prefix in the archive.
#[test]
fn every_dockerfile_copy_source_is_in_the_local_build_context() {
let tar_bytes = create_build_context().expect("context builds");
let mut archive = tar::Archive::new(&tar_bytes[..]);
let paths: Vec<String> = archive
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
.collect();
let mut missing = Vec::new();
for line in DOCKERFILE.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("COPY ") else { continue };
let args: Vec<&str> = rest.split_whitespace().filter(|a| !a.starts_with("--")).collect();
if rest.contains("--from=") || args.len() < 2 {
continue;
}
for src in &args[..args.len() - 1] {
let src = src.trim_end_matches('/');
let present = paths.iter().any(|p| p == src || p.starts_with(&format!("{}/", src)));
if !present {
missing.push(src.to_string());
}
}
}
assert!(missing.is_empty(), "COPY sources missing from the local build context: {:?}", missing);
}
}
#[cfg(all(test, unix))]
mod tests {
use super::ENTRYPOINT;
+1
View File
@@ -1,5 +1,6 @@
pub mod ca_certs;
pub mod client;
pub mod events;
pub mod container;
pub mod image;
pub mod exec;
+5 -2
View File
@@ -40,7 +40,10 @@ pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll {
ViewerPoll { exists: true, hash, size }
}
pub async fn poll_file(container_id: &str, container_path: &str) -> Result<ViewerPoll, String> {
pub async fn poll_file(
container: &ContainerTarget,
container_path: &str,
) -> Result<ViewerPoll, String> {
let cmd = vec![
"sh".to_string(),
"-c".to_string(),
@@ -48,7 +51,7 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result<Viewe
"poll".to_string(),
container_path.to_string(),
];
let (stdout, stderr, code) = ContainerTarget::docker(container_id)
let (stdout, stderr, code) = container
.exec_oneshot_streams_as("claude", cmd, Vec::new())
.await?;
if code != 0 && code != 4 {
+2 -2
View File
@@ -131,12 +131,12 @@ pub fn parse_probe_output(stdout: &str) -> Vec<String> {
}
pub async fn probe_candidates(
container_id: &str,
container: &ContainerTarget,
candidates: &[String],
) -> Result<Vec<String>, String> {
let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()];
cmd.extend(candidates.iter().cloned());
let (stdout, _stderr, _code) = ContainerTarget::docker(container_id)
let (stdout, _stderr, _code) = container
.exec_oneshot_streams_as("claude", cmd, Vec::new())
.await?;
Ok(parse_probe_output(&stdout))
+2 -3
View File
@@ -172,7 +172,7 @@ fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result<SavedFile, String>
}
pub async fn write_file(
container_id: &str,
container: &ContainerTarget,
exec_manager: &ExecSessionManager,
target: &str,
bytes: &[u8],
@@ -180,9 +180,8 @@ pub async fn write_file(
) -> Result<SavedFile, String> {
check_write_input(bytes.len(), base_hash)?;
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
let container = ContainerTarget::docker(container_id);
let tmp_path = exec_manager
.write_file_to_container(&container, &tmp_name, bytes)
.write_file_to_container(container, &tmp_name, bytes)
.await?;
let cmd = write_command(target, &tmp_path, base_hash);
let (stdout, stderr, code) = container
+915
View File
@@ -0,0 +1,915 @@
//! The app's side of `triple-c-agent`, the small server that runs inside a
//! project container and does over HTTP what the Docker variant does through
//! the Docker API.
//!
//! Wire protocol: `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`.
//! Its "Client mapping" table is what this file implements, one function per
//! [`ContainerTarget`](super::ContainerTarget) method. The contract is Docker
//! parity: every function here returns exactly what its `docker/exec.rs`
//! counterpart returns for the same command, including the shape of one-shot
//! output (combined in arrival order, or stdout/stderr split), the
//! `OUTPUT_LIMIT` refusal text, and the path an upload reports.
//!
//! The token never leaves this file except as the `Authorization` header:
//! [`AgentEndpoint`]'s `Debug` redacts it and no error string is built from a
//! request (reqwest and tungstenite errors name the URL, which carries no
//! secret, never the headers).
use std::collections::{HashMap, VecDeque};
use std::io;
use std::pin::Pin;
use std::sync::{Mutex, OnceLock};
use std::task::{Context, Poll};
use std::time::Duration;
use base64::Engine;
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use tokio::io::AsyncWrite;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use crate::host::OUTPUT_LIMIT_MARKER;
use tokio_tungstenite::tungstenite::Message;
use tokio_util::sync::PollSender;
use super::{ArchiveStream, AttachedExec, ExecChunk, ExecSpec};
use crate::docker::exec::{
container_join, output_limit_error, parse_user_ids, read_host_file_for_upload, user_ids_cmd,
OneshotOutput, EXEC_EXIT_POLLS, EXEC_EXIT_POLL_INTERVAL, HOST_FILE_UPLOAD_MODE,
MAX_ONESHOT_OUTPUT, USER_IDS_OUTPUT_LIMIT,
};
/// Connecting (TCP, and the WebSocket handshake) must not hang a caller on an
/// agent that is not there. Nothing else has an overall timeout: a one-shot
/// exec, an attach or an archive download can legitimately run for minutes.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// `is_running` is a liveness probe; it answers quickly or says "no".
const HEALTH_TIMEOUT: Duration = Duration::from_secs(3);
/// Whole-request ceiling for the small calls (detached start, exec status,
/// resize) and for reading any small body. The agent is not trusted: one that
/// accepts a connection and then says nothing must not hang the app. One-shot
/// execs, attach and archive have *no* overall timeout — commands can
/// legitimately run long and send nothing until they finish, as with Docker.
#[cfg(not(test))]
const SMALL_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
/// Short in tests, so the hostile-agent tests finish quickly.
#[cfg(test)]
const SMALL_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
/// Largest WebSocket message either side may send; the agent enforces the same
/// (its output frames are at most 32 KiB). Stdin is split to fit.
const WS_MAX_MESSAGE: usize = 1024 * 1024;
const STDIN_FRAME_MAX: usize = 64 * 1024;
/// How long an attach waits for the agent's `started` reply after the
/// handshake. Spawning a process is fast; this only bounds a wedged agent.
const ATTACH_START_TIMEOUT: Duration = Duration::from_secs(30);
/// In-flight stdin frames per attach before `poll_write` applies backpressure.
const STDIN_QUEUE: usize = 64;
/// Where an agent listens and the bearer token it wants.
///
/// `base_url` is `http://127.0.0.1:<port>/`: the local end of an SSM
/// port-forward on AWS, or a published loopback port in the local harness.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct AgentEndpoint {
pub base_url: url::Url,
token: String,
}
impl std::fmt::Debug for AgentEndpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentEndpoint")
.field("base_url", &self.base_url.as_str())
.field("token", &"<redacted>")
.finish()
}
}
impl AgentEndpoint {
/// An endpoint at `base_url`, which must be a plain `http://` URL with a
/// host. A trailing `/` is added so endpoint paths join beneath it.
pub fn new(mut base_url: url::Url, token: impl Into<String>) -> Result<Self, String> {
if base_url.scheme() != "http" || base_url.host().is_none() {
return Err(format!(
"An agent endpoint must be an http:// URL with a host, not {}",
base_url
));
}
if !base_url.path().ends_with('/') {
let path = format!("{}/", base_url.path());
base_url.set_path(&path);
}
base_url.set_query(None);
base_url.set_fragment(None);
let token = token.into();
// It goes in an HTTP header; refuse anything that cannot, here, once,
// rather than failing (or silently degrading) on every request.
if token.is_empty() || !token.bytes().all(|b| b.is_ascii_graphic()) {
return Err("An agent token must be non-empty printable ASCII with no spaces".to_string());
}
Ok(Self {
base_url,
token,
})
}
/// The agent on `127.0.0.1:<port>`.
pub fn loopback(port: u16, token: impl Into<String>) -> Self {
let url = url::Url::parse(&format!("http://127.0.0.1:{}/", port))
.expect("a loopback URL always parses");
Self::new(url, token).expect("a loopback URL is a valid endpoint")
}
fn url(&self, path: &str) -> url::Url {
self.base_url
.join(path)
.expect("endpoint paths are fixed relative paths")
}
fn ws_url(&self, path: &str) -> url::Url {
let mut u = self.url(path);
u.set_scheme("ws").expect("http -> ws is a valid scheme change");
u
}
fn bearer(&self) -> String {
format!("Bearer {}", self.token)
}
/// The `Authorization` header, marked sensitive so it is never logged or
/// shown by `Debug` (reqwest/hyper honour the flag).
fn auth(&self) -> HeaderValue {
let mut v = HeaderValue::from_str(&self.bearer())
.unwrap_or_else(|_| HeaderValue::from_static("Bearer invalid-token"));
v.set_sensitive(true);
v
}
}
// ── HTTP plumbing ────────────────────────────────────────────────────────────
/// One client for every agent: connection pooling is per host, and building a
/// client per call would redo TLS-less setup for nothing. No proxy — the agent
/// is always on loopback, and a corporate `HTTP_PROXY` must not capture it.
fn http() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.no_proxy()
// The agent lives inside the container, which is not trusted with
// the host: a redirect would let it point this client — running in
// the host app — at any other URL. A 3xx is an error (it fails the
// `is_success` checks like any other non-2xx).
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("a reqwest client with no TLS config always builds")
})
}
#[derive(Deserialize)]
struct ErrorBody {
error: String,
}
/// Ceiling on any small JSON body from the agent (errors, exec ids, exec
/// status). The agent is inside the container, so a body it sends must not be
/// able to grow host memory without bound; real ones are tens of bytes.
const SMALL_BODY_CAP: usize = 64 * 1024;
/// Ceiling on error text passed through to the UI.
const ERROR_TEXT_CAP: usize = 4 * 1024;
/// Parse a small JSON body, reading at most [`SMALL_BODY_CAP`] bytes.
async fn small_json<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Option<T> {
let raw = tokio::time::timeout(SMALL_REQUEST_TIMEOUT, read_capped(resp, SMALL_BODY_CAP))
.await
.ok()?
.ok()??;
serde_json::from_slice(&raw).ok()
}
/// The agent's `{"error": …}` text, if the body has one — bounded in both
/// bytes read and length returned.
async fn error_text(resp: reqwest::Response) -> Option<String> {
let mut e = small_json::<ErrorBody>(resp).await?.error;
if e.len() > ERROR_TEXT_CAP {
let mut cut = ERROR_TEXT_CAP;
while !e.is_char_boundary(cut) {
cut -= 1;
}
e.truncate(cut);
e.push('…');
}
Some(e)
}
/// `"<context>: <why>"` for a non-success response.
async fn status_error(context: &str, resp: reqwest::Response) -> String {
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return format!("{}: the agent rejected the credentials (401)", context);
}
match error_text(resp).await {
Some(e) => format!("{}: {}", context, e),
None => format!("{}: agent returned HTTP {}", context, status.as_u16()),
}
}
async fn send(
context: &str,
req: reqwest::RequestBuilder,
) -> Result<reqwest::Response, String> {
req.send().await.map_err(|e| format!("{}: {}", context, e))
}
/// Read a response body into memory, refusing past `cap` bytes.
async fn read_capped(mut resp: reqwest::Response, cap: usize) -> Result<Option<Vec<u8>>, String> {
let mut body = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| format!("Exec output error: {}", e))?
{
if body.len() + chunk.len() > cap {
return Ok(None);
}
body.extend_from_slice(&chunk);
}
Ok(Some(body))
}
/// ExecSpec as the protocol spells it.
fn spec_json(spec: &ExecSpec) -> serde_json::Map<String, serde_json::Value> {
let v = serde_json::json!({
"cmd": spec.cmd,
"user": spec.user,
"working_dir": spec.working_dir,
"env": spec.env,
"tty": spec.tty,
});
match v {
serde_json::Value::Object(m) => m,
_ => unreachable!("json! of an object literal is an object"),
}
}
// ── one-shot execs ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct OneshotChunk {
s: String,
d: String,
}
#[derive(Deserialize)]
struct OneshotResponse {
chunks: Vec<OneshotChunk>,
exit_code: i64,
}
/// `POST /v1/exec`, reassembled into the same [`OneshotOutput`] the Docker
/// path builds. Like Docker's one-shot, there is no tty, stdin is empty, and
/// the working directory is the container's default.
async fn exec_oneshot_raw(
ep: &AgentEndpoint,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
limit: usize,
) -> Result<OneshotOutput, String> {
const CONTEXT: &str = "Failed to start exec";
let spec = ExecSpec::new(cmd).user(user).working_dir(None).env(env).tty(false);
let mut body = spec_json(&spec);
body.insert("limit".into(), serde_json::json!(limit));
let resp = send(
CONTEXT,
http()
.post(ep.url("v1/exec"))
.header(reqwest::header::AUTHORIZATION, ep.auth())
.json(&body),
)
.await?;
let status = resp.status();
if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE {
// Verbatim when it is the limit message (callers match on
// OUTPUT_LIMIT_MARKER); anything else the agent puts in a 413 is not
// trusted to speak for it.
return Err(error_text(resp)
.await
.filter(|e| e.starts_with(OUTPUT_LIMIT_MARKER))
.unwrap_or_else(|| output_limit_error(limit)));
}
if !status.is_success() {
return Err(status_error(CONTEXT, resp).await);
}
// The agent enforces `limit`, but this is container-controlled input
// being buffered into the host, so the client bounds it too: base64 is
// 4/3 of the payload and each chunk carries a few bytes of JSON, so a
// body past this is over the limit however it was chunked.
let cap = limit.saturating_mul(8).saturating_add(64 * 1024);
let raw = read_capped(resp, cap)
.await?
.ok_or_else(|| output_limit_error(limit))?;
let parsed: OneshotResponse = serde_json::from_slice(&raw)
.map_err(|e| format!("Exec output error: malformed agent response: {}", e))?;
let mut out = OneshotOutput::new();
let b64 = base64::engine::general_purpose::STANDARD;
for chunk in parsed.chunks {
let from_stdout = match chunk.s.as_str() {
"out" => true,
"err" => false,
other => {
return Err(format!(
"Exec output error: unknown stream {:?} in agent response",
other
))
}
};
let bytes = b64
.decode(chunk.d.as_bytes())
.map_err(|e| format!("Exec output error: bad base64 from agent: {}", e))?;
out.push(from_stdout, &bytes, limit)?;
}
out.exit_code = parsed.exit_code;
Ok(out)
}
/// Combined output and exit code under `limit` — `exec_oneshot_limited` and,
/// with [`MAX_ONESHOT_OUTPUT`], every other combined-output variant.
pub async fn exec_oneshot_limited(
ep: &AgentEndpoint,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
limit: usize,
) -> Result<(String, i64), String> {
let out = exec_oneshot_raw(ep, user, cmd, env, limit).await?;
Ok((out.text(), out.exit_code))
}
/// `exec_oneshot_as`: combined output under [`MAX_ONESHOT_OUTPUT`].
pub async fn exec_oneshot_as(
ep: &AgentEndpoint,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<(String, i64), String> {
exec_oneshot_limited(ep, user, cmd, env, MAX_ONESHOT_OUTPUT).await
}
/// `exec_oneshot_streams_as`: `(stdout, stderr, exit_code)`.
pub async fn exec_oneshot_streams_as(
ep: &AgentEndpoint,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<(String, String, i64), String> {
let out = exec_oneshot_raw(ep, user, cmd, env, MAX_ONESHOT_OUTPUT).await?;
Ok((out.stdout(), out.stderr(), out.exit_code))
}
// ── detached execs, status, resize ───────────────────────────────────────────
#[derive(Deserialize)]
struct ExecIdResponse {
exec_id: String,
}
pub async fn exec_detached(ep: &AgentEndpoint, spec: &ExecSpec) -> Result<String, String> {
const CONTEXT: &str = "Failed to start exec";
let resp = send(
CONTEXT,
http()
.post(ep.url("v1/exec/detached"))
.timeout(SMALL_REQUEST_TIMEOUT)
.header(reqwest::header::AUTHORIZATION, ep.auth())
.json(&spec_json(spec)),
)
.await?;
if !resp.status().is_success() {
return Err(status_error(CONTEXT, resp).await);
}
small_json::<ExecIdResponse>(resp)
.await
.map(|r| r.exec_id)
.ok_or_else(|| format!("{}: malformed agent response", CONTEXT))
}
#[derive(Deserialize)]
struct ExecStatus {
running: bool,
exit_code: Option<i64>,
}
/// Exit codes attach streams saw in their `exit` frame, so `wait_exec_exit`
/// can answer without a round trip. Bounded: a long-running app opens many
/// sessions, and the agent keeps the same answer for ten minutes anyway.
struct ExitBook {
codes: HashMap<(String, String), i64>,
order: VecDeque<(String, String)>,
}
const EXIT_BOOK_CAP: usize = 256;
fn exit_book() -> &'static Mutex<ExitBook> {
static BOOK: OnceLock<Mutex<ExitBook>> = OnceLock::new();
BOOK.get_or_init(|| {
Mutex::new(ExitBook {
codes: HashMap::new(),
order: VecDeque::new(),
})
})
}
fn record_exit(base_url: &str, exec_id: &str, code: i64) {
let key = (base_url.to_string(), exec_id.to_string());
let mut book = exit_book().lock().unwrap_or_else(|p| p.into_inner());
if book.codes.insert(key.clone(), code).is_none() {
book.order.push_back(key);
while book.order.len() > EXIT_BOOK_CAP {
if let Some(old) = book.order.pop_front() {
book.codes.remove(&old);
}
}
}
}
fn recorded_exit(base_url: &str, exec_id: &str) -> Option<i64> {
let book = exit_book().lock().unwrap_or_else(|p| p.into_inner());
book.codes
.get(&(base_url.to_string(), exec_id.to_string()))
.copied()
}
/// Poll `GET /v1/exec/{id}` with Docker's budget. `None` on any error, an
/// unknown id, a finished exec with no code, or the budget running out —
/// exactly where `docker::exec::wait_for_exec_exit` says `None`.
pub async fn wait_exec_exit(ep: &AgentEndpoint, exec_id: &str) -> Option<i64> {
// Docker's budget is a number of polls; bound the wall clock too, so a
// slow-answering agent cannot stretch it (each poll is also bounded).
let budget = EXEC_EXIT_POLL_INTERVAL * EXEC_EXIT_POLLS as u32 + SMALL_REQUEST_TIMEOUT;
tokio::time::timeout(budget, wait_exec_exit_polls(ep, exec_id)).await.ok()?
}
async fn wait_exec_exit_polls(ep: &AgentEndpoint, exec_id: &str) -> Option<i64> {
if let Some(code) = recorded_exit(ep.base_url.as_str(), exec_id) {
return Some(code);
}
let url = ep.url(&format!("v1/exec/{}", urlencode_segment(exec_id)));
for _ in 0..EXEC_EXIT_POLLS {
if let Some(code) = recorded_exit(ep.base_url.as_str(), exec_id) {
return Some(code);
}
let resp = http()
.get(url.clone())
.timeout(SMALL_REQUEST_TIMEOUT)
.header(reqwest::header::AUTHORIZATION, ep.auth())
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let status: ExecStatus = small_json(resp).await?;
if !status.running {
return status.exit_code;
}
tokio::time::sleep(EXEC_EXIT_POLL_INTERVAL).await;
}
None
}
pub async fn resize_exec(ep: &AgentEndpoint, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
const CONTEXT: &str = "Failed to resize exec";
let resp = send(
CONTEXT,
http()
.post(ep.url(&format!("v1/exec/{}/resize", urlencode_segment(exec_id))))
.timeout(SMALL_REQUEST_TIMEOUT)
.header(reqwest::header::AUTHORIZATION, ep.auth())
.json(&serde_json::json!({ "cols": cols, "rows": rows })),
)
.await?;
if !resp.status().is_success() {
return Err(status_error(CONTEXT, resp).await);
}
Ok(())
}
/// Exec ids come from the agent; escape them anyway so one can never turn
/// into a different path.
fn urlencode_segment(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
// ── attach ───────────────────────────────────────────────────────────────────
type Ws = tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>;
#[derive(Deserialize)]
struct ControlFrame {
#[serde(rename = "type")]
kind: String,
exec_id: Option<String>,
code: Option<i64>,
error: Option<String>,
}
/// What the stdin writer hands the socket task.
enum StdinCmd {
Data(Bytes),
Close,
}
/// Open `GET /v1/attach` and authenticate. Shared by attach (and, later,
/// tunnels).
async fn connect_ws(ep: &AgentEndpoint, path: &str, context: &str) -> Result<Ws, String> {
let mut req = ep
.ws_url(path)
.as_str()
.into_client_request()
.map_err(|e| format!("{}: {}", context, e))?;
let auth = ep.auth();
req.headers_mut()
.insert(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, auth);
let config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
.max_message_size(Some(WS_MAX_MESSAGE))
.max_frame_size(Some(WS_MAX_MESSAGE));
match tokio::time::timeout(
CONNECT_TIMEOUT,
tokio_tungstenite::connect_async_with_config(req, Some(config), false),
)
.await
{
Err(_) => Err(format!("{}: timed out connecting to the agent", context)),
Ok(Err(tokio_tungstenite::tungstenite::Error::Http(resp)))
if resp.status() == tokio_tungstenite::tungstenite::http::StatusCode::UNAUTHORIZED =>
{
Err(format!("{}: the agent rejected the credentials (401)", context))
}
Ok(Err(e)) => Err(format!("{}: {}", context, e)),
Ok(Ok((ws, _))) => Ok(ws),
}
}
/// `GET /v1/attach`: the same [`AttachedExec`] Docker hands back.
///
/// Output: channel 1 → [`ExecChunk::StdOut`], 2 → [`ExecChunk::StdErr`]; the
/// `exit` frame ends the stream and is remembered for [`wait_exec_exit`].
/// Input: each write is one binary frame; `shutdown` sends `close_stdin`.
/// Dropping both halves closes the socket.
pub async fn attach(ep: &AgentEndpoint, spec: &ExecSpec) -> Result<AttachedExec, String> {
const CONTEXT: &str = "Failed to start exec";
let mut ws = connect_ws(ep, "v1/attach", CONTEXT).await?;
let mut hello = spec_json(spec);
hello.insert("attach_stdin".into(), serde_json::json!(spec.attach_stdin));
let hello = serde_json::Value::Object(hello).to_string();
ws.send(Message::text(hello))
.await
.map_err(|e| format!("{}: {}", CONTEXT, e))?;
let exec_id = match tokio::time::timeout(ATTACH_START_TIMEOUT, wait_started(&mut ws)).await {
Err(_) => return Err(format!("{}: the agent did not start the command", CONTEXT)),
Ok(r) => r.map_err(|e| format!("{}: {}", CONTEXT, e))?,
};
let (mut sink, stream) = ws.split();
// stdin: a bounded queue drained by one task that owns the socket's write
// half. The task ends — dropping the write half — when the writer is
// dropped or the socket fails.
let (tx, mut rx) = tokio::sync::mpsc::channel::<StdinCmd>(STDIN_QUEUE);
tokio::spawn(async move {
while let Some(cmd) = rx.recv().await {
let msg = match cmd {
StdinCmd::Data(b) => Message::binary(b),
StdinCmd::Close => Message::text(r#"{"type":"close_stdin"}"#),
};
if sink.send(msg).await.is_err() {
break;
}
}
});
let base_url = ep.base_url.as_str().to_string();
let id_for_stream = exec_id.clone();
let output = futures_util::stream::unfold(Some(stream), move |state| {
let base_url = base_url.clone();
let exec_id = id_for_stream.clone();
async move {
let mut stream = state?;
loop {
match stream.next().await? {
Err(e) => return Some((Err(format!("Exec output error: {}", e)), None)),
Ok(Message::Binary(b)) => {
if b.is_empty() {
continue;
}
let payload = b.slice(1..);
let chunk = match b[0] {
1 => ExecChunk::StdOut(payload),
2 => ExecChunk::StdErr(payload),
_ => ExecChunk::Other(payload),
};
return Some((Ok(chunk), Some(stream)));
}
Ok(Message::Text(t)) => {
let frame: ControlFrame = match serde_json::from_str(t.as_str()) {
Ok(f) => f,
Err(_) => continue,
};
match frame.kind.as_str() {
"exit" => {
if let Some(code) = frame.code {
record_exit(&base_url, &exec_id, code);
}
return None;
}
"error" => {
let e = frame.error.unwrap_or_else(|| "agent error".into());
return Some((Err(format!("Exec output error: {}", e)), None));
}
_ => continue,
}
}
Ok(Message::Close(_)) => return None,
Ok(_) => continue,
}
}
}
});
Ok(AttachedExec {
exec_id,
output: Box::pin(output),
input: Box::pin(AttachInput {
tx: PollSender::new(tx),
close_sent: false,
}),
})
}
/// Read until the agent says `started` (its exec id) or `error`.
async fn wait_started(ws: &mut Ws) -> Result<String, String> {
while let Some(msg) = ws.next().await {
match msg.map_err(|e| e.to_string())? {
Message::Text(t) => {
let frame: ControlFrame = serde_json::from_str(t.as_str())
.map_err(|e| format!("malformed agent frame: {}", e))?;
match frame.kind.as_str() {
"started" => {
return frame
.exec_id
.ok_or_else(|| "agent sent no exec id".to_string())
}
"error" => {
return Err(frame.error.unwrap_or_else(|| "agent error".into()))
}
_ => continue,
}
}
Message::Close(_) => break,
_ => continue,
}
}
Err("the agent closed the connection before starting the command".to_string())
}
/// An attach's stdin.
struct AttachInput {
tx: PollSender<StdinCmd>,
close_sent: bool,
}
fn broken_pipe() -> io::Error {
io::Error::new(io::ErrorKind::BrokenPipe, "agent connection closed")
}
impl AsyncWrite for AttachInput {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.get_mut();
if buf.is_empty() {
return Poll::Ready(Ok(0));
}
if this.close_sent {
return Poll::Ready(Err(broken_pipe()));
}
match this.tx.poll_reserve(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Err(_)) => Poll::Ready(Err(broken_pipe())),
Poll::Ready(Ok(())) => {
// One frame per write, at most STDIN_FRAME_MAX; `write_all`
// loops for the rest. Keeps every frame under the agent's
// WebSocket message limit.
let n = buf.len().min(STDIN_FRAME_MAX);
this.tx
.send_item(StdinCmd::Data(Bytes::copy_from_slice(&buf[..n])))
.map_err(|_| broken_pipe())?;
Poll::Ready(Ok(n))
}
}
}
/// Frames are flushed by the socket task as it sends them.
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
/// EOF on the child's stdin: `{"type":"close_stdin"}`, once.
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.get_mut();
if this.close_sent {
return Poll::Ready(Ok(()));
}
match this.tx.poll_reserve(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Err(_)) => Poll::Ready(Err(broken_pipe())),
Poll::Ready(Ok(())) => {
this.tx.send_item(StdinCmd::Close).map_err(|_| broken_pipe())?;
this.close_sent = true;
Poll::Ready(Ok(()))
}
}
}
}
// ── files ────────────────────────────────────────────────────────────────────
/// `(uid, gid)` of `claude`, falling back to `(1000, 1000)` — the same probe
/// and parse as Docker's `container_user_ids`.
pub async fn user_ids(ep: &AgentEndpoint) -> (u64, u64) {
let (out, _) = exec_oneshot_limited(ep, "claude", user_ids_cmd(), Vec::new(), USER_IDS_OUTPUT_LIMIT)
.await
.unwrap_or_default();
parse_user_ids(&out)
}
/// Split Docker's tar-entry `name` (which may carry a relative directory, as
/// the terminal drop's `triple-c-drops/<file>` does) into the `dir` and
/// single-component `name` `PUT /v1/files` takes. Docker's extractor would
/// put the file at the same place.
fn split_upload_target(dest_dir: &str, file_name: &str) -> Result<(String, String), String> {
let parts: Vec<&str> = file_name.split('/').filter(|p| !p.is_empty()).collect();
let Some((name, parents)) = parts.split_last() else {
return Err(format!("Failed to upload file to container: empty file name {:?}", file_name));
};
if parts.iter().any(|p| *p == "." || *p == "..") {
return Err(format!(
"Failed to upload file to container: {:?} is not a plain relative name",
file_name
));
}
let mut dir = dest_dir.trim_end_matches('/').to_string();
for p in parents {
dir.push('/');
dir.push_str(p);
}
if dir.is_empty() {
dir.push('/');
}
Ok((dir, name.to_string()))
}
async fn put_file(
ep: &AgentEndpoint,
dest_dir: &str,
file_name: &str,
data: Vec<u8>,
mode: u32,
(uid, gid): (u64, u64),
) -> Result<(), String> {
const CONTEXT: &str = "Failed to upload file to container";
let (dir, name) = split_upload_target(dest_dir, file_name)?;
let mut url = ep.url("v1/files");
url.query_pairs_mut()
.append_pair("dir", &dir)
.append_pair("name", &name)
.append_pair("mode", &format!("{:o}", mode))
.append_pair("uid", &uid.to_string())
.append_pair("gid", &gid.to_string());
// Generous, but finite: a minute plus 256 KiB/s for the payload.
let timeout = Duration::from_secs(60 + (data.len() as u64) / (256 * 1024));
let resp = send(
CONTEXT,
http()
.put(url)
.timeout(timeout)
.header(reqwest::header::AUTHORIZATION, ep.auth())
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.body(data),
)
.await?;
if !resp.status().is_success() {
return Err(status_error(CONTEXT, resp).await);
}
Ok(())
}
/// Docker's `upload_bytes_with_ids`: same reported path.
pub async fn upload_bytes_with_ids(
ep: &AgentEndpoint,
dest_dir: &str,
file_name: &str,
data: &[u8],
mode: u32,
ids: (u64, u64),
) -> Result<String, String> {
put_file(ep, dest_dir, file_name, data.to_vec(), mode, ids).await?;
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
}
/// Docker's `upload_host_file_with_ids`: the host file is read (and checked)
/// by the same function, mode 0644, same reported path.
pub async fn upload_host_file_with_ids(
ep: &AgentEndpoint,
host_path: &str,
dest_dir: &str,
dest_name: &str,
ids: (u64, u64),
) -> Result<String, String> {
let path = host_path.to_string();
let data = tokio::task::spawn_blocking(move || read_host_file_for_upload(&path))
.await
.map_err(|e| format!("Upload task panicked: {}", e))??;
put_file(ep, dest_dir, dest_name, data, HOST_FILE_UPLOAD_MODE, ids).await?;
Ok(container_join(dest_dir, dest_name))
}
/// `GET /v1/archive`, streamed. Dropping the stream drops the response,
/// which closes the connection and cancels the read on the agent.
pub fn download_archive(ep: &AgentEndpoint, path: &str) -> ArchiveStream {
const CONTEXT: &str = "Failed to download archive";
let mut url = ep.url("v1/archive");
url.query_pairs_mut().append_pair("path", path);
let req = http()
.get(url)
.header(reqwest::header::AUTHORIZATION, ep.auth());
enum State {
Start(reqwest::RequestBuilder),
Body(reqwest::Response),
}
Box::pin(futures_util::stream::unfold(
Some(State::Start(req)),
|state| async move {
let mut resp = match state? {
State::Start(req) => match req.send().await {
Err(e) => return Some((Err(format!("{}: {}", CONTEXT, e)), None)),
Ok(r) if !r.status().is_success() => {
return Some((Err(status_error(CONTEXT, r).await), None))
}
Ok(r) => r,
},
State::Body(r) => r,
};
loop {
match resp.chunk().await {
Err(e) => return Some((Err(format!("{}: {}", CONTEXT, e)), None)),
Ok(None) => return None,
Ok(Some(b)) if b.is_empty() => continue,
Ok(Some(b)) => return Some((Ok(b), Some(State::Body(resp)))),
}
}
},
))
}
// ── state ────────────────────────────────────────────────────────────────────
/// `GET /v1/health` answers → `true`. Can't connect (or times out) → `false`,
/// the same "not running" an inspect failure gives on Docker. An agent that
/// answers with an error status is reachable but unusable, which is an
/// `Err` rather than a quiet `false`.
pub async fn is_running(ep: &AgentEndpoint) -> Result<bool, String> {
let resp = match http()
.get(ep.url("v1/health"))
.header(reqwest::header::AUTHORIZATION, ep.auth())
.timeout(HEALTH_TIMEOUT)
.send()
.await
{
Ok(r) => r,
Err(_) => return Ok(false),
};
if !resp.status().is_success() {
return Err(status_error("Agent health check failed", resp).await);
}
Ok(true)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod live_tests;
+265
View File
@@ -0,0 +1,265 @@
//! The real agent against `docker exec`, in one real container: every
//! operation goes through `ContainerTarget::Docker` and
//! `ContainerTarget::Agent` on the *same* container and must come back the
//! same. That is the agent protocol's whole requirement ("Docker parity is the
//! requirement"), checked end to end rather than against a fake.
//!
//! Needs a Docker daemon and an image whose entrypoint starts the agent:
//! `TRIPLEC_AGENT_E2E_IMAGE`, default `tc-agent-e2e:latest` (the sandbox image
//! plus this branch's `entrypoint.sh` and `triple-c-agent`).
use super::*;
use crate::host::{ContainerTarget, ExecChunk, ExecSpec, OUTPUT_LIMIT_MARKER};
use futures_util::StreamExt;
use std::process::Command;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
fn docker_cli(args: &[&str]) -> String {
let out = Command::new("docker").args(args).output().expect("docker cli");
assert!(out.status.success(), "docker {:?}: {}", args, String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn sh(script: &str) -> Vec<String> {
vec!["sh".into(), "-c".into(), script.into()]
}
/// Drain an attached exec: (stdout, stderr).
async fn drain(exec: crate::host::AttachedExec) -> (String, String) {
let mut out = exec.output;
let (mut o, mut e) = (Vec::new(), Vec::new());
let collect = async {
while let Some(chunk) = out.next().await {
match chunk {
Ok(ExecChunk::StdOut(b)) => o.extend_from_slice(&b),
Ok(ExecChunk::StdErr(b)) => e.extend_from_slice(&b),
Ok(ExecChunk::Other(_)) => {}
Err(_) => break,
}
}
};
tokio::time::timeout(Duration::from_secs(20), collect).await.expect("attach output ended");
(String::from_utf8_lossy(&o).into_owned(), String::from_utf8_lossy(&e).into_owned())
}
/// Fail with both answers side by side.
fn same<T: PartialEq + std::fmt::Debug>(what: &str, docker: T, agent: T) -> Result<(), String> {
if docker == agent {
Ok(())
} else {
Err(format!("{what} differs\n docker: {docker:?}\n agent: {agent:?}"))
}
}
async fn parity(d: &ContainerTarget, a: &ContainerTarget) -> Result<(), String> {
// ── one-shot ─────────────────────────────────────────────────────────────
let script = sh("echo out1; echo err1 >&2; echo out2; exit 3");
same(
"streams + exit code",
d.exec_oneshot_streams_as("claude", script.clone(), vec![]).await?,
a.exec_oneshot_streams_as("claude", script, vec![]).await?,
)?;
for user in ["claude", "root"] {
let env = sh("env | sort");
let strip = |s: String| -> String {
// `docker exec` inherits the container's configured env, which in
// this harness carries the agent's settings (the token included —
// the local-harness trade-off entrypoint.sh documents). The agent
// never passes its own settings on.
s.lines().filter(|l| !l.starts_with("TRIPLEC_AGENT_")).collect::<Vec<_>>().join("\n")
};
let (de, dc) = d.exec_oneshot_as(user, env.clone(), vec![]).await?;
let (ae, ac) = a.exec_oneshot_as(user, env, vec![]).await?;
same(&format!("environment as {user}"), (strip(de), dc), (strip(ae), ac))?;
}
same("default working dir", d.exec_oneshot(vec!["pwd".into()]).await?, a.exec_oneshot(vec!["pwd".into()]).await?)?;
let probe = sh("echo \"$FOO|$HOME|$USER|$(id -un)\"");
let env = vec!["FOO=bar".to_string(), "HOME=/tmp".to_string()];
same(
"env overrides",
d.exec_oneshot_env_status(probe.clone(), env.clone()).await?,
a.exec_oneshot_env_status(probe, env).await?,
)?;
same("user ids", d.user_ids().await, a.user_ids().await)?;
// Signal state: an exec starts with nothing ignored or blocked, whatever
// the process that launched it had (the entrypoint's supervisor ignores
// SIGHUP; that must not turn every exec into `nohup`).
let sigs = sh("grep -E '^Sig(Ign|Blk):' /proc/self/status");
same("signal dispositions/mask", d.exec_oneshot(sigs.clone()).await?, a.exec_oneshot(sigs).await?)?;
// Stdio belongs to the exec user, as runc arranges: re-opening it by
// name works for a non-root user.
let reopen = sh("echo out > /dev/stdout; echo err > /dev/stderr");
same(
"re-opening stdio by name as claude",
d.exec_oneshot_streams_as("claude", reopen.clone(), vec![]).await?,
a.exec_oneshot_streams_as("claude", reopen, vec![]).await?,
)?;
let flood = sh("head -c 100000 /dev/zero");
let de = d.exec_oneshot_limited("claude", flood.clone(), 1000).await.unwrap_err();
let ae = a.exec_oneshot_limited("claude", flood, 1000).await.unwrap_err();
if !de.starts_with(OUTPUT_LIMIT_MARKER) {
return Err(format!("docker limit error: {de}"));
}
same("output limit error", de, ae)?;
let (_, dcode) = d.exec_oneshot_env_status(vec!["no-such-binary-tc".into()], vec![]).await?;
let (_, acode) = a.exec_oneshot_env_status(vec!["no-such-binary-tc".into()], vec![]).await?;
same("missing binary exit code", dcode, acode)?;
// ── files ────────────────────────────────────────────────────────────────
let ids = d.user_ids().await;
d.upload_bytes_with_ids("/tmp", "via-docker.txt", b"hello\n", 0o640, ids).await?;
a.upload_bytes_with_ids("/tmp", "via-agent.txt", b"hello\n", 0o640, ids).await?;
let stat = |n: &str| sh(&format!("stat -c '%a %u %g %s' /tmp/{n} && cat /tmp/{n}"));
same(
"uploaded file (mode, owner, size, content)",
d.exec_oneshot(stat("via-docker.txt")).await?,
a.exec_oneshot(stat("via-agent.txt")).await?,
)?;
d.exec_oneshot(sh("rm -rf /tmp/arch && mkdir -p /tmp/arch/sub && echo x > /tmp/arch/x && echo y > /tmp/arch/sub/y")).await?;
let list = |t: &ContainerTarget| {
let mut s = t.download_archive("/tmp/arch");
async move {
let mut bytes = Vec::new();
while let Some(c) = s.next().await {
bytes.extend_from_slice(&c?);
}
let mut names: Vec<(String, Vec<u8>)> = tar::Archive::new(&bytes[..])
.entries()
.map_err(|e| e.to_string())?
.map(|e| {
let mut e = e.unwrap();
let name = e.path().unwrap().to_string_lossy().trim_end_matches('/').to_string();
let mut data = Vec::new();
std::io::Read::read_to_end(&mut e, &mut data).unwrap();
(name, data)
})
.collect();
names.sort();
Ok::<_, String>(names)
}
};
same("archive members and contents", list(d).await?, list(a).await?)?;
// ── attached ─────────────────────────────────────────────────────────────
for (label, t) in [("docker", d), ("agent", a)] {
let mut exec = t.attach_exec(vec!["head".into(), "-n1".into()], false).await?;
exec.input.write_all(b"ping\n").await.map_err(|e| e.to_string())?;
exec.input.flush().await.map_err(|e| e.to_string())?;
let id = exec.exec_id.clone();
let (o, e) = drain(exec).await;
same(&format!("{label}: attach echo"), ("ping\n".to_string(), String::new()), (o, e))?;
same(&format!("{label}: attach exit"), Some(0), t.wait_exec_exit(&id).await)?;
}
let mut sizes = Vec::new();
for t in [d, a] {
let exec = t
.attach(ExecSpec::new(sh("sleep 1; stty size")).tty(true))
.await?;
t.resize_exec(&exec.exec_id, 100, 40).await?;
let (o, _) = drain(exec).await;
sizes.push(o.trim().to_string());
}
same("tty size after resize", sizes[0].clone(), sizes[1].clone())?;
same("tty size value", "40 100".to_string(), sizes[1].clone())?;
// A tty session whose client goes away ends (SIGHUP), as a dropped
// `docker exec -t` does. Only checked through the agent: it is the agent's
// disconnect handling under test. Nothing else in this fresh container
// runs `sleep 600`, so counting exact matches is the probe.
let exec = a.attach(ExecSpec::new(vec!["sleep".into(), "600".into()]).tty(true)).await?;
tokio::time::sleep(Duration::from_millis(500)).await;
let count = sh("pgrep -xfc 'sleep 600' || true");
let before = a.exec_oneshot(count.clone()).await?;
drop(exec);
let mut after = String::new();
for _ in 0..40 {
tokio::time::sleep(Duration::from_millis(250)).await;
after = a.exec_oneshot(count.clone()).await?;
if after.trim() == "0" {
break;
}
}
same("tty session ends when its client goes away (sleep count before → after)", ("1".to_string(), "0".to_string()), (before.trim().to_string(), after.trim().to_string()))?;
// ── detached ─────────────────────────────────────────────────────────────
let mut codes = Vec::new();
for t in [d, a] {
let id = t.exec_detached(ExecSpec::new(sh("sleep 0.3; exit 5"))).await?;
codes.push(t.wait_exec_exit(&id).await);
}
same("detached exit code", codes[0], codes[1])?;
same("is_running", d.is_running().await?, a.is_running().await?)?;
Ok(())
}
#[tokio::test]
#[ignore = "needs a Docker daemon and an image whose entrypoint starts triple-c-agent (TRIPLEC_AGENT_E2E_IMAGE)"]
async fn the_agent_answers_exactly_as_docker_exec_does() {
let image = std::env::var("TRIPLEC_AGENT_E2E_IMAGE").unwrap_or_else(|_| "tc-agent-e2e:latest".into());
let token = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple());
let name = format!("tc-agent-e2e-{}", uuid::Uuid::new_v4().simple());
docker_cli(&[
"run", "-d", "--name", &name, "-w", "/workspace",
"-e", &format!("TRIPLEC_AGENT_TOKEN={token}"),
// Docker forwards a published port to the container's own address,
// not its loopback, so the harness binds the agent wide *inside* the
// container and publishes it to the host's loopback only.
"-e", "TRIPLEC_AGENT_ADDR=0.0.0.0:47900",
"-p", "127.0.0.1::47900",
&image,
]);
let outcome = async {
let mapped = docker_cli(&["port", &name, "47900/tcp"]);
let port: u16 = mapped.lines().next().and_then(|l| l.rsplit(':').next()).and_then(|p| p.parse().ok())
.ok_or_else(|| format!("no published port in {mapped:?}"))?;
// Two ways in, because where *this test* runs decides which one routes:
// on the host (where the app runs) the published loopback port; from
// inside another container on the same daemon, the published port is
// on the host's loopback, not ours, but the container's bridge address
// is reachable.
let ip = docker_cli(&["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", &name]);
let mut candidates = vec![AgentEndpoint::loopback(port, token.clone())];
for addr in ip.split_whitespace() {
let url = url::Url::parse(&format!("http://{addr}:47900/")).map_err(|e| e.to_string())?;
candidates.push(AgentEndpoint::new(url, token.clone())?);
}
let docker = ContainerTarget::docker(name.clone());
// The entrypoint starts the agent last, after setup (including a
// bounded `claude update`), so health is the readiness signal.
let mut agent = None;
'wait: for _ in 0..240 {
for ep in &candidates {
let t = ContainerTarget::Agent(ep.clone());
if t.is_running().await.unwrap_or(false) {
agent = Some(t);
break 'wait;
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
let Some(agent) = agent else {
return Err(format!("agent never answered on {candidates:?}; logs:\n{}", docker_cli(&["logs", "--tail", "40", &name])));
};
eprintln!("agent reached at {}", agent.id());
parity(&docker, &agent).await
}
.await;
let logs = docker_cli(&["logs", "--tail", "20", &name]);
let _ = Command::new("docker").args(["rm", "-f", &name]).output();
if let Err(e) = outcome {
panic!("{e}\n--- container log tail ---\n{logs}");
}
}
+673
View File
@@ -0,0 +1,673 @@
//! The agent client against a **fake** agent: a few axum handlers that speak
//! just enough of the v1 protocol to exercise each client path, and record
//! what they were sent. It is not the real server (that lives in `agent/`)
//! and does not try to behave like one beyond the wire shapes.
use super::*;
use crate::host::{ContainerTarget, OUTPUT_LIMIT_MARKER};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
/// Obviously fake; long enough to be unmistakable in an error string.
const TOKEN: &str = "fake-agent-token-for-unit-tests-only-0000";
#[derive(Default)]
struct Seen {
auth: Vec<String>,
exec_bodies: Vec<Value>,
detached_bodies: Vec<Value>,
status_polls: u32,
resizes: Vec<(String, Value)>,
uploads: Vec<(HashMap<String, String>, Vec<u8>)>,
attach_hello: Option<Value>,
stdin: Vec<u8>,
close_stdin: bool,
}
struct Fake {
seen: std::sync::Mutex<Seen>,
}
type St = State<Arc<Fake>>;
impl Fake {
fn authed(&self, h: &HeaderMap) -> bool {
let got = h
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let ok = got == format!("Bearer {}", TOKEN);
self.seen.lock().unwrap().auth.push(got);
ok
}
}
fn unauthorized() -> Response {
StatusCode::UNAUTHORIZED.into_response()
}
fn b64(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
async fn exec(State(f): St, h: HeaderMap, Json(body): Json<Value>) -> Response {
if !f.authed(&h) {
return unauthorized();
}
f.seen.lock().unwrap().exec_bodies.push(body.clone());
match body["cmd"][0].as_str().unwrap_or("") {
"sh" => Json(json!({"chunks": [{"s": "out", "d": b64(b"1000\n1001\n")}], "exit_code": 0}))
.into_response(),
// stdout and stderr interleaved; `ü` split across two stdout chunks;
// one byte that is not UTF-8 at all on stderr.
"mixed" => Json(json!({
"chunks": [
{"s": "out", "d": b64(b"first ")},
{"s": "err", "d": b64(b"find: /x: denied\n")},
{"s": "out", "d": b64(&[0xc3])},
{"s": "out", "d": b64(&[0xbc, b'!'])},
{"s": "err", "d": b64(&[0xff])},
],
"exit_code": 3
}))
.into_response(),
"big" => (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({"error": "OUTPUT_LIMIT: Command output exceeded 5 bytes and was abandoned"})),
)
.into_response(),
_ => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "boom"}))).into_response(),
}
}
async fn detached(State(f): St, h: HeaderMap, Json(body): Json<Value>) -> Response {
if !f.authed(&h) {
return unauthorized();
}
f.seen.lock().unwrap().detached_bodies.push(body);
Json(json!({"exec_id": "d1"})).into_response()
}
/// `d1` reports running for two polls, then exit 7. Everything else is 404 —
/// including the attach's `a1`, so an attach's exit can only have come from
/// the client's own record of the `exit` frame.
async fn status(State(f): St, h: HeaderMap, Path(id): Path<String>) -> Response {
if !f.authed(&h) {
return unauthorized();
}
if id != "d1" {
return (StatusCode::NOT_FOUND, Json(json!({"error": "no such exec"}))).into_response();
}
let mut s = f.seen.lock().unwrap();
s.status_polls += 1;
if s.status_polls < 3 {
Json(json!({"running": true, "exit_code": null})).into_response()
} else {
Json(json!({"running": false, "exit_code": 7})).into_response()
}
}
async fn resize(State(f): St, h: HeaderMap, Path(id): Path<String>, Json(body): Json<Value>) -> Response {
if !f.authed(&h) {
return unauthorized();
}
f.seen.lock().unwrap().resizes.push((id, body));
StatusCode::NO_CONTENT.into_response()
}
async fn files(
State(f): St,
h: HeaderMap,
Query(q): Query<HashMap<String, String>>,
body: Bytes,
) -> Response {
if !f.authed(&h) {
return unauthorized();
}
let path = format!("{}/{}", q["dir"], q["name"]);
f.seen.lock().unwrap().uploads.push((q, body.to_vec()));
Json(json!({"path": path})).into_response()
}
async fn archive(State(f): St, h: HeaderMap, Query(q): Query<HashMap<String, String>>) -> Response {
if !f.authed(&h) {
return unauthorized();
}
if q["path"] == "/missing" {
return (StatusCode::NOT_FOUND, Json(json!({"error": "no such path"}))).into_response();
}
let parts: Vec<Result<Bytes, std::io::Error>> = vec![
Ok(Bytes::from_static(b"tar-part-1;")),
Ok(Bytes::from_static(b"tar-part-2;")),
Ok(Bytes::from(q["path"].clone().into_bytes())),
];
axum::body::Body::from_stream(futures_util::stream::iter(parts)).into_response()
}
async fn health(State(f): St, h: HeaderMap) -> Response {
if !f.authed(&h) {
return unauthorized();
}
Json(json!({"agent": "triple-c-agent", "version": "0.0.0-fake", "protocol": 1})).into_response()
}
async fn attach_ws(State(f): St, h: HeaderMap, ws: WebSocketUpgrade) -> Response {
if !f.authed(&h) {
return unauthorized();
}
ws.on_upgrade(move |socket| fake_attach(f, socket))
}
/// started → stdout, stderr, an unknown channel → collect stdin until
/// `close_stdin` → echo it on stdout → exit 5 → close.
async fn fake_attach(f: Arc<Fake>, mut ws: WebSocket) {
let Some(Ok(WsMessage::Text(hello))) = ws.recv().await else { return };
let hello: Value = serde_json::from_str(hello.as_str()).unwrap();
let refuse = hello["cmd"][0] == "nope";
f.seen.lock().unwrap().attach_hello = Some(hello);
if refuse {
let _ = ws
.send(WsMessage::Text(r#"{"type":"error","error":"no such user"}"#.into()))
.await;
return;
}
let _ = ws
.send(WsMessage::Text(r#"{"type":"started","exec_id":"a1"}"#.into()))
.await;
for frame in [&b"\x01out"[..], b"\x02err", b"\x09odd"] {
let _ = ws.send(WsMessage::Binary(Bytes::copy_from_slice(frame))).await;
}
while let Some(Ok(msg)) = ws.recv().await {
match msg {
WsMessage::Binary(b) => f.seen.lock().unwrap().stdin.extend_from_slice(&b),
WsMessage::Text(t) if t.as_str().contains("close_stdin") => {
f.seen.lock().unwrap().close_stdin = true;
break;
}
_ => {}
}
}
let mut echo = vec![1u8];
echo.extend_from_slice(&f.seen.lock().unwrap().stdin);
let _ = ws.send(WsMessage::Binary(echo.into())).await;
let _ = ws.send(WsMessage::Text(r#"{"type":"exit","code":5}"#.into())).await;
let _ = ws.close().await;
}
async fn fake_agent() -> (AgentEndpoint, Arc<Fake>) {
let fake = Arc::new(Fake {
seen: std::sync::Mutex::new(Seen::default()),
});
let app = Router::new()
.route("/v1/health", get(health))
.route("/v1/exec", post(exec))
.route("/v1/exec/detached", post(detached))
.route("/v1/exec/{id}", get(status))
.route("/v1/exec/{id}/resize", post(resize))
.route("/v1/files", put(files))
.route("/v1/archive", get(archive))
.route("/v1/attach", get(attach_ws))
.with_state(fake.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(AgentEndpoint::loopback(port, TOKEN), fake)
}
fn target(ep: &AgentEndpoint) -> ContainerTarget {
ContainerTarget::Agent(ep.clone())
}
fn cmd(c: &str) -> Vec<String> {
vec![c.to_string()]
}
// ── endpoint ─────────────────────────────────────────────────────────────────
#[test]
fn the_token_never_appears_in_debug_output() {
let ep = AgentEndpoint::loopback(47900, TOKEN);
let dbg = format!("{:?} {:?}", ep, ContainerTarget::Agent(ep.clone()));
assert!(!dbg.contains(TOKEN), "{}", dbg);
assert!(dbg.contains("127.0.0.1:47900"));
assert!(dbg.contains("redacted"));
}
#[test]
fn an_agent_target_is_identified_by_its_base_url() {
let a = ContainerTarget::Agent(AgentEndpoint::loopback(47900, TOKEN));
assert_eq!(a.id(), "http://127.0.0.1:47900/");
assert_eq!(a, ContainerTarget::Agent(AgentEndpoint::loopback(47900, TOKEN)));
assert_ne!(a, ContainerTarget::Agent(AgentEndpoint::loopback(47901, TOKEN)));
let mut set = std::collections::HashSet::new();
set.insert(a.clone());
assert!(set.contains(&a));
}
#[test]
fn only_plain_http_endpoints_are_accepted() {
let ok = AgentEndpoint::new(url::Url::parse("http://127.0.0.1:1/x").unwrap(), TOKEN).unwrap();
assert_eq!(ok.base_url.as_str(), "http://127.0.0.1:1/x/");
assert_eq!(ok.url("v1/health").as_str(), "http://127.0.0.1:1/x/v1/health");
assert_eq!(ok.ws_url("v1/attach").as_str(), "ws://127.0.0.1:1/x/v1/attach");
assert!(AgentEndpoint::new(url::Url::parse("https://h/").unwrap(), TOKEN).is_err());
assert!(AgentEndpoint::new(url::Url::parse("file:///tmp").unwrap(), TOKEN).is_err());
}
// ── auth ─────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn every_request_carries_the_bearer_token() {
let (ep, fake) = fake_agent().await;
let t = target(&ep);
assert!(t.is_running().await.unwrap());
t.exec_oneshot(cmd("sh")).await.unwrap();
let auth = fake.seen.lock().unwrap().auth.clone();
assert_eq!(auth.len(), 2);
assert!(auth.iter().all(|a| a == &format!("Bearer {}", TOKEN)));
}
#[tokio::test]
async fn a_wrong_token_is_an_error_that_does_not_carry_any_token() {
let (ep, _) = fake_agent().await;
let wrong_token = "wrong-fake-token-for-unit-tests-only-1111";
let bad = ContainerTarget::Agent(AgentEndpoint::new(ep.base_url.clone(), wrong_token).unwrap());
let e = bad.exec_oneshot(cmd("sh")).await.unwrap_err();
assert!(e.contains("401"), "{}", e);
assert!(e.starts_with("Failed to start exec"), "{}", e);
let Err(attach_err) = bad.attach(ExecSpec::new(cmd("sh"))).await else {
panic!("attach with a wrong token succeeded")
};
assert!(attach_err.contains("401"), "{}", attach_err);
let health_err = bad.is_running().await.unwrap_err();
let up_err = bad.upload_bytes("/tmp", "x", b"x", 0o644).await.unwrap_err();
for e in [&e, &attach_err, &health_err, &up_err] {
assert!(!e.contains(wrong_token) && !e.contains(TOKEN), "{}", e);
}
}
#[tokio::test]
async fn other_errors_carry_the_agents_message_and_no_token() {
let (ep, _) = fake_agent().await;
let e = target(&ep).exec_oneshot(cmd("fail")).await.unwrap_err();
assert_eq!(e, "Failed to start exec: boom");
}
// ── one-shot execs ───────────────────────────────────────────────────────────
#[tokio::test]
async fn every_oneshot_variant_reassembles_what_docker_would_return() {
let (ep, fake) = fake_agent().await;
let t = target(&ep);
// Arrival order, `ü` rejoined across chunks, the stray 0xff lossy.
let combined = "first find: /x: denied\nü!\u{fffd}";
let env = vec!["K=V".to_string()];
assert_eq!(t.exec_oneshot(cmd("mixed")).await.unwrap(), combined);
assert_eq!(t.exec_oneshot_env(cmd("mixed"), env.clone()).await.unwrap(), combined);
assert_eq!(
t.exec_oneshot_env_status(cmd("mixed"), env.clone()).await.unwrap(),
(combined.to_string(), 3)
);
assert_eq!(
t.exec_oneshot_as("root", cmd("mixed"), env.clone()).await.unwrap(),
(combined.to_string(), 3)
);
assert_eq!(
t.exec_oneshot_limited("root", cmd("mixed"), 4096).await.unwrap(),
(combined.to_string(), 3)
);
assert_eq!(
t.exec_oneshot_streams_as("root", cmd("mixed"), env.clone()).await.unwrap(),
("first ü!".to_string(), "find: /x: denied\n\u{fffd}".to_string(), 3)
);
let bodies = fake.seen.lock().unwrap().exec_bodies.clone();
let max = MAX_ONESHOT_OUTPUT as u64;
let expect = [
("claude", json!([]), max),
("claude", json!(["K=V"]), max),
("claude", json!(["K=V"]), max),
("root", json!(["K=V"]), max),
("root", json!([]), 4096),
("root", json!(["K=V"]), max),
];
assert_eq!(bodies.len(), expect.len());
for (b, (user, env, limit)) in bodies.iter().zip(expect) {
assert_eq!(b["user"], user);
assert_eq!(b["env"], env);
assert_eq!(b["limit"].as_u64(), Some(limit));
assert_eq!(b["cmd"], json!(["mixed"]));
// Docker's one-shot names no working dir and no tty.
assert_eq!(b["working_dir"], Value::Null);
assert_eq!(b["tty"], false);
}
}
#[tokio::test]
async fn an_output_limit_refusal_passes_through_verbatim() {
let (ep, _) = fake_agent().await;
let e = target(&ep).exec_oneshot_limited("claude", cmd("big"), 5).await.unwrap_err();
assert_eq!(e, "OUTPUT_LIMIT: Command output exceeded 5 bytes and was abandoned");
assert!(e.starts_with(OUTPUT_LIMIT_MARKER));
}
#[tokio::test]
async fn the_client_also_enforces_the_limit_on_what_it_buffers() {
// An agent that ignored `limit` still cannot make the client buffer past it.
let (ep, _) = fake_agent().await;
let e = target(&ep).exec_oneshot_limited("claude", cmd("mixed"), 8).await.unwrap_err();
assert!(e.starts_with(OUTPUT_LIMIT_MARKER), "{}", e);
}
#[tokio::test]
async fn user_ids_come_from_the_same_probe_as_docker() {
let (ep, fake) = fake_agent().await;
assert_eq!(target(&ep).user_ids().await, (1000, 1001));
let b = fake.seen.lock().unwrap().exec_bodies[0].clone();
assert_eq!(b["cmd"], json!(["sh", "-c", "id -u; id -g"]));
assert_eq!(b["user"], "claude");
assert_eq!(b["limit"], 256);
}
// ── detached, status, resize ─────────────────────────────────────────────────
#[tokio::test]
async fn a_detached_exec_is_polled_until_it_reports_its_exit() {
let (ep, fake) = fake_agent().await;
let t = target(&ep);
let spec = ExecSpec::new(cmd("sleep")).user("root").working_dir(None).no_stdin();
let id = t.exec_detached(spec).await.unwrap();
assert_eq!(id, "d1");
assert_eq!(t.wait_exec_exit(&id).await, Some(7));
let s = fake.seen.lock().unwrap();
assert_eq!(s.status_polls, 3);
assert_eq!(s.detached_bodies[0]["user"], "root");
assert_eq!(s.detached_bodies[0]["working_dir"], Value::Null);
drop(s);
assert_eq!(t.wait_exec_exit("unknown").await, None);
}
#[tokio::test]
async fn resize_posts_cols_and_rows() {
let (ep, fake) = fake_agent().await;
target(&ep).resize_exec("a1", 120, 40).await.unwrap();
let r = fake.seen.lock().unwrap().resizes.clone();
assert_eq!(r, vec![("a1".to_string(), json!({"cols": 120, "rows": 40}))]);
}
// ── attach ───────────────────────────────────────────────────────────────────
#[tokio::test]
async fn attach_maps_channels_forwards_stdin_and_ends_on_exit() {
let (ep, fake) = fake_agent().await;
let t = target(&ep);
let spec = ExecSpec::new(cmd("cat")).env(vec!["A=1".into()]).tty(true);
let AttachedExec {
exec_id,
mut output,
mut input,
} = t.attach(spec).await.unwrap();
assert_eq!(exec_id, "a1");
assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::StdOut(Bytes::from_static(b"out")));
assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::StdErr(Bytes::from_static(b"err")));
assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::Other(Bytes::from_static(b"odd")));
input.write_all(b"hello ").await.unwrap();
input.write_all(b"agent").await.unwrap();
input.shutdown().await.unwrap();
assert_eq!(
output.next().await.unwrap().unwrap(),
ExecChunk::StdOut(Bytes::from_static(b"hello agent"))
);
// The exit frame ends the stream…
assert!(output.next().await.is_none());
// …and is what answers wait_exec_exit (the fake 404s `a1`).
assert_eq!(t.wait_exec_exit(&exec_id).await, Some(5));
let s = fake.seen.lock().unwrap();
assert!(s.close_stdin);
assert_eq!(s.stdin, b"hello agent");
let hello = s.attach_hello.clone().unwrap();
assert_eq!(hello["cmd"], json!(["cat"]));
assert_eq!(hello["user"], "claude");
assert_eq!(hello["working_dir"], "/workspace");
assert_eq!(hello["env"], json!(["A=1"]));
assert_eq!(hello["tty"], true);
assert_eq!(hello["attach_stdin"], true);
}
#[tokio::test]
async fn an_attach_the_agent_refuses_is_an_error() {
let (ep, _) = fake_agent().await;
let Err(e) = target(&ep).attach(ExecSpec::new(cmd("nope"))).await else {
panic!("a refused attach succeeded")
};
assert_eq!(e, "Failed to start exec: no such user");
}
// ── files ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn uploads_put_the_bytes_with_dir_name_mode_and_owner() {
let (ep, fake) = fake_agent().await;
let t = target(&ep);
assert_eq!(
t.upload_bytes_with_ids("/tmp/", "x.txt", b"hi", 0o755, (1000, 1001))
.await
.unwrap(),
"/tmp/x.txt"
);
assert_eq!(
t.upload_bytes("/home/claude/in", "p.tar", b"tar", 0o644).await.unwrap(),
"/home/claude/in/p.tar"
);
// The terminal drop's nested name: Docker's tar entry would land it in the
// subdirectory, so the agent is asked for that directory.
assert_eq!(
t.upload_bytes_with_ids("/tmp", "triple-c-drops/a.txt", b"a", 0o644, (1, 2))
.await
.unwrap(),
"/tmp/triple-c-drops/a.txt"
);
assert!(t.upload_bytes("/tmp", "../etc/x", b"", 0o644).await.is_err());
let up = fake.seen.lock().unwrap().uploads.clone();
let q = |i: usize, k: &str| up[i].0[k].clone();
assert_eq!((q(0, "dir"), q(0, "name"), q(0, "mode")), ("/tmp".into(), "x.txt".into(), "755".into()));
assert_eq!((q(0, "uid"), q(0, "gid")), ("1000".into(), "1001".into()));
assert_eq!(up[0].1, b"hi");
assert_eq!((q(1, "uid"), q(1, "gid"), q(1, "mode")), ("0".into(), "0".into(), "644".into()));
assert_eq!((q(2, "dir"), q(2, "name")), ("/tmp/triple-c-drops".into(), "a.txt".into()));
assert_eq!(up.len(), 3);
}
#[tokio::test]
async fn a_host_file_upload_reads_through_the_shared_checked_reader() {
let (ep, fake) = fake_agent().await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.txt");
std::fs::write(&path, b"from the host").unwrap();
let path = path.canonicalize().unwrap();
let t = target(&ep);
let got = t
.upload_host_file_with_ids(path.to_str().unwrap(), "/workspace/", "notes.txt", (7, 8))
.await
.unwrap();
assert_eq!(got, "/workspace/notes.txt");
// Without ids: resolved through the uid probe first.
t.upload_host_file(path.to_str().unwrap(), "/tmp", "n2.txt").await.unwrap();
let up = fake.seen.lock().unwrap().uploads.clone();
assert_eq!(up[0].1, b"from the host");
assert_eq!(up[0].0["mode"], "644");
assert_eq!((up[0].0["uid"].as_str(), up[0].0["gid"].as_str()), ("7", "8"));
assert_eq!((up[1].0["uid"].as_str(), up[1].0["gid"].as_str()), ("1000", "1001"));
let missing = dir.path().join("gone.txt");
let e = t
.upload_host_file_with_ids(missing.to_str().unwrap(), "/tmp", "g", (1, 1))
.await
.unwrap_err();
assert!(e.starts_with("Failed to read"), "{}", e);
}
#[tokio::test]
async fn an_archive_is_streamed_and_a_missing_path_is_an_error() {
let (ep, _) = fake_agent().await;
let t = target(&ep);
let mut s = t.download_archive("/workspace/a dir");
let mut got = Vec::new();
while let Some(chunk) = s.next().await {
got.extend_from_slice(&chunk.unwrap());
}
assert_eq!(got, b"tar-part-1;tar-part-2;/workspace/a dir");
let mut s = t.download_archive("/missing");
let e = s.next().await.unwrap().unwrap_err();
assert_eq!(e, "Failed to download archive: no such path");
assert!(s.next().await.is_none());
}
// ── state ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn is_running_follows_whether_the_agent_answers() {
let (ep, _) = fake_agent().await;
assert!(target(&ep).is_running().await.unwrap());
assert!(!target(&ep).has_exited().await);
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
drop(l);
let gone = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN));
assert!(!gone.is_running().await.unwrap());
// Silence is not a positive exit.
assert!(!gone.has_exited().await);
}
// ── a hostile agent ──────────────────────────────────────────────────────────
// The agent runs inside the container, which is not trusted with the host.
/// Serve `app` on an ephemeral loopback port; return the port.
async fn serve(app: Router) -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
port
}
#[tokio::test]
async fn a_redirect_from_the_agent_is_not_followed() {
let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let elsewhere = {
let hits = hits.clone();
serve(Router::new().fallback(move || {
let hits = hits.clone();
async move {
hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
"followed"
}
}))
.await
};
let to = format!("http://127.0.0.1:{}/anything", elsewhere);
let agent = serve(Router::new().fallback(move || {
let to = to.clone();
async move { (StatusCode::TEMPORARY_REDIRECT, [(axum::http::header::LOCATION, to)]) }
}))
.await;
let t = ContainerTarget::Agent(AgentEndpoint::loopback(agent, TOKEN));
assert!(t.exec_detached(ExecSpec::new(cmd("true"))).await.is_err());
assert!(t.exec_oneshot(cmd("true")).await.is_err());
assert!(t.is_running().await.is_err(), "a 3xx health answer is not 'running'");
assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 0, "the client followed a redirect");
}
#[tokio::test]
async fn an_oversized_error_body_is_neither_buffered_nor_passed_through_whole() {
// 8 MiB of error text: past the small-body cap, so it is not even parsed.
let huge = serve(Router::new().fallback(|| async {
let body = json!({ "error": "x".repeat(8 * 1024 * 1024) });
(StatusCode::INTERNAL_SERVER_ERROR, Json(body))
}))
.await;
let e = ContainerTarget::Agent(AgentEndpoint::loopback(huge, TOKEN))
.exec_detached(ExecSpec::new(cmd("true")))
.await
.unwrap_err();
assert!(e.len() < 1024, "error was {} bytes", e.len());
assert!(e.contains("500"), "{e}");
// Under the body cap but long: parsed, then cut to the display cap.
let long = serve(Router::new().fallback(|| async {
let body = json!({ "error": "é".repeat(20 * 1024) });
(StatusCode::INTERNAL_SERVER_ERROR, Json(body))
}))
.await;
let e = ContainerTarget::Agent(AgentEndpoint::loopback(long, TOKEN))
.exec_detached(ExecSpec::new(cmd("true")))
.await
.unwrap_err();
assert!(e.len() <= 4 * 1024 + 64, "error was {} bytes", e.len());
assert!(e.ends_with('…'), "{e}");
}
#[tokio::test]
async fn an_agent_that_accepts_and_then_says_nothing_cannot_hang_the_small_calls() {
// Accepts every connection, reads nothing, answers nothing, holds it open.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((sock, _)) = listener.accept().await {
held.push(sock);
}
});
let t = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN));
let started = std::time::Instant::now();
assert!(t.exec_detached(ExecSpec::new(cmd("true"))).await.is_err());
assert!(t.resize_exec("abc", 80, 24).await.is_err());
assert_eq!(t.wait_exec_exit("abc").await, None);
// Three calls, each bounded by SMALL_REQUEST_TIMEOUT (1 s under test).
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
}
#[test]
fn a_token_that_cannot_be_a_header_value_is_refused_up_front() {
let url = url::Url::parse("http://127.0.0.1:1/").unwrap();
for bad in ["", "has space", "new\nline", "tab\there", "ünïcode"] {
assert!(AgentEndpoint::new(url.clone(), bad).is_err(), "{bad:?} accepted");
}
assert!(AgentEndpoint::new(url, TOKEN).is_ok());
}
#[tokio::test]
async fn a_413_that_is_not_the_limit_message_is_not_passed_through() {
let port = serve(Router::new().fallback(|| async {
(StatusCode::PAYLOAD_TOO_LARGE, Json(json!({ "error": "click here: http://evil.example" })))
}))
.await;
let e = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN))
.exec_oneshot_limited("claude", cmd("true"), 1000)
.await
.unwrap_err();
assert!(e.starts_with(OUTPUT_LIMIT_MARKER), "{e}");
assert!(!e.contains("evil"), "{e}");
}
+333
View File
@@ -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...",
]
);
}
}
+124 -27
View File
@@ -7,8 +7,9 @@
//! "is it running" — goes through [`ContainerTarget`] instead of naming
//! `docker::exec` or bollard directly. Today the only variant is
//! [`ContainerTarget::Docker`], which delegates to the existing code in
//! `docker/exec.rs` and `docker/container.rs` with no change in behaviour. An
//! AWS variant (reached through the in-container agent) slots in beside it.
//! `docker/exec.rs` and `docker/container.rs` with no change in behaviour.
//! [`ContainerTarget::Agent`] reaches a container through the in-container
//! `triple-c-agent` instead (`agent.rs`), which is how the AWS host will get in.
//!
//! ## Why an enum and not a trait object
//!
@@ -26,11 +27,19 @@
//! `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;
// Nothing in the app opens an SSM tunnel until the AWS host lands (phase 5 of
// the AWS spec); its tests exercise it meanwhile. Remove this allow then.
#[allow(dead_code)]
pub mod ssm;
pub mod watch;
pub use crate::docker::exec::{
MAX_DROP_BYTES, OUTPUT_LIMIT_MARKER, PROC_NET_OUTPUT_LIMIT,
};
pub use agent::AgentEndpoint;
pub use sessions::ExecSessionManager;
use bytes::Bytes;
@@ -130,6 +139,12 @@ pub type ArchiveStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send
pub enum ContainerTarget {
/// A container on the local Docker daemon, by id or name.
Docker { container_id: String },
/// A container reached through the in-container `triple-c-agent`
/// (`host/agent.rs`; protocol in
/// `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`). Used
/// by the AWS host (later) and the local agent harness. The host that
/// runs the container, not this target, owns its lifecycle.
Agent(AgentEndpoint),
}
impl ContainerTarget {
@@ -149,6 +164,7 @@ impl ContainerTarget {
pub fn id(&self) -> &str {
match self {
ContainerTarget::Docker { container_id } => container_id,
ContainerTarget::Agent(ep) => ep.base_url.as_str(),
}
}
@@ -161,6 +177,7 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::container::is_container_running(container_id).await
}
ContainerTarget::Agent(ep) => agent::is_running(ep).await,
}
}
@@ -173,6 +190,9 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot(container_id, cmd).await
}
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, Vec::new())
.await
.map(|(out, _)| out),
}
}
@@ -185,6 +205,9 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_env(container_id, cmd, env).await
}
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, env)
.await
.map(|(out, _)| out),
}
}
@@ -197,6 +220,7 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_env_status(container_id, cmd, env).await
}
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, env).await,
}
}
@@ -210,6 +234,7 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_as(container_id, user, cmd, env).await
}
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, user, cmd, env).await,
}
}
@@ -223,6 +248,9 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_limited(container_id, user, cmd, limit).await
}
ContainerTarget::Agent(ep) => {
agent::exec_oneshot_limited(ep, user, cmd, Vec::new(), limit).await
}
}
}
@@ -237,6 +265,7 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_oneshot_streams_as(container_id, user, cmd, env).await
}
ContainerTarget::Agent(ep) => agent::exec_oneshot_streams_as(ep, user, cmd, env).await,
}
}
@@ -247,6 +276,7 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::exec_detached(container_id, &spec).await
}
ContainerTarget::Agent(ep) => agent::exec_detached(ep, &spec).await,
}
}
@@ -262,6 +292,7 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::create_attached_exec_spec(container_id, &spec).await
}
ContainerTarget::Agent(ep) => agent::attach(ep, &spec).await,
}
}
@@ -270,12 +301,14 @@ impl ContainerTarget {
pub async fn wait_exec_exit(&self, exec_id: &str) -> Option<i64> {
match self {
ContainerTarget::Docker { .. } => docker::exec::wait_for_exec_exit(exec_id).await,
ContainerTarget::Agent(ep) => agent::wait_exec_exit(ep, exec_id).await,
}
}
pub async fn resize_exec(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
match self {
ContainerTarget::Docker { .. } => docker::exec::resize_exec(exec_id, cols, rows).await,
ContainerTarget::Agent(ep) => agent::resize_exec(ep, exec_id, cols, rows).await,
}
}
@@ -287,6 +320,7 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::container_user_ids(container_id).await
}
ContainerTarget::Agent(ep) => agent::user_ids(ep).await,
}
}
@@ -309,6 +343,9 @@ impl ContainerTarget {
)
.await
}
ContainerTarget::Agent(ep) => {
agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, (0, 0)).await
}
}
}
@@ -333,6 +370,9 @@ impl ContainerTarget {
)
.await
}
ContainerTarget::Agent(ep) => {
agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, ids).await
}
}
}
@@ -353,6 +393,10 @@ impl ContainerTarget {
)
.await
}
ContainerTarget::Agent(ep) => {
let ids = agent::user_ids(ep).await;
agent::upload_host_file_with_ids(ep, host_path, dest_dir, dest_name, ids).await
}
}
}
@@ -375,6 +419,9 @@ impl ContainerTarget {
)
.await
}
ContainerTarget::Agent(ep) => {
agent::upload_host_file_with_ids(ep, host_path, dest_dir, dest_name, ids).await
}
}
}
@@ -385,6 +432,7 @@ impl ContainerTarget {
ContainerTarget::Docker { container_id } => {
docker::exec::download_archive(container_id, path)
}
ContainerTarget::Agent(ep) => agent::download_archive(ep, path),
}
}
}
@@ -421,22 +469,11 @@ mod tests {
assert_eq!(ContainerTarget::docker("abc").id(), "abc");
}
/// Phase 1's boundary, pinned: outside `docker/` and `host/`, only the
/// files listed here may name bollard or `docker::exec`. Each entry is
/// Docker-only by nature (migration, snapshots, sidecars) or is scheduled
/// for phase 1b (lifecycle, the `die` event stream). Shrink this list; do
/// not grow it — new container-interior code goes through
/// [`ContainerTarget`].
#[test]
fn no_container_interior_bollard_outside_the_host_layer() {
const ALLOWED: &[&str] = &[
// Docker-only: base-image migration replays apt / restores a payload.
"commands/migration_commands.rs",
// Phase 1b: lifecycle and the `die` event stream.
"container_watch.rs",
];
/// Every non-`docker/`, non-`host/` source file, with its path relative to
/// `src/`, minus the files in `allowed`.
fn sources_outside_the_host_layer(allowed: &[&str]) -> Vec<(String, String)> {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let mut out = Vec::new();
let mut stack = vec![src.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
@@ -452,23 +489,83 @@ mod tests {
}
continue;
}
if !rel.ends_with(".rs") || ALLOWED.contains(&rel.as_str()) {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
if text.contains("bollard::")
|| text.contains("docker::exec")
|| text.contains("get_docker")
{
offenders.push(rel);
if rel.ends_with(".rs") && !allowed.contains(&rel.as_str()) {
out.push((rel, std::fs::read_to_string(&path).unwrap()));
}
}
}
offenders.sort();
out.sort();
out
}
/// Docker-only by nature: base-image migration replays apt, restores a
/// payload, commits and swaps containers. It has no AWS meaning.
const DOCKER_ONLY: &[&str] = &["commands/migration_commands.rs"];
/// The host boundary, pinned: outside `docker/` and `host/`, nothing names
/// bollard or `docker::exec`. Container-interior code goes through
/// [`ContainerTarget`]. Shrink [`DOCKER_ONLY`]; do not grow it.
#[test]
fn no_container_interior_bollard_outside_the_host_layer() {
let offenders: Vec<String> = sources_outside_the_host_layer(DOCKER_ONLY)
.into_iter()
.filter(|(_, text)| {
text.contains("bollard::")
|| text.contains("docker::exec")
|| text.contains("get_docker")
})
.map(|(rel, _)| rel)
.collect();
assert!(
offenders.is_empty(),
"these files reach into the container without going through host::ContainerTarget: {:?}",
offenders
);
}
/// The lifecycle half of the boundary: outside `docker/` and `host/`, no
/// non-test code creates, starts, stops, finds or recreates a container
/// itself (that is [`lifecycle::ProjectHost`]'s job), and none builds a
/// [`ContainerTarget::docker`] from a bare id, which would hard-code the
/// host. Comments and `#[cfg(test)]` code are not checked.
///
/// `docker::remove_container` is deliberately absent: startup's
/// pending-cleanup retry removes Docker leftovers it recorded *by name*,
/// which is Docker housekeeping, not lifecycle.
#[test]
fn no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer() {
const BANNED: &[&str] = &[
"docker::create_container(",
"docker::start_container(",
"docker::stop_container(",
"docker::find_existing_container(",
"docker::container_needs_recreation(",
"docker::commit_container_snapshot(",
"docker::remove_snapshot_image(",
"docker::remove_project_volumes(",
"docker::is_container_running(",
"container::is_container_running(",
"ContainerTarget::docker(",
];
let mut offenders = Vec::new();
for (rel, text) in sources_outside_the_host_layer(DOCKER_ONLY) {
let code = text.split("#[cfg(test)]").next().unwrap_or("");
for (n, line) in code.lines().enumerate() {
let line = line.trim_start();
if line.starts_with("//") {
continue;
}
for pat in BANNED {
if line.contains(pat) {
offenders.push(format!("{}:{}: {}", rel, n + 1, pat));
}
}
}
}
assert!(
offenders.is_empty(),
"container lifecycle / bare Docker targets outside host::lifecycle:\n{}",
offenders.join("\n")
);
}
}
+842
View File
@@ -0,0 +1,842 @@
//! The transport to an agent in an ECS task: one SSM port-forwarding session
//! (`AWS-StartPortForwardingSession`) from a loopback port on this machine to
//! the agent's port inside the task, kept open for as long as the project is
//! connected and reopened when it drops. Everything else — execs, files,
//! tunnels — then goes through [`AgentEndpoint`] exactly as it does locally.
//! Design: `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`
//! ("Connectivity").
//!
//! The SSM data channel is a custom websocket protocol the Rust SDK does not
//! implement, so v1 runs AWS's `session-manager-plugin` as a child process,
//! the way the AWS CLI does: the app calls `ssm:StartSession` itself and hands
//! the plugin the stream URL and token.
//!
//! Three things here are deliberate:
//!
//! - **The session token never goes on a command line.** The plugin reads the
//! `StartSession` response from the environment variable named by its first
//! argument (`AWS_SSM_START_SESSION_RESPONSE`, what the AWS CLI does for
//! plugins that support it), so it is not visible in `ps`.
//! - **The plugin gets the caller's current credentials in its environment,
//! and nothing else of AWS's.** It needs them only to resume its data
//! channel after a network blip (`ssm:ResumeSession`), which keeps every TCP
//! connection through the tunnel — open terminals included — alive. Any
//! `AWS_*` variable this process happens to have is removed first, so the
//! plugin can never act as a different identity than the one that started
//! the session. When those credentials have expired a resume fails, the
//! plugin exits, and the supervisor here opens a fresh session with fresh
//! credentials on the **same local port**, so an [`AgentEndpoint`] built
//! once stays valid across reconnects (open connections do not survive that
//! path; new ones succeed).
//! - **Whoever holds a [`PortForward`] must `close()` it when the app exits.**
//! Tauri exits through `std::process::exit`, which drops no runtime tasks,
//! so `kill_on_drop` never fires and the plugin would keep the session and
//! the port until SSM's idle timeout.
//! - **The plugin listens on `127.0.0.1` only** (checked: plugin 1.2.896), and
//! the agent behind it still wants its bearer token. The SSM session's IAM
//! check is the outer gate.
use aws_config::SdkConfig;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::watch;
use tokio_util::sync::{CancellationToken, DropGuard};
use zeroize::Zeroizing;
use super::AgentEndpoint;
/// Where `triple-c-agent` listens inside the task (its default).
pub const AGENT_PORT: u16 = 47900;
const PORT_FORWARD_DOCUMENT: &str = "AWS-StartPortForwardingSession";
/// The environment variable the plugin reads the `StartSession` response from.
const RESPONSE_ENV: &str = "AWS_SSM_START_SESSION_RESPONSE";
/// What the plugin prints once its local listener is up.
const READY_LINE: &str = "Waiting for connections";
/// Plugin start to listener up. Measured at ~1-2 s; this bounds a wedged one.
const READY_TIMEOUT: Duration = if cfg!(test) {
Duration::from_secs(2)
} else {
Duration::from_secs(30)
};
/// The last lines of plugin output kept for error messages.
const OUTPUT_TAIL_LINES: usize = 20;
/// Reconnect delays; the last repeats. A reconnect that keeps failing for
/// about ten minutes gives up ([`TunnelState::Failed`]) — by then the task is
/// gone or the credentials need the user, and the host layer decides what the
/// project's state is.
const RECONNECT_BACKOFF: &[Duration] = &[
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(5),
Duration::from_secs(10),
Duration::from_secs(30),
];
const MAX_RECONNECT_ATTEMPTS: u32 = 25;
/// Attempts to find a free local port for the first connection.
const INITIAL_PORT_ATTEMPTS: u32 = 3;
const TERMINATE_TIMEOUT: Duration = Duration::from_secs(5);
/// How often an Up tunnel sends traffic of its own. Session Manager ends a
/// session after 20 minutes with no data (its default `idleSessionTimeout`;
/// measured: a silent tunnel was cut at 19.7 min), and with it every TCP
/// connection inside — an idle terminal tab included.
const KEEPALIVE_INTERVAL: Duration = if cfg!(test) {
Duration::from_millis(200)
} else {
Duration::from_secs(5 * 60)
};
const KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
// ── The task ─────────────────────────────────────────────────────────────────
/// An ECS task container that an SSM session can target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EcsTask {
/// The cluster's *name* (the SSM target wants the name, not the ARN).
pub cluster: String,
pub task_id: String,
/// The container's runtime id, from `DescribeTasks`.
pub runtime_id: String,
}
impl EcsTask {
/// `ecs:<cluster>_<taskId>_<runtimeId>`, the SSM target for one container
/// of one task.
pub fn ssm_target(&self) -> String {
format!("ecs:{}_{}_{}", self.cluster, self.task_id, self.runtime_id)
}
/// Look up `container` in `task` (an id or ARN) on `cluster` (a name or
/// ARN), and check it can take a session now: the task is running and
/// its ECS Exec agent is up. Fargate starts that agent a few seconds after
/// the container, so "not ready yet" is a normal answer just after start.
pub async fn resolve(
ecs: &aws_sdk_ecs::Client,
cluster: &str,
task: &str,
container: &str,
) -> Result<Self, String> {
let cluster_name = last_arn_segment(cluster, "cluster/");
let out = ecs
.describe_tasks()
.cluster(cluster)
.tasks(task)
.send()
.await
.map_err(|e| format!("Could not look up the ECS task: {}", sdk_error(&e)))?;
let t = out
.tasks()
.first()
.ok_or_else(|| format!("ECS task {task} was not found in cluster {cluster_name}"))?;
let task_arn = t.task_arn().unwrap_or(task);
let task_id = last_arn_segment(task_arn, "/").to_string();
let status = t.last_status().unwrap_or("UNKNOWN");
if status != "RUNNING" {
return Err(format!("ECS task {task_id} is {status}, not RUNNING"));
}
let c = t
.containers()
.iter()
.find(|c| c.name() == Some(container))
.ok_or_else(|| format!("ECS task {task_id} has no container named {container}"))?;
let runtime_id = c
.runtime_id()
.ok_or_else(|| format!("Container {container} in task {task_id} has no runtime id yet"))?
.to_string();
let exec_agent = c
.managed_agents()
.iter()
.find(|a| a.name().map(|n| n.as_str()) == Some("ExecuteCommandAgent"));
match exec_agent.and_then(|a| a.last_status()) {
Some("RUNNING") => {}
Some(s) => {
return Err(format!(
"Container {container} in task {task_id} is not ready for connections yet (ECS Exec agent {s})"
))
}
None => {
return Err(format!(
"ECS task {task_id} was started without ECS Exec, so it cannot be connected to"
))
}
}
Ok(Self {
cluster: cluster_name.to_string(),
task_id,
runtime_id,
})
}
}
/// The part of an ARN after the last `marker` (`.../cluster/name` → `name`),
/// or the whole string when it is not an ARN.
fn last_arn_segment<'a>(s: &'a str, marker: &str) -> &'a str {
if !s.starts_with("arn:") {
return s;
}
match s.rfind(marker) {
Some(i) => &s[i + marker.len()..],
None => s,
}
}
/// An SDK error with its cause chain (the bare `Display` is often just
/// "service error").
fn sdk_error(e: &(dyn std::error::Error + 'static)) -> String {
format!("{}", aws_sdk_ssm::error::DisplayErrorContext(e))
}
// ── The plugin ───────────────────────────────────────────────────────────────
/// Find `session-manager-plugin`: `TRIPLEC_SSM_PLUGIN` if set, then `PATH`,
/// then where AWS's installers put it (the GUI app on macOS does not get the
/// shell's `PATH`, so the install locations are not optional).
pub fn find_plugin() -> Result<PathBuf, String> {
if let Some(p) = std::env::var_os("TRIPLEC_SSM_PLUGIN").filter(|p| !p.is_empty()) {
let p = PathBuf::from(p);
return if p.is_file() {
Ok(p)
} else {
Err(format!("TRIPLEC_SSM_PLUGIN names {}, which is not a file", p.display()))
};
}
let name = if cfg!(windows) { "session-manager-plugin.exe" } else { "session-manager-plugin" };
let on_path = std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).map(|d| d.join(name)).collect::<Vec<_>>())
.unwrap_or_default();
let installed: Vec<PathBuf> = if cfg!(windows) {
["ProgramFiles", "ProgramFiles(x86)"]
.iter()
.filter_map(|v| std::env::var_os(v))
.map(|d| PathBuf::from(d).join(r"Amazon\SessionManagerPlugin\bin").join(name))
.collect()
} else {
[
"/usr/local/sessionmanagerplugin/bin",
"/usr/local/bin",
"/opt/homebrew/bin",
"/usr/bin",
]
.iter()
.map(|d| Path::new(d).join(name))
.collect()
};
on_path
.into_iter()
.chain(installed)
.find(|p| p.is_file())
.ok_or_else(|| {
"The AWS Session Manager plugin (session-manager-plugin) is not installed. It is \
what connects to a workspace on AWS; install it from \
https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html"
.to_string()
})
}
/// A running plugin process with a listener on the local port.
struct Running {
child: Child,
session_id: String,
output: OutputTail,
}
/// The last lines a plugin printed (stdout and stderr), for error text.
#[derive(Clone, Default)]
struct OutputTail(Arc<Mutex<Vec<String>>>);
impl OutputTail {
fn push(&self, line: String) {
let mut v = self.0.lock().unwrap();
v.push(line);
if v.len() > OUTPUT_TAIL_LINES {
v.remove(0);
}
}
/// The most informative recent line: the last non-blank one.
fn last(&self) -> Option<String> {
self.0.lock().unwrap().iter().rev().find(|l| !l.trim().is_empty()).cloned()
}
fn joined(&self) -> String {
self.0
.lock()
.unwrap()
.iter()
.filter(|l| !l.trim().is_empty())
.cloned()
.collect::<Vec<_>>()
.join("; ")
}
}
/// How to start the plugin for one session. Everything but `env_secret` is
/// safe to log; that is the `StartSession` response.
struct PluginLaunch {
program: PathBuf,
args: Vec<String>,
env: Vec<(String, Zeroizing<String>)>,
session_id: String,
}
/// Start the plugin and wait until it says its listener is up. Its output is
/// then drained in the background for good (a full pipe would stall it).
async fn spawn_plugin(launch: PluginLaunch) -> Result<Running, String> {
let mut cmd = Command::new(&launch.program);
cmd.args(&launch.args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
// Only the credentials this session was started with: nothing AWS-shaped
// from our own environment rides along.
for (k, _) in std::env::vars_os() {
if k.to_string_lossy().starts_with("AWS_") {
cmd.env_remove(&k);
}
}
for (k, v) in &launch.env {
cmd.env(k, v.as_str());
}
#[cfg(windows)]
{
// CREATE_NO_WINDOW: no console flashing up per (re)connect.
cmd.creation_flags(0x0800_0000);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Could not start {}: {e}", launch.program.display()))?;
let output = OutputTail::default();
let (ready_tx, mut ready_rx) = watch::channel(false);
let stdout = child.stdout.take().expect("stdout is piped");
let stderr = child.stderr.take().expect("stderr is piped");
drain(stdout, output.clone(), Some(ready_tx));
drain(stderr, output.clone(), None);
let ready = async {
loop {
if *ready_rx.borrow_and_update() {
return true;
}
if ready_rx.changed().await.is_err() {
// stdout closed without the ready line: the plugin is exiting.
return *ready_rx.borrow();
}
}
};
tokio::select! {
r = tokio::time::timeout(READY_TIMEOUT, ready) => match r {
Ok(true) => Ok(Running { child, session_id: launch.session_id, output }),
Ok(false) => {
let _ = child.wait().await;
Err(plugin_failed(&output))
}
Err(_) => {
let _ = child.kill().await;
Err(format!(
"The SSM port forward did not come up within {} s ({})",
READY_TIMEOUT.as_secs(),
output.joined()
))
}
},
// Exited before (or without) printing the ready line.
status = child.wait() => {
// Let the drains catch the last lines before reporting them.
tokio::time::sleep(Duration::from_millis(50)).await;
let _ = status;
Err(plugin_failed(&output))
}
}
}
fn plugin_failed(output: &OutputTail) -> String {
match output.joined() {
s if s.is_empty() => "The SSM port forward exited before it was ready".to_string(),
s => format!("The SSM port forward exited before it was ready: {s}"),
}
}
fn drain(
stream: impl AsyncRead + Unpin + Send + 'static,
tail: OutputTail,
ready: Option<watch::Sender<bool>>,
) {
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
log::debug!("session-manager-plugin: {line}");
if let Some(tx) = &ready {
if line.contains(READY_LINE) {
let _ = tx.send(true);
}
}
tail.push(line);
}
// `ready` drops here; a waiter sees the channel close.
});
}
// ── Opening sessions ─────────────────────────────────────────────────────────
/// Opens and ends sessions. The real one calls SSM and runs the plugin; tests
/// substitute a fake plugin without AWS.
trait SessionOpener: Send + Sync + 'static {
fn open(&self, local_port: u16) -> impl Future<Output = Result<Running, String>> + Send;
fn terminate(&self, session_id: String) -> impl Future<Output = ()> + Send;
}
struct SsmOpener {
sdk: SdkConfig,
/// The credentials last handed to the plugin, reused until they near
/// expiry. Each fetch from the provider can be a `credential_process`
/// (an MFA or biometric prompt), an STS call or an SSO call, and the
/// provider in `SdkConfig` is not the SDK's cached one.
plugin_creds: tokio::sync::Mutex<Option<aws_sdk_ssm::config::Credentials>>,
ssm: aws_sdk_ssm::Client,
region: String,
plugin: PathBuf,
target: String,
remote_port: u16,
}
impl SessionOpener for SsmOpener {
async fn open(&self, local_port: u16) -> Result<Running, String> {
let out = self
.ssm
.start_session()
.target(&self.target)
.document_name(PORT_FORWARD_DOCUMENT)
.parameters("portNumber", vec![self.remote_port.to_string()])
.parameters("localPortNumber", vec![local_port.to_string()])
.send()
.await
.map_err(|e| format!("Could not start the SSM session: {}", sdk_error(&e)))?;
let session_id = out.session_id().unwrap_or_default().to_string();
// From here the session exists on AWS's side. If this future is
// dropped before it returns — a close or drop racing a reconnect, a
// caller timing out the first open — the guard ends the session.
let guard = SessionGuard {
ssm: self.ssm.clone(),
session_id: Some(session_id.clone()),
};
let response = Zeroizing::new(
serde_json::json!({
"SessionId": session_id,
"StreamUrl": out.stream_url().unwrap_or_default(),
"TokenValue": out.token_value().unwrap_or_default(),
})
.to_string(),
);
let request = serde_json::json!({
"Target": self.target,
"DocumentName": PORT_FORWARD_DOCUMENT,
"Parameters": {
"portNumber": [self.remote_port.to_string()],
"localPortNumber": [local_port.to_string()],
},
})
.to_string();
let mut env = vec![
(RESPONSE_ENV.to_string(), response),
("AWS_REGION".to_string(), Zeroizing::new(self.region.clone())),
];
env.extend(self.plugin_credentials().await);
let launch = PluginLaunch {
program: self.plugin.clone(),
// <response env var> <region> StartSession <profile> <request> <endpoint>:
// what the AWS CLI passes. No profile: credentials come from env.
args: vec![
RESPONSE_ENV.to_string(),
self.region.clone(),
"StartSession".to_string(),
String::new(),
request,
ssm_endpoint(&self.region),
],
env,
session_id: session_id.clone(),
};
match spawn_plugin(launch).await {
Ok(r) => {
guard.disarm();
Ok(r)
}
Err(e) => {
// No plugin is attached to the session; don't leave it open
// until the idle timeout.
guard.terminate().await;
Err(e)
}
}
}
async fn terminate(&self, session_id: String) {
terminate_session(&self.ssm, session_id).await
}
}
async fn terminate_session(ssm: &aws_sdk_ssm::Client, session_id: String) {
if session_id.is_empty() {
return;
}
let call = ssm.terminate_session().session_id(&session_id).send();
match tokio::time::timeout(TERMINATE_TIMEOUT, call).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => log::warn!("TerminateSession {session_id}: {}", sdk_error(&e)),
Err(_) => log::warn!("TerminateSession {session_id}: timed out"),
}
}
/// Ends a started session unless disarmed: on an error path explicitly, and
/// on drop (a cancelled open) from a spawned task.
struct SessionGuard {
ssm: aws_sdk_ssm::Client,
session_id: Option<String>,
}
impl SessionGuard {
fn disarm(mut self) {
self.session_id = None;
}
async fn terminate(mut self) {
if let Some(id) = self.session_id.take() {
terminate_session(&self.ssm, id).await;
}
}
}
impl Drop for SessionGuard {
fn drop(&mut self) {
if let Some(id) = self.session_id.take() {
if let Ok(rt) = tokio::runtime::Handle::try_current() {
let ssm = self.ssm.clone();
rt.spawn(async move { terminate_session(&ssm, id).await });
}
}
}
}
/// How long before expiry cached plugin credentials are refreshed. A plugin
/// started with them may need them for a resume well after it starts.
const PLUGIN_CREDS_MIN_REMAINING: Duration = Duration::from_secs(15 * 60);
impl SsmOpener {
/// The caller's credentials as the plugin's environment, for its own
/// `ResumeSession` calls. Without them a resume fails and the
/// supervisor's full reconnect takes over, so a failure here is not fatal.
async fn plugin_credentials(&self) -> Vec<(String, Zeroizing<String>)> {
use aws_sdk_ssm::config::ProvideCredentials;
let mut cached = self.plugin_creds.lock().await;
let fresh = |c: &aws_sdk_ssm::config::Credentials| match c.expiry() {
None => true,
Some(exp) => exp
.duration_since(std::time::SystemTime::now())
.map(|left| left > PLUGIN_CREDS_MIN_REMAINING)
.unwrap_or(false),
};
if !cached.as_ref().is_some_and(fresh) {
*cached = None;
if let Some(provider) = self.sdk.credentials_provider() {
match provider.provide_credentials().await {
Ok(c) => *cached = Some(c),
Err(e) => log::warn!("No credentials for the SSM plugin's resume: {}", sdk_error(&e)),
}
}
}
let Some(c) = cached.as_ref() else {
return vec![];
};
let mut v = vec![
("AWS_ACCESS_KEY_ID".to_string(), Zeroizing::new(c.access_key_id().to_string())),
(
"AWS_SECRET_ACCESS_KEY".to_string(),
Zeroizing::new(c.secret_access_key().to_string()),
),
];
if let Some(t) = c.session_token() {
v.push(("AWS_SESSION_TOKEN".to_string(), Zeroizing::new(t.to_string())));
}
v
}
}
fn ssm_endpoint(region: &str) -> String {
let suffix = if region.starts_with("cn-") { "amazonaws.com.cn" } else { "amazonaws.com" };
format!("https://ssm.{region}.{suffix}")
}
/// A free loopback port, for the plugin to bind. The plugin binds it a moment
/// later, so another process can take it in between; the first connection
/// retries with a new port when that happens.
fn free_local_port() -> Result<u16, String> {
std::net::TcpListener::bind(("127.0.0.1", 0))
.and_then(|l| l.local_addr())
.map(|a| a.port())
.map_err(|e| format!("Could not find a free local port: {e}"))
}
// ── The port forward ─────────────────────────────────────────────────────────
/// Where the tunnel is. Watch it with [`PortForward::state`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TunnelState {
Up,
/// The session dropped and is being reopened. `last_error` is why the
/// previous one ended or the previous attempt failed.
Reconnecting { attempt: u32, last_error: String },
/// Reconnecting gave up. Nothing listens on the local port.
Failed(String),
/// [`PortForward::close`] ran, or the handle was dropped.
Closed,
}
/// One SSM port-forward to an agent, supervised: reopened on the same local
/// port when it drops, ended (`TerminateSession`) when this handle is closed
/// or dropped.
pub struct PortForward {
local_port: u16,
state: watch::Receiver<TunnelState>,
plugin_pid: watch::Receiver<Option<u32>>,
supervisor: Option<tokio::task::JoinHandle<()>>,
_cancel: DropGuard,
cancel: CancellationToken,
}
impl std::fmt::Debug for PortForward {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PortForward")
.field("local_port", &self.local_port)
.field("state", &*self.state.borrow())
.finish()
}
}
impl PortForward {
/// Open a port forward from a free loopback port to `remote_port` in
/// `task`, and return once the local listener is up. Errors (no plugin,
/// IAM denial, the task not reachable) come back here rather than as a
/// state.
pub async fn open(sdk: &SdkConfig, task: &EcsTask, remote_port: u16) -> Result<Self, String> {
let region = sdk
.region()
.ok_or("No AWS region is configured for this connection")?
.to_string();
let opener = SsmOpener {
sdk: sdk.clone(),
plugin_creds: Default::default(),
ssm: aws_sdk_ssm::Client::new(sdk),
region,
plugin: find_plugin()?,
target: task.ssm_target(),
remote_port,
};
Self::open_with(opener).await
}
async fn open_with<O: SessionOpener>(opener: O) -> Result<Self, String> {
let mut last_err = String::new();
for _ in 0..INITIAL_PORT_ATTEMPTS {
let port = free_local_port()?;
match opener.open(port).await {
Ok(running) => return Ok(Self::supervise(opener, port, running)),
Err(e) if is_port_in_use(&e) => last_err = e,
Err(e) => return Err(e),
}
}
Err(last_err)
}
fn supervise<O: SessionOpener>(opener: O, local_port: u16, running: Running) -> Self {
let (state_tx, state) = watch::channel(TunnelState::Up);
let (pid_tx, plugin_pid) = watch::channel(running.child.id());
let cancel = CancellationToken::new();
let supervisor = tokio::spawn(run_supervisor(
opener,
local_port,
running,
state_tx,
pid_tx,
cancel.clone(),
));
Self {
local_port,
state,
plugin_pid,
supervisor: Some(supervisor),
_cancel: cancel.clone().drop_guard(),
cancel,
}
}
pub fn local_port(&self) -> u16 {
self.local_port
}
/// The agent at the local end of this tunnel. Stays valid across
/// reconnects (the port does not change).
pub fn endpoint(&self, token: impl Into<String>) -> AgentEndpoint {
AgentEndpoint::loopback(self.local_port, token)
}
pub fn state(&self) -> watch::Receiver<TunnelState> {
self.state.clone()
}
/// The running plugin's process id, if one is running.
pub fn plugin_pid(&self) -> Option<u32> {
*self.plugin_pid.borrow()
}
/// Stop the plugin and end the session, and wait for both.
pub async fn close(mut self) {
self.cancel.cancel();
if let Some(s) = self.supervisor.take() {
let _ = s.await;
}
}
}
fn is_port_in_use(e: &str) -> bool {
let e = e.to_ascii_lowercase();
e.contains("address already in use") || e.contains("only one usage of each socket address")
}
async fn run_supervisor<O: SessionOpener>(
opener: O,
local_port: u16,
mut running: Running,
state: watch::Sender<TunnelState>,
pid: watch::Sender<Option<u32>>,
cancel: CancellationToken,
) {
loop {
// ── Up: wait for the plugin to exit, or for close; keep it busy ──
let mut keepalive = tokio::time::interval_at(
tokio::time::Instant::now() + KEEPALIVE_INTERVAL,
KEEPALIVE_INTERVAL,
);
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let exited = loop {
tokio::select! {
s = running.child.wait() => break s,
_ = keepalive.tick() => {
tokio::spawn(keepalive_probe(local_port));
}
_ = cancel.cancelled() => {
let _ = running.child.kill().await;
opener.terminate(running.session_id).await;
let _ = pid.send(None);
let _ = state.send(TunnelState::Closed);
return;
}
}
};
let _ = pid.send(None);
// Let the drains catch the last lines.
tokio::time::sleep(Duration::from_millis(50)).await;
// Both halves: SSM's own reason is in the output ("timed out due to
// inactivity"), but after a kill the last line is just whatever the
// plugin printed before, so the exit status has to be there too.
let mut last_error = match running.output.last() {
Some(line) => format!("the SSM session ended ({}; last output: {line})", describe_exit(exited)),
None => format!("the SSM session ended ({})", describe_exit(exited)),
};
log::warn!("SSM port forward on 127.0.0.1:{local_port}: {last_error}; reconnecting");
opener.terminate(running.session_id).await;
// ── Reconnecting ──
let mut attempt = 0;
running = loop {
attempt += 1;
if attempt > MAX_RECONNECT_ATTEMPTS {
let _ = state.send(TunnelState::Failed(last_error));
// Nothing to clean up; wait for the handle to go.
cancel.cancelled().await;
let _ = state.send(TunnelState::Closed);
return;
}
let _ = state.send(TunnelState::Reconnecting {
attempt,
last_error: last_error.clone(),
});
let delay = reconnect_delay(attempt);
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = cancel.cancelled() => {
let _ = state.send(TunnelState::Closed);
return;
}
}
let opened = tokio::select! {
r = opener.open(local_port) => r,
_ = cancel.cancelled() => {
let _ = state.send(TunnelState::Closed);
return;
}
};
match opened {
Ok(r) => break r,
Err(e) => {
log::warn!("SSM port forward on 127.0.0.1:{local_port}: reconnect {attempt}: {e}");
last_error = e;
}
}
};
let _ = pid.send(running.child.id());
let _ = state.send(TunnelState::Up);
log::info!("SSM port forward on 127.0.0.1:{local_port}: reconnected after {attempt} attempt(s)");
}
}
/// One request through the tunnel so SSM sees data: an *unauthenticated*
/// `GET /v1/health`. The agent answers `401` before it counts anything as
/// activity, so this keeps the session alive without keeping the workspace
/// "in use" for idle auto-stop — and it needs no token here.
async fn keepalive_probe(local_port: u16) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let probe = async {
let mut s = tokio::net::TcpStream::connect(("127.0.0.1", local_port)).await?;
s.write_all(b"GET /v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.await?;
let mut sink = [0u8; 512];
while s.read(&mut sink).await? > 0 {}
Ok::<_, std::io::Error>(())
};
match tokio::time::timeout(KEEPALIVE_TIMEOUT, probe).await {
Ok(Ok(())) => {}
Ok(Err(e)) => log::debug!("SSM keepalive on 127.0.0.1:{local_port}: {e}"),
Err(_) => log::debug!("SSM keepalive on 127.0.0.1:{local_port}: timed out"),
}
}
fn reconnect_delay(attempt: u32) -> Duration {
let d = RECONNECT_BACKOFF[(attempt as usize).saturating_sub(1).min(RECONNECT_BACKOFF.len() - 1)];
if cfg!(test) {
d / 100
} else {
d
}
}
fn describe_exit(s: std::io::Result<std::process::ExitStatus>) -> String {
match s {
Ok(s) => format!("plugin {s}"),
Err(e) => format!("plugin wait failed: {e}"),
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod live_tests;
+192
View File
@@ -0,0 +1,192 @@
//! The real thing: an SSM port forward to `triple-c-agent` in a running ECS
//! task, used through `ContainerTarget::Agent` exactly as the app will.
//!
//! Ignored; needs AWS credentials (the default chain — profile, SSO or env),
//! `session-manager-plugin`, and a task started with ECS Exec from an image
//! that has the agent:
//!
//! - `TRIPLEC_SSM_E2E_CLUSTER`, `TRIPLEC_SSM_E2E_TASK` (name/id or ARN)
//! - `TRIPLEC_SSM_E2E_TOKEN` — the agent token the task was given
//! - `TRIPLEC_SSM_E2E_CONTAINER` — default `workspace`
//! - `TRIPLEC_SSM_E2E_HOLD_MINUTES` — only for the hold test
//!
//! The SSM session's IAM gate is what these exercise against AWS; the agent's
//! Docker parity is already pinned by `host::agent::live_tests`.
use super::*;
use crate::host::{ContainerTarget, ExecChunk};
use futures_util::StreamExt;
use std::time::Instant;
use tokio::io::AsyncWriteExt;
fn var(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)"))
}
async fn connect() -> (PortForward, ContainerTarget, Duration) {
let sdk = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let ecs = aws_sdk_ecs::Client::new(&sdk);
let container = std::env::var("TRIPLEC_SSM_E2E_CONTAINER").unwrap_or_else(|_| "workspace".into());
let task = EcsTask::resolve(&ecs, &var("TRIPLEC_SSM_E2E_CLUSTER"), &var("TRIPLEC_SSM_E2E_TASK"), &container)
.await
.expect("resolve the task");
let t0 = Instant::now();
let pf = PortForward::open(&sdk, &task, AGENT_PORT).await.expect("open the port forward");
let opened = t0.elapsed();
let target = ContainerTarget::Agent(pf.endpoint(var("TRIPLEC_SSM_E2E_TOKEN")));
(pf, target, opened)
}
async fn wait_up(pf: &PortForward, within: Duration) -> Duration {
let t0 = Instant::now();
let mut s = pf.state();
tokio::time::timeout(within, s.wait_for(|s| *s == TunnelState::Up))
.await
.expect("the tunnel did not come back")
.unwrap();
t0.elapsed()
}
async fn whoami(t: &ContainerTarget) -> Result<String, String> {
t.exec_oneshot(vec!["whoami".into()]).await.map(|s| s.trim().to_string())
}
#[tokio::test]
#[ignore]
async fn the_agent_works_through_an_ssm_port_forward_and_survives_a_drop() {
let (pf, t, opened) = connect().await;
eprintln!("port forward up in {opened:?} on 127.0.0.1:{}", pf.local_port());
// ── the operations the app uses ──
assert!(t.is_running().await.unwrap());
assert_eq!(whoami(&t).await.unwrap(), "claude");
let n = 20;
let t0 = Instant::now();
for _ in 0..n {
t.exec_oneshot(vec!["true".into()]).await.unwrap();
}
eprintln!("one-shot exec round trip: {:?} average over {n}", t0.elapsed() / n);
// attach with stdin
let mut exec = t
.attach(crate::host::ExecSpec::new(vec!["head".into(), "-n1".into()]))
.await
.unwrap();
exec.input.write_all(b"through ssm\n").await.unwrap();
exec.input.shutdown().await.unwrap();
let mut out = Vec::new();
while let Some(Ok(c)) = exec.output.next().await {
if let ExecChunk::StdOut(b) = c {
out.extend_from_slice(&b);
}
}
assert_eq!(String::from_utf8_lossy(&out), "through ssm\n");
// 4 MiB up and back
let data: Vec<u8> = (0..4 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
let t0 = Instant::now();
t.upload_bytes_with_ids("/tmp", "ssm-e2e.bin", &data, 0o644, (1000, 1000)).await.unwrap();
let up = t0.elapsed();
let t0 = Instant::now();
let mut tar = Vec::new();
let mut s = t.download_archive("/tmp/ssm-e2e.bin");
while let Some(chunk) = s.next().await {
tar.extend_from_slice(&chunk.unwrap());
}
let down = t0.elapsed();
let mut a = tar::Archive::new(&tar[..]);
let mut e = a.entries().unwrap().next().unwrap().unwrap();
let mut back = Vec::new();
std::io::Read::read_to_end(&mut e, &mut back).unwrap();
assert!(back == data, "the file came back different");
eprintln!("4 MiB: upload {up:?}, download {down:?}");
t.exec_oneshot(vec!["rm".into(), "-f".into(), "/tmp/ssm-e2e.bin".into()]).await.unwrap();
// ── a dropped session: kill the plugin, as a crash or sleep would ──
let pid = pf.plugin_pid().unwrap();
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().unwrap();
let mut s = pf.state();
s.wait_for(|s| *s != TunnelState::Up).await.unwrap();
let back_in = wait_up(&pf, Duration::from_secs(60)).await;
eprintln!("reconnected in {back_in:?} (same port {})", pf.local_port());
assert_eq!(whoami(&t).await.unwrap(), "claude", "the same endpoint works after a reconnect");
// ── close ends it ──
let port = pf.local_port();
pf.close().await;
assert!(
std::net::TcpStream::connect(("127.0.0.1", port)).is_err(),
"something still listens on the closed tunnel's port"
);
}
/// Hold one tunnel for `TRIPLEC_SSM_E2E_HOLD_MINUTES` (default 60): a probe
/// every minute for the first and last stretch, and a silent stretch in the
/// middle longer than Session Manager's default 20-minute idle timeout. One
/// attached exec stays open the whole time. Prints a timeline of tunnel
/// states; fails only if the tunnel cannot serve a request at the end.
#[tokio::test]
#[ignore]
async fn an_ssm_port_forward_holds_for_a_long_session() {
let minutes: u64 = std::env::var("TRIPLEC_SSM_E2E_HOLD_MINUTES")
.ok()
.and_then(|m| m.parse().ok())
.unwrap_or(60);
let (pf, t, opened) = connect().await;
let start = Instant::now();
let stamp = move || format!("[{:>5.1} min]", start.elapsed().as_secs_f64() / 60.0);
eprintln!("{} up in {opened:?}", stamp());
// Log every state change as it happens.
let mut states = pf.state();
let log_states = tokio::spawn(async move {
while states.changed().await.is_ok() {
eprintln!("{} tunnel: {:?}", stamp(), *states.borrow());
}
});
// A long-lived connection: does one TCP stream survive the whole hold?
let mut held = t.attach(crate::host::ExecSpec::new(vec!["cat".into()])).await.unwrap();
let idle_from = minutes / 4;
let idle_to = idle_from + 25.min(minutes / 2);
let mut failures = 0;
for m in 0..minutes {
if m < idle_from || m >= idle_to {
let t0 = Instant::now();
match whoami(&t).await {
Ok(_) => eprintln!("{} probe ok ({:?})", stamp(), t0.elapsed()),
Err(e) => {
failures += 1;
eprintln!("{} probe FAILED: {e}", stamp())
}
}
} else if m == idle_from {
eprintln!("{} silent until minute {idle_to}", stamp());
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
// Did the held stream survive? Echo through it.
let survived = async {
held.input.write_all(b"still here\n").await.ok()?;
let mut got = Vec::new();
while let Ok(Some(Ok(c))) = tokio::time::timeout(Duration::from_secs(10), held.output.next()).await {
if let ExecChunk::StdOut(b) = c {
got.extend_from_slice(&b);
if got.ends_with(b"\n") {
break;
}
}
}
Some(got == b"still here\n")
}
.await
.unwrap_or(false);
eprintln!("{} long-lived attach survived: {survived}; probe failures: {failures}", stamp());
assert_eq!(whoami(&t).await.unwrap(), "claude", "the tunnel serves at the end");
pf.close().await;
log_states.abort();
}
+402
View File
@@ -0,0 +1,402 @@
//! The plugin handling and the supervisor, against a fake plugin (a shell
//! script) instead of AWS. The real thing is in `live_tests.rs`.
use super::*;
#[test]
fn the_ssm_target_names_cluster_task_and_container_runtime() {
let t = EcsTask {
cluster: "triple-c".into(),
task_id: "9fb3a6cf".into(),
runtime_id: "9fb3a6cf-0791508406".into(),
};
assert_eq!(t.ssm_target(), "ecs:triple-c_9fb3a6cf_9fb3a6cf-0791508406");
}
#[test]
fn arns_are_reduced_to_their_names() {
assert_eq!(
last_arn_segment("arn:aws:ecs:us-west-2:123456789012:cluster/triple-c", "cluster/"),
"triple-c"
);
assert_eq!(
last_arn_segment("arn:aws:ecs:us-west-2:123456789012:task/triple-c/9fb3a6cf", "/"),
"9fb3a6cf"
);
assert_eq!(last_arn_segment("triple-c", "cluster/"), "triple-c");
assert_eq!(last_arn_segment("9fb3a6cf", "/"), "9fb3a6cf");
}
#[test]
fn the_plugin_endpoint_follows_the_partition() {
assert_eq!(ssm_endpoint("us-west-2"), "https://ssm.us-west-2.amazonaws.com");
assert_eq!(ssm_endpoint("cn-north-1"), "https://ssm.cn-north-1.amazonaws.com.cn");
}
#[test]
fn a_port_clash_is_recognised_on_every_platform() {
assert!(is_port_in_use("listen tcp 127.0.0.1:4: bind: address already in use"));
assert!(is_port_in_use(
"bind: Only one usage of each socket address (protocol/network address/port) is normally permitted."
));
assert!(!is_port_in_use("TargetNotConnected"));
}
#[test]
fn backoff_grows_then_holds() {
assert!(reconnect_delay(1) < reconnect_delay(2));
assert_eq!(reconnect_delay(5), reconnect_delay(500));
}
#[cfg(unix)]
mod with_a_fake_plugin {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicU32, Ordering};
/// A fake `session-manager-plugin`: a shell script.
fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p
}
/// Runs the script as `/bin/sh <script>`: the script is only ever read,
/// never exec'd, so a parallel test's fork holding it open for writing
/// for a moment cannot fail the spawn with ETXTBSY ("Text file busy").
fn launch(script: PathBuf, secret: &str) -> PluginLaunch {
PluginLaunch {
program: "/bin/sh".into(),
args: vec![
script.display().to_string(),
RESPONSE_ENV.into(),
"us-west-2".into(),
"StartSession".into(),
],
env: vec![(RESPONSE_ENV.into(), Zeroizing::new(secret.into()))],
session_id: "s-1".into(),
}
}
const READY: &str = "echo 'Port 1 opened for sessionId s.'; echo 'Waiting for connections...'";
#[tokio::test]
async fn the_session_token_travels_in_the_environment_never_on_the_command_line() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("seen");
// Nothing AWS-shaped from our own environment may reach the plugin.
// (A name no other test reads, so setting it here is not a race.)
std::env::set_var("AWS_TRIPLEC_SSM_TEST_LEAK", "leaked");
let p = script(
dir.path(),
"plugin",
&format!(
"echo \"argv=$*\" > {o}; echo \"env=${r}\" >> {o}; echo \"leak=$AWS_TRIPLEC_SSM_TEST_LEAK\" >> {o}; {READY}; exec sleep 30",
o = out.display(),
r = RESPONSE_ENV
),
);
let running = spawn_plugin(launch(p, "the-secret-token")).await.expect("ready");
let seen = std::fs::read_to_string(&out).unwrap();
std::env::remove_var("AWS_TRIPLEC_SSM_TEST_LEAK");
assert!(seen.contains("env=the-secret-token"), "{seen}");
let argv = seen.lines().find(|l| l.starts_with("argv=")).unwrap();
assert!(!argv.contains("the-secret-token"), "{argv}");
assert!(argv.contains(RESPONSE_ENV), "the plugin is told which variable to read: {argv}");
assert!(seen.contains("leak=\n"), "{seen}");
drop(running); // kill_on_drop
}
#[tokio::test]
async fn a_plugin_that_exits_early_says_why() {
let dir = tempfile::tempdir().unwrap();
let p = script(
dir.path(),
"plugin",
"echo 'An error occurred (TargetNotConnected) when calling the StartSession operation' >&2; exit 255",
);
let err = spawn_plugin(launch(p, "x")).await.err().expect("must fail");
assert!(err.contains("TargetNotConnected"), "{err}");
}
#[tokio::test]
async fn a_plugin_that_never_gets_ready_is_killed_and_reported() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", "echo 'Starting session'; exec sleep 30");
let err = spawn_plugin(launch(p, "x")).await.err().expect("must time out");
assert!(err.contains("did not come up"), "{err}");
assert!(err.contains("Starting session"), "{err}");
}
/// Opens sessions by running a fake plugin; records what it was asked.
struct FakeOpener {
plugin: PathBuf,
ports: Arc<Mutex<Vec<u16>>>,
terminated: Arc<Mutex<Vec<String>>>,
opened: Arc<AtomicU32>,
/// Fail opens whose 1-based number is in here, with this message.
fail: Vec<(u32, &'static str)>,
/// Connections made to the local port (the fake plugin listens on
/// nothing, so the opener stands in for its listener).
connections: Arc<AtomicU32>,
}
impl FakeOpener {
fn new(plugin: PathBuf) -> Self {
Self {
plugin,
ports: Default::default(),
terminated: Default::default(),
opened: Default::default(),
fail: vec![],
connections: Default::default(),
}
}
}
impl SessionOpener for FakeOpener {
async fn open(&self, local_port: u16) -> Result<Running, String> {
let n = self.opened.fetch_add(1, Ordering::SeqCst) + 1;
self.ports.lock().unwrap().push(local_port);
if let Some((_, msg)) = self.fail.iter().find(|(i, _)| *i == n) {
return Err(msg.to_string());
}
if let Ok(listener) = tokio::net::TcpListener::bind(("127.0.0.1", local_port)).await {
let count = self.connections.clone();
tokio::spawn(async move {
while let Ok((sock, _)) = listener.accept().await {
count.fetch_add(1, Ordering::SeqCst);
drop(sock);
}
});
}
let mut l = launch(self.plugin.clone(), "x");
l.session_id = format!("s-{n}");
spawn_plugin(l).await
}
async fn terminate(&self, session_id: String) {
self.terminated.lock().unwrap().push(session_id);
}
}
async fn wait_for(state: &mut watch::Receiver<TunnelState>, want: impl Fn(&TunnelState) -> bool) -> TunnelState {
let s = tokio::time::timeout(Duration::from_secs(10), state.wait_for(|s| want(s)))
.await
.expect("state did not arrive")
.expect("supervisor gone")
.clone();
s
}
fn alive(pid: u32) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn kill(pid: u32) {
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().unwrap();
}
#[tokio::test]
async fn a_dropped_session_is_reopened_on_the_same_local_port() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let opener = FakeOpener::new(p);
let (ports, terminated) = (opener.ports.clone(), opener.terminated.clone());
let pf = PortForward::open_with(opener).await.expect("open");
let mut state = pf.state();
assert_eq!(*state.borrow(), TunnelState::Up);
let first = pf.plugin_pid().expect("a plugin runs");
kill(first);
wait_for(&mut state, |s| matches!(s, TunnelState::Reconnecting { .. })).await;
wait_for(&mut state, |s| *s == TunnelState::Up).await;
let second = pf.plugin_pid().expect("a new plugin runs");
assert_ne!(first, second);
let ports = ports.lock().unwrap().clone();
assert_eq!(ports.len(), 2, "{ports:?}");
assert_eq!(ports[0], ports[1], "the endpoint must stay valid: same port");
assert_eq!(pf.local_port(), ports[0]);
assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()], "the dead session is ended");
pf.close().await;
assert!(!alive(second));
}
#[tokio::test]
async fn an_up_tunnel_keeps_itself_busy() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let opener = FakeOpener::new(p);
let connections = opener.connections.clone();
let pf = PortForward::open_with(opener).await.expect("open");
tokio::time::sleep(KEEPALIVE_INTERVAL * 5 + KEEPALIVE_INTERVAL / 2).await;
let n = connections.load(Ordering::SeqCst);
assert!((3..=6).contains(&n), "{n} keepalives in 5.5 intervals");
pf.close().await;
}
#[tokio::test]
async fn close_kills_the_plugin_and_ends_the_session() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let opener = FakeOpener::new(p);
let terminated = opener.terminated.clone();
let pf = PortForward::open_with(opener).await.expect("open");
let pid = pf.plugin_pid().unwrap();
let mut state = pf.state();
pf.close().await;
assert_eq!(*state.borrow_and_update(), TunnelState::Closed);
assert!(!alive(pid), "the plugin outlived close()");
assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()]);
}
#[tokio::test]
async fn dropping_the_handle_also_ends_the_session() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let opener = FakeOpener::new(p);
let terminated = opener.terminated.clone();
let pf = PortForward::open_with(opener).await.expect("open");
let pid = pf.plugin_pid().unwrap();
let mut state = pf.state();
drop(pf);
wait_for(&mut state, |s| *s == TunnelState::Closed).await;
assert!(!alive(pid));
assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()]);
}
#[tokio::test]
async fn the_first_open_moves_off_a_port_someone_else_took() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let mut opener = FakeOpener::new(p);
opener.fail = vec![(1, "listen tcp 127.0.0.1:1: bind: address already in use")];
let opened = opener.opened.clone();
let pf = PortForward::open_with(opener).await.expect("second port works");
assert_eq!(opened.load(Ordering::SeqCst), 2);
pf.close().await;
}
#[tokio::test]
async fn other_first_open_errors_are_returned_not_retried() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let mut opener = FakeOpener::new(p);
opener.fail = vec![(1, "AccessDeniedException: not authorized to perform ssm:StartSession")];
let opened = opener.opened.clone();
let err = PortForward::open_with(opener).await.err().expect("must fail");
assert!(err.contains("AccessDenied"), "{err}");
assert_eq!(opened.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn reconnecting_gives_up_after_the_limit_and_says_why() {
let dir = tempfile::tempdir().unwrap();
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
let mut opener = FakeOpener::new(p);
// Open 1 works; every reconnect after it fails.
opener.fail = (2..=MAX_RECONNECT_ATTEMPTS + 1)
.map(|i| (i, "TargetNotConnected: the task stopped"))
.collect();
let pf = PortForward::open_with(opener).await.expect("open");
let mut state = pf.state();
kill(pf.plugin_pid().unwrap());
let s = tokio::time::timeout(
Duration::from_secs(30),
state.wait_for(|s| matches!(s, TunnelState::Failed(_))),
)
.await
.expect("gave up")
.unwrap()
.clone();
assert_eq!(s, TunnelState::Failed("TargetNotConnected: the task stopped".into()));
assert_eq!(pf.plugin_pid(), None);
pf.close().await;
assert_eq!(*state.borrow_and_update(), TunnelState::Closed);
}
}
/// A cancelled open (its future dropped after `StartSession` succeeded) must
/// still end the session: the guard's drop sends `TerminateSession`. Checked
/// against a fake SSM endpoint that records what it is asked.
#[tokio::test]
async fn a_dropped_open_still_ends_its_session() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
tokio::spawn(async move {
loop {
let (mut sock, _) = listener.accept().await.unwrap();
let tx = seen_tx.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 64 * 1024];
let mut req = Vec::new();
// Headers and the small JSON body arrive together here.
while let Ok(n) = sock.read(&mut buf).await {
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&req);
if text.contains("SessionId") {
break;
}
}
let _ = tx.send(String::from_utf8_lossy(&req).into_owned());
let body = r#"{"SessionId":"s-dropped"}"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/x-amz-json-1.1\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
});
}
});
let conf = aws_sdk_ssm::Config::builder()
.behavior_version(aws_sdk_ssm::config::BehaviorVersion::latest())
.region(aws_sdk_ssm::config::Region::new("us-west-2"))
.credentials_provider(aws_sdk_ssm::config::Credentials::new("AKIDTEST", "secret", None, None, "test"))
.endpoint_url(format!("http://127.0.0.1:{port}"))
.build();
let guard = SessionGuard {
ssm: aws_sdk_ssm::Client::from_conf(conf),
session_id: Some("s-dropped".into()),
};
drop(guard);
let req = tokio::time::timeout(Duration::from_secs(10), seen_rx.recv())
.await
.expect("no TerminateSession arrived")
.unwrap();
assert!(req.contains("AmazonSSM.TerminateSession"), "{req}");
assert!(req.contains("s-dropped"), "{req}");
}
/// The keepalive is the unauthenticated health probe: some traffic, no token.
#[tokio::test]
async fn the_keepalive_asks_for_health_without_a_token() {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(b"HTTP/1.1 401 Unauthorized\r\ncontent-length: 0\r\n\r\n").await.unwrap();
String::from_utf8_lossy(&buf[..n]).into_owned()
});
keepalive_probe(port).await;
let req = server.await.unwrap();
assert!(req.starts_with("GET /v1/health HTTP/1.1\r\n"), "{req}");
assert!(!req.to_ascii_lowercase().contains("authorization"), "{req}");
}
+60
View File
@@ -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,
}
}
}
+10
View File
@@ -1,4 +1,5 @@
mod auth_bridge;
mod aws;
mod browser_view;
#[cfg(test)]
mod command_census;
@@ -593,6 +594,15 @@ pub fn run() {
commands::settings_commands::detect_aws_config,
commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles,
// AWS host connections
commands::host_commands::add_aws_profile_connection,
commands::host_commands::add_aws_keys_connection,
commands::host_commands::update_aws_connection,
commands::host_commands::remove_host_connection,
commands::host_commands::aws_connection_identity,
commands::host_commands::aws_connection_sso_login,
commands::host_commands::check_aws_permissions,
commands::host_commands::aws_policy_document,
commands::settings_commands::detect_host_timezone,
// Settings export/import
commands::settings_export_commands::export_settings,
+11 -6
View File
@@ -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));
+5 -3
View File
@@ -92,9 +92,11 @@ fn tail(text: &str, max: usize) -> &str {
/// Wait for readiness, upload the payload and the script, run the script as
/// `claude`, and return its report.
pub async fn sync_container(container_id: &str, payload: &Payload) -> Result<SyncReport, String> {
let target = ContainerTarget::docker(container_id);
wait_until_ready(&target).await?;
pub async fn sync_container(
target: &ContainerTarget,
payload: &Payload,
) -> Result<SyncReport, String> {
wait_until_ready(target).await?;
let (out, code) = target
.exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![])
+5
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use super::gateway_settings::GatewaySettings;
use super::host_connection::HostConnection;
use super::marketplace::{Marketplace, MarketplaceAccount, MarketplaceInstall};
use super::project::{ClaudeCodeSettings, EnvVar};
@@ -143,6 +144,9 @@ pub struct AppSettings {
/// Marketplace git repos the user added.
#[serde(default)]
pub marketplaces: Vec<Marketplace>,
/// Remote host connections (AWS). Secrets live in the OS keychain, never here.
#[serde(default)]
pub hosts: Vec<HostConnection>,
/// Items installed for every project (projects may opt out per item).
#[serde(default)]
pub global_marketplace_installs: Vec<MarketplaceInstall>,
@@ -259,6 +263,7 @@ impl Default for AppSettings {
global_claude_code_settings: None,
marketplace_accounts: Vec::new(),
marketplaces: Vec::new(),
hosts: Vec::new(),
global_marketplace_installs: Vec::new(),
terminal_gpu_rendering: None,
}
+181
View File
@@ -0,0 +1,181 @@
//! Remote host connections (currently AWS). Only non-secret data lives here;
//! access keys are held in the OS keychain and referenced by `keyring_ref`.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostConnection {
pub id: String,
pub name: String,
pub kind: HostKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HostKind {
Aws { auth: AwsAuth, default_region: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum AwsAuth {
/// A named profile from the host's AWS config.
Profile { name: String },
/// Static keys stored in the OS keychain under this reference.
Keys { keyring_ref: String },
}
/// Unicode format characters (category Cf): invisible, but able to reorder or
/// hide text (bidi overrides, zero-width spaces). `char::is_control` is Cc only.
fn is_format_char(c: char) -> bool {
matches!(c as u32,
0x00AD | 0x0600..=0x0605 | 0x061C | 0x06DD | 0x070F | 0x180E
| 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x206F
| 0xFEFF | 0xFFF9..=0xFFFB | 0xE0001 | 0xE0020..=0xE007F)
}
/// Trimmed, 1..=64 characters, no control or invisible format characters.
pub fn validate_connection_name(name: &str) -> Result<String, String> {
let name = name.trim();
let len = name.chars().count();
if len == 0 || len > 64 {
return Err("Connection name must be 1 to 64 characters".into());
}
if name.chars().any(|c| c.is_control() || is_format_char(c)) {
return Err("Connection name must not contain control or invisible formatting characters".into());
}
Ok(name.to_string())
}
/// An AWS region id such as `us-west-2` or `us-gov-west-1`.
pub fn validate_region(region: &str) -> Result<String, String> {
let b = region.as_bytes();
let ok = (|| {
// ^[a-z]{2}(-[a-z]+)+-\d+$
if b.len() < 2 || !b[..2].iter().all(u8::is_ascii_lowercase) {
return false;
}
let mut parts = region[2..].split('-');
if parts.next() != Some("") {
return false;
}
let rest: Vec<&str> = parts.collect();
let Some((last, words)) = rest.split_last() else {
return false;
};
!words.is_empty()
&& words
.iter()
.all(|w| !w.is_empty() && w.bytes().all(|c| c.is_ascii_lowercase()))
&& !last.is_empty()
&& last.bytes().all(|c| c.is_ascii_digit())
})();
if ok {
Ok(region.to_string())
} else {
Err(format!("\"{}\" is not a valid AWS region", region.escape_debug()))
}
}
/// A region Triple-C's policies and checks can work with. Every policy and check
/// template hard-codes the `aws` partition (`arn:aws:`), so China, GovCloud and
/// the isolated partitions would always check as Insufficient. `validate_region`
/// stays general (it only checks the shape); connection add/update/import use this.
pub fn validate_supported_region(region: &str) -> Result<String, String> {
let region = validate_region(region)?;
if ["cn-", "us-gov-", "us-iso-", "us-isob-", "eu-isoe-", "us-isof-"]
.iter()
.any(|p| region.starts_with(p))
{
return Err("China and GovCloud regions aren't supported yet.".into());
}
Ok(region)
}
/// Non-empty, at most 128 characters of `[A-Za-z0-9_.@+=,-]`.
pub fn validate_profile_name(name: &str) -> Result<String, String> {
if name.is_empty() || name.len() > 128 {
return Err("Profile name must be 1 to 128 characters".into());
}
if !name
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"_.@+=,-".contains(&c))
{
return Err("Profile name contains characters AWS profiles do not allow".into());
}
Ok(name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::AppSettings;
#[test]
fn settings_without_hosts_deserialize_to_empty() {
let mut json = serde_json::to_value(AppSettings::default()).unwrap();
json.as_object_mut().unwrap().remove("hosts");
let s: AppSettings = serde_json::from_value(json).unwrap();
assert_eq!(s.hosts, vec![]);
}
#[test]
fn a_profile_connection_round_trips() {
let c = HostConnection {
id: "h1".into(),
name: "Prod".into(),
kind: HostKind::Aws {
auth: AwsAuth::Profile { name: "default".into() },
default_region: "us-west-2".into(),
},
};
let s = serde_json::to_string(&c).unwrap();
assert!(s.contains("\"type\":\"aws\""), "{s}");
assert!(s.contains("\"method\":\"profile\""), "{s}");
assert_eq!(serde_json::from_str::<HostConnection>(&s).unwrap(), c);
}
#[test]
fn regions_are_validated() {
for ok in ["us-west-2", "eu-central-1", "cn-north-1", "us-gov-west-1"] {
assert_eq!(validate_region(ok).unwrap(), ok);
}
for ok in ["us-west-2", "eu-central-1", "ap-southeast-4"] {
assert_eq!(validate_supported_region(ok).unwrap(), ok);
}
for other in ["cn-north-1", "cn-northwest-1", "us-gov-west-1", "us-gov-east-1"] {
assert_eq!(
validate_supported_region(other).unwrap_err(),
"China and GovCloud regions aren't supported yet."
);
}
assert!(validate_supported_region("nope").unwrap_err().contains("not a valid"));
for bad in ["", "US-WEST-2", "us-west", "us-west-2; rm"] {
assert!(validate_region(bad).is_err(), "{bad:?}");
}
}
#[test]
fn profile_names_are_validated() {
for ok in ["default", "340315884407_StaffAdministratorAccess"] {
assert_eq!(validate_profile_name(ok).unwrap(), ok);
}
for bad in ["", "a b", "x\n"] {
assert!(validate_profile_name(bad).is_err(), "{bad:?}");
}
assert!(validate_profile_name(&"a".repeat(129)).is_err());
assert!(validate_profile_name(&"a".repeat(128)).is_ok());
}
#[test]
fn connection_names_are_validated() {
assert_eq!(validate_connection_name(" Prod ").unwrap(), "Prod");
assert!(validate_connection_name(" ").is_err());
assert!(validate_connection_name("a\tb").is_err());
for c in ['\u{202E}', '\u{200B}', '\u{2066}', '\u{2069}', '\u{FEFF}'] {
assert!(validate_connection_name(&format!("ab{c}cd")).is_err(), "{c:?}");
}
assert!(validate_connection_name("Prod é 日本").is_ok());
assert!(validate_connection_name(&"a".repeat(65)).is_err());
}
}
+2
View File
@@ -1,6 +1,7 @@
pub mod app_settings;
pub mod container_config;
pub mod gateway_settings;
pub mod host_connection;
pub mod marketplace;
pub mod migration;
pub mod note;
@@ -11,6 +12,7 @@ pub mod update_info;
pub use app_settings::*;
pub use container_config::*;
pub use gateway_settings::*;
pub use host_connection::*;
pub use migration::*;
pub use note::*;
pub use project::*;
+219
View File
@@ -64,6 +64,11 @@ pub struct ExportedSecrets {
/// here or an imported account could never fetch.
#[serde(default)]
pub marketplace_account_tokens: BTreeMap<String, String>,
/// Access keys of `AwsAuth::Keys` host connections, keyed by connection
/// id; each value is the `AwsKeys` JSON. Like the tokens above they live
/// in the keychain, not in `AppSettings::hosts`.
#[serde(default)]
pub aws_connection_keys: BTreeMap<String, String>,
}
impl ExportedSecrets {
@@ -74,6 +79,7 @@ impl ExportedSecrets {
&& blank(&self.gateway_master_key)
&& blank(&self.web_terminal_access_token)
&& self.marketplace_account_tokens.values().all(|v| v.trim().is_empty())
&& self.aws_connection_keys.values().all(|v| v.trim().is_empty())
}
}
@@ -171,6 +177,69 @@ pub struct SettingsImportPreview {
/// Non-blank marketplace account tokens the import restores.
#[serde(default)]
pub marketplace_account_token_count: usize,
/// The AWS connections the import brings. Applied wholesale, so a crafted
/// export could otherwise add or replace one unannounced. Names and
/// profile names are cleaned; no key values, only the count below.
#[serde(default)]
pub aws_connections: Vec<AwsConnectionPreview>,
/// Non-blank AWS access-key entries the import restores to the keychain.
#[serde(default)]
pub aws_connection_key_count: usize,
/// Local connections the import drops, because it replaces the whole list.
/// Their keychain entries, if any, are left behind.
#[serde(default)]
pub removed_aws_connections: Vec<AwsConnectionSummary>,
}
/// Cleaned, non-secret description of one connection.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AwsConnectionSummary {
pub name: String,
/// `"profile"` or `"keys"`.
pub auth: String,
pub profile_name: Option<String>,
pub default_region: String,
}
impl AwsConnectionSummary {
fn of(h: &crate::models::host_connection::HostConnection) -> Self {
use crate::models::host_connection::{AwsAuth, HostKind};
let HostKind::Aws { auth, default_region } = &h.kind;
let (auth_kind, profile_name) = match auth {
AwsAuth::Profile { name } => ("profile", Some(sanitize_for_preview(name))),
AwsAuth::Keys { .. } => ("keys", None),
};
Self {
name: sanitize_for_preview(&h.name),
auth: auth_kind.to_string(),
profile_name,
default_region: sanitize_for_preview(default_region),
}
}
}
/// One imported AWS connection as the confirmation dialog shows it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AwsConnectionPreview {
pub name: String,
/// `"profile"` or `"keys"`.
pub auth: String,
/// The profile name, for a `profile` connection.
pub profile_name: Option<String>,
pub default_region: String,
/// A connection with this id exists here with different content.
pub overwrites_existing: bool,
/// The import carries keys for this id and a keychain entry for it
/// already exists here (its value is never read into the preview).
pub overwrites_keys: bool,
/// The import carries keys for this id and the keychain could not be
/// asked whether some are stored already.
#[serde(default)]
pub keys_check_failed: bool,
/// The local connection with this id, when the import replaces it, so an
/// imported name cannot hide which connection is being replaced.
#[serde(default)]
pub local: Option<AwsConnectionSummary>,
}
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
@@ -195,7 +264,55 @@ fn sanitize_for_preview(value: &str) -> String {
}
impl SettingsImportPreview {
#[cfg(test)]
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
Self::from_payload_against(payload, &[], |_| Some(false))
}
/// Builds the preview, also flagging what the import would overwrite or
/// drop: `local_hosts` are the connections held now and `local_has_keys`
/// says whether a keychain entry exists for an id (`None`: could not be
/// determined).
pub fn from_payload_against(
payload: &SettingsExportPayload,
local_hosts: &[crate::models::host_connection::HostConnection],
local_has_keys: impl Fn(&str) -> Option<bool>,
) -> Self {
let carried_keys = |id: &str| {
payload
.secrets
.aws_connection_keys
.get(id)
.is_some_and(|v| !v.trim().is_empty())
};
let aws_connections = payload
.settings
.hosts
.iter()
.map(|h| {
let summary = AwsConnectionSummary::of(h);
let local = local_hosts.iter().find(|l| l.id == h.id);
let overwrites_existing = local.is_some_and(|l| l != h);
let key_state = if carried_keys(&h.id) { local_has_keys(&h.id) } else { Some(false) };
AwsConnectionPreview {
name: summary.name,
auth: summary.auth,
profile_name: summary.profile_name,
default_region: summary.default_region,
overwrites_existing,
overwrites_keys: key_state == Some(true),
keys_check_failed: key_state.is_none(),
local: local
.filter(|_| overwrites_existing || key_state != Some(false))
.map(AwsConnectionSummary::of),
}
})
.collect();
let removed_aws_connections = local_hosts
.iter()
.filter(|l| !payload.settings.hosts.iter().any(|h| h.id == l.id))
.map(AwsConnectionSummary::of)
.collect();
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
let sanitized_non_blank = |s: &Option<String>| {
s.as_deref()
@@ -240,6 +357,14 @@ impl SettingsImportPreview {
.values()
.filter(|v| !v.trim().is_empty())
.count(),
aws_connections,
aws_connection_key_count: payload
.secrets
.aws_connection_keys
.values()
.filter(|v| !v.trim().is_empty())
.count(),
removed_aws_connections,
}
}
}
@@ -477,4 +602,98 @@ mod tests {
assert_eq!(preview.global_plugin_install_count, 2);
assert_eq!(preview.global_hook_install_count, 1);
}
fn aws_host(id: &str, name: &str, keys: bool) -> crate::models::host_connection::HostConnection {
use crate::models::host_connection::*;
HostConnection {
id: id.into(),
name: name.into(),
kind: HostKind::Aws {
auth: if keys {
AwsAuth::Keys { keyring_ref: id.into() }
} else {
AwsAuth::Profile { name: "prod-profile".into() }
},
default_region: "us-west-2".into(),
},
}
}
#[test]
fn import_preview_discloses_aws_connections_and_key_count() {
let mut payload = payload_with(ExportedSecrets {
aws_connection_keys: BTreeMap::from([
("k1".to_string(), "{\"secret_access_key\":\"test-secret-not-real\"}".to_string()),
("k2".to_string(), " ".to_string()),
]),
..Default::default()
});
payload.settings.hosts = vec![aws_host("k1", "Ke\nys", true), aws_host("p1", "Prof", false)];
let preview = SettingsImportPreview::from_payload(&payload);
assert_eq!(preview.aws_connection_key_count, 1);
assert_eq!(preview.aws_connections.len(), 2);
assert_eq!(preview.aws_connections[0].name, "Keys");
assert_eq!(preview.aws_connections[0].auth, "keys");
assert_eq!(preview.aws_connections[1].auth, "profile");
assert_eq!(preview.aws_connections[1].profile_name.as_deref(), Some("prod-profile"));
assert_eq!(preview.aws_connections[1].default_region, "us-west-2");
assert!(!serde_json::to_string(&preview).unwrap().contains("test-secret-not-real"));
}
#[test]
fn import_preview_flags_aws_overwrites() {
let mut payload = payload_with(ExportedSecrets {
aws_connection_keys: BTreeMap::from([("k1".to_string(), "{}".to_string())]),
..Default::default()
});
payload.settings.hosts = vec![
aws_host("k1", "Keys", true),
aws_host("same", "Same", false),
aws_host("changed", "New name", false),
aws_host("fresh", "Fresh", false),
];
let local = vec![aws_host("same", "Same", false), aws_host("changed", "Old name", false)];
let preview =
SettingsImportPreview::from_payload_against(&payload, &local, |id| Some(id == "k1"));
let flags: Vec<_> = preview
.aws_connections
.iter()
.map(|c| (c.overwrites_existing, c.overwrites_keys))
.collect();
assert_eq!(flags, vec![(false, true), (false, false), (true, false), (false, false)]);
}
#[test]
fn aws_preview_names_the_local_connection_and_discloses_removals_and_failed_checks() {
let mut payload = payload_with(ExportedSecrets {
aws_connection_keys: BTreeMap::from([
("k1".to_string(), "{}".to_string()),
("k2".to_string(), "{}".to_string()),
]),
..Default::default()
});
payload.settings.hosts = vec![
aws_host("k1", "Innocent", true),
aws_host("k2", "Other", true),
aws_host("p1", "Fresh Name", false),
];
let local = vec![
aws_host("k1", "Real Prod", true),
aws_host("p1", "Real Dev", false),
aws_host("gone", "Dropped\n", false),
];
let preview = SettingsImportPreview::from_payload_against(&payload, &local, |id| match id {
"k1" => Some(true),
_ => None,
});
let c = &preview.aws_connections;
assert!(c[0].overwrites_keys && !c[0].keys_check_failed);
assert_eq!(c[0].local.as_ref().unwrap().name, "Real Prod");
assert!(c[1].keys_check_failed && !c[1].overwrites_keys && c[1].local.is_none());
assert!(c[2].overwrites_existing);
assert_eq!(c[2].local.as_ref().unwrap().name, "Real Dev");
assert_eq!(c[2].local.as_ref().unwrap().profile_name.as_deref(), Some("prod-profile"));
assert_eq!(preview.removed_aws_connections.len(), 1);
assert_eq!(preview.removed_aws_connections[0].name, "Dropped");
}
}
+126 -5
View File
@@ -167,7 +167,12 @@ fn read_entry(service: &str, label: &str) -> Result<Option<String>, String> {
fn delete_entry(service: &str, label: &str) -> Result<(), String> {
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.delete_credential() {
delete_outcome(entry.delete_credential(), label)
}
/// "Wasn't there" is success; any other failure names the entry, not its value.
fn delete_outcome(result: Result<(), keyring::Error>, label: &str) -> Result<(), String> {
match result {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to delete {}: {}", label, e)),
}
@@ -379,10 +384,7 @@ const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-";
/// The service name for one account. Ids are uuids; anything else is refused
/// before a keychain entry is constructed.
fn marketplace_token_service(account_id: &str) -> Result<String, String> {
let ok = !account_id.is_empty()
&& account_id.len() <= 64
&& account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
if !ok {
if !is_valid_connection_id(account_id) {
return Err(format!("Invalid marketplace account id {:?}", account_id));
}
Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id))
@@ -408,6 +410,90 @@ pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> {
delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
}
// ─────────────────────────────────────────────────────────────────────────────
// AWS connection access keys (global, one entry per connection)
// ─────────────────────────────────────────────────────────────────────────────
/// Keychain service prefix; the connection id completes it.
const AWS_CONNECTION_SERVICE_PREFIX: &str = "triple-c-aws-connection-";
/// Static AWS credentials for a `HostConnection` using `AwsAuth::Keys`. Held in
/// the keychain as one JSON value. `Debug` redacts the secret and the token.
#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct AwsKeys {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: Option<String>,
}
impl std::fmt::Debug for AwsKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AwsKeys")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"<redacted>")
.field("session_token", &self.session_token.as_ref().map(|_| "<redacted>"))
.finish()
}
}
/// Whether `id` may be used as a connection id / keyring reference: 1..=64 of
/// `[A-Za-z0-9-]`. The single definition of the keychain-id rule: marketplace
/// account ids, AWS connection ids and `commands::marketplace_commands`'s
/// `validate_id` all go through it.
pub fn is_valid_connection_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}
fn aws_connection_service(connection_id: &str) -> Result<String, String> {
if !is_valid_connection_id(connection_id) {
return Err(format!("Invalid AWS connection id {:?}", connection_id));
}
Ok(format!("{}{}", AWS_CONNECTION_SERVICE_PREFIX, connection_id))
}
pub fn store_aws_keys(connection_id: &str, keys: &AwsKeys) -> Result<(), String> {
let service = aws_connection_service(connection_id)?;
let json = serde_json::to_string(keys)
.map_err(|_| "Failed to encode the AWS access keys".to_string())?;
let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(&json)
.map_err(|e| format!("Failed to store the AWS access keys: {}", e))
}
pub fn get_aws_keys(connection_id: &str) -> Result<Option<AwsKeys>, String> {
let Some(json) = read_entry(&aws_connection_service(connection_id)?, "the AWS access keys")?
else {
return Ok(None);
};
serde_json::from_str(&json)
.map(Some)
.map_err(|_| "The stored AWS access keys are unreadable".to_string())
}
/// Whether an entry exists for the connection, readable or not. `None` means
/// the keychain could not be asked, so callers must not read it as "absent".
/// Never returns or logs a value.
pub fn aws_keys_entry_state(connection_id: &str) -> Option<bool> {
let svc = aws_connection_service(connection_id).ok()?;
let entry = keyring::Entry::new(&svc, KEYCHAIN_ACCOUNT).ok()?;
match entry.get_password() {
Ok(_) => Some(true),
Err(keyring::Error::NoEntry) => Some(false),
// Present but not a plain string, etc.: something is there.
Err(keyring::Error::BadEncoding(_)) => Some(true),
Err(_) => None,
}
}
/// Missing entry is success.
pub fn delete_aws_keys(connection_id: &str) -> Result<(), String> {
delete_entry(&aws_connection_service(connection_id)?, "the AWS access keys")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -496,4 +582,39 @@ mod tests {
// Pasted credentials routinely carry a trailing newline.
assert_eq!(secret_to_store(Some(" ghp_abc123\n")), Some("ghp_abc123"));
}
#[test]
fn aws_keys_debug_redacts_secrets() {
let keys = AwsKeys {
access_key_id: "AKIATESTNOTREAL".into(),
secret_access_key: "test-secret-not-real".into(),
session_token: Some("test-session-token-not-real".into()),
};
let shown = format!("{:?}", keys);
assert!(!shown.contains("test-secret-not-real"), "{shown}");
assert!(!shown.contains("test-session-token-not-real"), "{shown}");
}
#[test]
fn aws_key_service_names_reject_bad_ids() {
for bad in ["../x", "", &"a".repeat(65)] {
assert!(aws_connection_service(bad).is_err(), "{bad:?}");
assert!(get_aws_keys(bad).is_err());
assert!(delete_aws_keys(bad).is_err());
}
assert_eq!(
aws_connection_service("0b9e6a2c-1111").unwrap(),
"triple-c-aws-connection-0b9e6a2c-1111"
);
}
/// There is no mock keyring here, so the "no entry is fine" rule is tested
/// as the pure mapping `delete_entry` applies.
#[test]
fn removing_a_keys_connection_with_no_keyring_entry_succeeds() {
assert_eq!(delete_outcome(Err(keyring::Error::NoEntry), "x"), Ok(()));
assert_eq!(delete_outcome(Ok(()), "x"), Ok(()));
let err = delete_outcome(Err(keyring::Error::NoStorageAccess("denied".into())), "x");
assert!(err.is_err());
}
}
+76 -1
View File
@@ -47,6 +47,12 @@ impl SettingsStore {
})
}
/// A store over an explicit file, for tests (no data-dir lookup).
#[cfg(test)]
pub(crate) fn at_path(file_path: PathBuf) -> Self {
Self { settings: Mutex::new(AppSettings::default()), file_path }
}
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {
self.settings.lock().unwrap_or_else(|e| e.into_inner())
}
@@ -69,8 +75,77 @@ impl SettingsStore {
pub fn update(&self, new_settings: AppSettings) -> Result<AppSettings, String> {
let mut settings = self.lock();
// Save first: a failed save must not leave the unsaved copy in memory.
self.save(&new_settings)?;
*settings = new_settings.clone();
self.save(&settings)?;
Ok(new_settings)
}
/// Read-modify-save under the store's lock, so concurrent callers cannot
/// lose each other's updates. `f` edits a copy; if it fails, or the save
/// fails, memory and disk are unchanged. Returns the saved settings and
/// whatever `f` returned.
pub fn modify<R, F>(&self, f: F) -> Result<(AppSettings, R), String>
where
F: FnOnce(&mut AppSettings) -> Result<R, String>,
{
let mut settings = self.lock();
let mut copy = settings.clone();
let out = f(&mut copy)?;
self.save(&copy)?;
*settings = copy.clone();
Ok((copy, out))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modify_saves_and_returns_the_closure_result() {
let dir = tempfile::tempdir().unwrap();
let store = SettingsStore::at_path(dir.path().join("settings.json"));
let (saved, n) = store
.modify(|s| {
s.hosts.clear();
Ok(7)
})
.unwrap();
assert_eq!(n, 7);
assert_eq!(
serde_json::to_value(&saved).unwrap(),
serde_json::to_value(store.get()).unwrap()
);
assert!(dir.path().join("settings.json").exists());
}
#[test]
fn modify_does_not_change_memory_when_save_fails() {
let dir = tempfile::tempdir().unwrap();
let store = SettingsStore::at_path(dir.path().join("missing/settings.json"));
let before = serde_json::to_value(store.get()).unwrap();
let err = store.modify(|s| {
s.hosts.push(crate::models::HostConnection {
id: "x".into(),
name: "x".into(),
kind: crate::models::HostKind::Aws {
auth: crate::models::AwsAuth::Profile { name: "p".into() },
default_region: "us-west-2".into(),
},
});
Ok(())
});
assert!(err.is_err());
assert_eq!(serde_json::to_value(store.get()).unwrap(), before);
}
#[test]
fn modify_does_not_save_when_the_closure_fails() {
let dir = tempfile::tempdir().unwrap();
let store = SettingsStore::at_path(dir.path().join("settings.json"));
let err = store.modify(|_| -> Result<(), String> { Err("no".into()) }).unwrap_err();
assert_eq!(err, "no");
assert!(!dir.path().join("settings.json").exists());
}
}
@@ -0,0 +1,343 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import HostsSettings from "./HostsSettings";
import { useAppState } from "../../store/appState";
import type { AppSettings, HostConnection, PermissionReport } from "../../lib/types";
const invoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (cmd: string, args?: unknown) => invoke(cmd, args),
}));
const conn: HostConnection = {
id: "h1",
name: "Prod",
kind: { type: "aws", auth: { method: "profile", name: "prod-sso" }, default_region: "eu-west-1" },
};
const keysConn: HostConnection = {
id: "h2",
name: "Keys box",
kind: { type: "aws", auth: { method: "keys", keyring_ref: "r" }, default_region: "us-east-1" },
};
const identity = { account: "123456789012", arn: "arn:aws:iam::123456789012:user/me", user_id: "U" };
function report(over: Partial<PermissionReport>): PermissionReport {
return {
identity,
region: "eu-west-1",
method: "simulated",
summary: "insufficient",
tiers: [
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "denied", note: null }] },
{ tier: "environment_admin", results: [{ action: "iam:CreateRole", verdict: "denied", note: null }] },
],
...over,
};
}
function setHosts(hosts: HostConnection[]) {
useAppState.setState({ appSettings: { hosts } as unknown as AppSettings });
}
describe("HostsSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
setHosts([conn, keysConn]);
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev", "prod-sso"];
if (cmd === "get_settings") return { hosts: [conn, keysConn] };
throw new Error(`unexpected ${cmd}`);
});
});
it("shows Local Docker and existing AWS connections", async () => {
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
expect(screen.getByText("Local Docker")).toBeInTheDocument();
expect(screen.getByText("Prod")).toBeInTheDocument();
expect(screen.getByText("Profile: prod-sso")).toBeInTheDocument();
expect(within(screen.getByTestId("host-h2")).getByText("Access keys")).toBeInTheDocument();
expect(within(screen.getByTestId("host-h1")).getByText("eu-west-1")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Check/ })).toHaveLength(2);
expect(screen.queryByRole("button", { name: "Remove Local Docker" })).toBeNull();
});
it("adds a profile connection and shows the signed-in identity", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev", "prod-sso"];
if (cmd === "add_aws_profile_connection") return conn;
if (cmd === "aws_connection_identity") return identity;
if (cmd === "get_settings") return { hosts: [conn] };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
fireEvent.change(screen.getByLabelText("Default region"), { target: { value: "eu-west-1" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument();
expect(invoke).toHaveBeenCalledWith("add_aws_profile_connection", {
name: "Dev",
profile: "dev",
defaultRegion: "eu-west-1",
});
});
it("offers Sign in when the profile's SSO session expired", async () => {
let identityCalls = 0;
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev"];
if (cmd === "add_aws_profile_connection") return conn;
if (cmd === "aws_connection_identity") {
if (identityCalls++ === 0) throw { kind: "sso_expired", profile: "dev" };
return identity;
}
if (cmd === "aws_connection_sso_login") return undefined;
if (cmd === "get_settings") return { hosts: [conn] };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByText(`Signed in as ${identity.arn}`)).toBeInTheDocument();
expect(invoke).toHaveBeenCalledWith("aws_connection_sso_login", { id: "h1" });
// The connection was added once; only the failed step was retried.
expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1);
});
it("access key inputs are password fields and the session token is optional", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "add_aws_keys_connection") return keysConn;
if (cmd === "aws_connection_identity") return identity;
if (cmd === "get_settings") return { hosts: [keysConn] };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("radio", { name: "Access keys" }));
const id = screen.getByLabelText("Access key ID");
const secret = screen.getByLabelText("Secret access key");
const token = screen.getByLabelText(/Session token/);
for (const el of [id, secret, token]) {
expect(el).toHaveAttribute("type", "password");
expect(el).toHaveAttribute("autocomplete", "off");
}
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "K" } });
fireEvent.change(id, { target: { value: "AKIA" } });
fireEvent.change(secret, { target: { value: "shh" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
await screen.findByText(`Signed in as ${identity.arn}`);
expect(invoke).toHaveBeenCalledWith(
"add_aws_keys_connection",
expect.objectContaining({ accessKeyId: "AKIA", secretAccessKey: "shh", sessionToken: null }),
);
expect(screen.getByLabelText("Access key ID")).toHaveValue("");
expect(screen.getByLabelText("Secret access key")).toHaveValue("");
});
it("check shows Insufficient with the denied actions and the copy-policy buttons", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions") return report({});
if (cmd === "aws_policy_document") return '{"Version":"2012-10-17"}';
throw new Error(`unexpected ${cmd}`);
});
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
expect(await screen.findByText("Insufficient")).toBeInTheDocument();
expect(screen.getByText("ecs:RunTask")).toBeInTheDocument();
expect(screen.getByText(/SCPs or session policies/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Copy workspace-user policy" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"Version":"2012-10-17"}'));
expect(invoke).toHaveBeenCalledWith("aws_policy_document", { tier: "workspace_user" });
expect(screen.getByRole("button", { name: "Copy admin policy" })).toBeInTheDocument();
});
it("shows a neutral Incomplete badge when the only blockers are failed checks", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions")
return report({
tiers: [
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "throttled" }] },
{ tier: "environment_admin", results: [] },
],
});
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
const badge = await screen.findByText("Incomplete — check again");
expect(badge.className).not.toContain("--error");
expect(screen.queryByText("Insufficient")).not.toBeInTheDocument();
expect(screen.getByText(/Some checks couldn't complete/)).toBeInTheDocument();
});
it("explains denied own-principal checks as the first thing to ask an admin for", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions")
return report({
tiers: [
{ tier: "workspace_user", results: [{ action: "iam:SimulatePrincipalPolicy", verdict: "denied", note: null }] },
{ tier: "environment_admin", results: [] },
],
});
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
expect(await screen.findByText(/couldn't inspect the caller's own permissions/)).toBeInTheDocument();
});
it("shows throttling notes with a check-again hint", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions")
return report({
tiers: [
{ tier: "workspace_user", results: [{ action: "ecs:RunTask", verdict: "unverified", note: "Throttling" }] },
{ tier: "environment_admin", results: [] },
],
});
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
expect(await screen.findByText(/Throttling/)).toBeInTheDocument();
expect(screen.getAllByText(/Check again/).length).toBeGreaterThan(0);
});
it("a probed check says writes are unverified", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions")
return report({
method: { probed: { reason: "iam:SimulatePrincipalPolicy denied" } },
summary: "workspace_user",
});
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
expect(await screen.findByText("Workspace user (unverified)")).toBeInTheDocument();
expect(
screen.getByText(
"Simulation not permitted (iam:SimulatePrincipalPolicy denied). Read access was probed; write actions are unverified and will be confirmed on first use.",
),
).toBeInTheDocument();
});
it("asks for confirmation before removing", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "remove_host_connection") return { hosts: [keysConn] };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Remove Prod" }));
expect(invoke).not.toHaveBeenCalledWith("remove_host_connection", expect.anything());
fireEvent.click(screen.getByRole("button", { name: "Confirm remove Prod" }));
await waitFor(() => expect(screen.queryByText("Prod")).toBeNull());
expect(invoke).toHaveBeenCalledWith("remove_host_connection", { id: "h1" });
});
it("never renders a no-op Sign in when the add itself rejects with sso_expired", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev"];
if (cmd === "add_aws_profile_connection") throw { kind: "sso_expired", profile: "dev" };
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
expect(await screen.findByText("Your SSO session for profile dev has expired.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Sign in" })).toBeNull();
expect(screen.getByText("aws sso login --profile dev")).toBeInTheDocument();
});
it("never re-runs the add when the settings refresh fails after a successful add", async () => {
let settingsCalls = 0;
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return ["dev"];
if (cmd === "add_aws_profile_connection") return conn;
if (cmd === "get_settings") {
if (settingsCalls++ === 0) throw "boom";
return { hosts: [conn] };
}
if (cmd === "aws_connection_identity") return identity;
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
await screen.findByRole("option", { name: "dev" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Dev" } });
fireEvent.change(screen.getByLabelText("Profile"), { target: { value: "dev" } });
fireEvent.click(screen.getByRole("button", { name: "Add connection" }));
expect(await screen.findByText(/could not be refreshed/)).toBeInTheDocument();
expect(invoke.mock.calls.filter((c) => c[0] === "add_aws_profile_connection")).toHaveLength(1);
});
it("shows a message when the profile list cannot be read", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") throw "no config";
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
expect(await screen.findByText(/Couldn't read your AWS profiles: no config/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "Access keys" }));
expect(screen.getByLabelText("Access key ID")).toBeInTheDocument();
});
it("shows a message when copying the policy fails", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions") return report({});
if (cmd === "aws_policy_document") return "{}";
throw new Error(`unexpected ${cmd}`);
});
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } });
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
fireEvent.click(await screen.findByRole("button", { name: "Copy admin policy" }));
expect(await screen.findByText(/Could not copy the policy: denied/)).toBeInTheDocument();
});
it.each([
[{ kind: "profile_not_found", profile: "ghost" }, "Profile ghost was not found in your AWS config, or has no credentials."],
[
{ kind: "keys_missing" },
"The access keys for this connection are missing from the keychain. Remove and re-add the connection.",
],
])("a failed Check shows its error: %j", async (err, text) => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
if (cmd === "check_aws_permissions") throw err;
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Check Prod" }));
expect(await screen.findByText(text)).toBeInTheDocument();
expect(screen.queryByTestId("permission-report")).toBeNull();
});
it("moves focus to Confirm when removing, and back to Remove on cancel", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "list_aws_profiles") return [];
throw new Error(`unexpected ${cmd}`);
});
render(<HostsSettings />);
fireEvent.click(screen.getByRole("button", { name: "Remove Prod" }));
expect(screen.getByRole("button", { name: "Confirm remove Prod" })).toHaveFocus();
fireEvent.click(within(screen.getByTestId("host-h1")).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Remove Prod" })).toHaveFocus());
});
});
@@ -0,0 +1,554 @@
import { useEffect, useRef, useState } from "react";
import { useSettings } from "../../hooks/useSettings";
import {
addAwsKeysConnection,
addAwsProfileConnection,
awsConnectionIdentity,
awsConnectionSsoLogin,
awsPolicyDocument,
checkAwsPermissions,
listAwsProfiles,
removeHostConnection,
updateAwsConnection,
} from "../../lib/tauri-commands";
import { toAwsError } from "../../lib/aws-error";
import type {
AwsError,
HostConnection,
PermissionReport,
PermissionSummary,
Tier,
} from "../../lib/types";
import Button from "../ui/Button";
import SegmentedControl from "../ui/SegmentedControl";
import Field, { inputClass, selectClass } from "../ui/Field";
const COMMON_REGIONS = [
"us-east-1", "us-east-2", "us-west-1", "us-west-2", "ca-central-1",
"eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
"ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ap-northeast-2", "ap-south-1",
"sa-east-1",
];
const OWN_PRINCIPAL_ACTIONS = ["iam:GetRole", "iam:SimulatePrincipalPolicy"];
/** Human-readable copy for a failed AWS command; never raw JSON. */
export function awsErrorMessage(e: AwsError): string {
switch (e.kind) {
case "sso_expired":
return `Your SSO session for profile ${e.profile} has expired.`;
case "profile_not_found":
return `Profile ${e.profile} was not found in your AWS config, or has no credentials.`;
case "keys_missing":
return "The access keys for this connection are missing from the keychain. Remove and re-add the connection.";
default:
return e.message;
}
}
const SUMMARY_LABEL: Record<PermissionSummary, string> = {
environment_admin: "Environment admin",
workspace_user: "Workspace user",
insufficient: "Insufficient",
};
const SUMMARY_STYLE: Record<PermissionSummary, string> = {
environment_admin: "text-[var(--success)] border-[var(--success)]/40",
workspace_user: "text-[var(--accent)] border-[var(--accent)]/40",
insufficient: "text-[var(--error)] border-[var(--error)]/40",
};
type AuthChoice = "profile" | "keys";
/** An error with the step that failed, so Sign in can retry just that step. */
interface Failure {
error: AwsError;
retry: () => Promise<void>;
}
function ErrorLine({
failure,
onSignIn,
signingIn,
}: {
failure: Failure;
/** Absent when there is no saved connection to sign in with. */
onSignIn?: () => void;
signingIn: boolean;
}) {
return (
<div role="alert" className="text-xs text-[var(--error)] leading-snug flex items-center gap-2 flex-wrap">
<span>{awsErrorMessage(failure.error)}</span>
{failure.error.kind === "sso_expired" && !onSignIn && (
<span>
Sign in with <code>aws sso login --profile {failure.error.profile}</code> and try again.
</span>
)}
{failure.error.kind === "sso_expired" && onSignIn && (
<Button size="sm" variant="secondary" onClick={onSignIn} disabled={signingIn}>
{signingIn ? "Signing in…" : "Sign in"}
</Button>
)}
</div>
);
}
function PermissionReportView({ report }: { report: PermissionReport }) {
const [copyNote, setCopyNote] = useState<string | null>(null);
const probed = typeof report.method === "object";
// Insufficient only because checks couldn't complete (no denied row in the
// workspace tier, only rows with a note): don't claim the permissions are missing.
const workspaceTier = report.tiers.find((t) => t.tier === "workspace_user");
const incomplete =
report.summary === "insufficient" &&
!!workspaceTier &&
!workspaceTier.results.some((r) => r.verdict === "denied") &&
workspaceTier.results.some((r) => r.note);
const badge = incomplete
? "Incomplete — check again"
: report.summary === "workspace_user" && probed
? "Workspace user (unverified)"
: SUMMARY_LABEL[report.summary];
const badgeStyle = incomplete
? "text-[var(--text-secondary)] border-[var(--border-color)]"
: SUMMARY_STYLE[report.summary];
const flagged = report.tiers
.map((t) => ({ ...t, results: t.results.filter((r) => r.verdict !== "allowed") }))
.filter((t) => t.results.length > 0);
const ownPrincipalDenied =
report.summary === "insufficient" &&
report.tiers.some((t) =>
t.results.some((r) => r.verdict === "denied" && OWN_PRINCIPAL_ACTIONS.includes(r.action)),
);
const copy = async (tier: Tier) => {
try {
await navigator.clipboard.writeText(await awsPolicyDocument(tier));
setCopyNote("Policy copied to clipboard.");
} catch (e) {
setCopyNote(`Could not copy the policy: ${awsErrorMessage(toAwsError(e))}`);
}
};
return (
<div className="space-y-2 pt-2" data-testid="permission-report">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full border ${badgeStyle}`}
>
{badge}
</span>
<span className="text-xs text-[var(--text-secondary)]">
{report.identity.arn} · {report.region}
</span>
</div>
{incomplete && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Some checks couldn't complete (for example because AWS throttled the requests), so the
permissions couldn't be fully assessed. Check again in a moment.
</p>
)}
{typeof report.method === "object" && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
{`Simulation not permitted (${report.method.probed.reason}). Read access was probed; write actions are unverified and will be confirmed on first use.`}
</p>
)}
{ownPrincipalDenied && (
<p className="text-xs text-[var(--warning)] leading-snug">
Triple-C couldn't inspect the caller's own permissions. Both policies grant iam:GetRole and
iam:SimulatePrincipalPolicy on the caller's own principal, so this is the first thing to ask
an admin for.
</p>
)}
{flagged.map((t) => (
<details key={t.tier} className="text-xs">
<summary className="cursor-pointer text-[var(--text-primary)]">
{t.tier === "workspace_user" ? "Workspace user" : "Environment admin"} ({t.results.length} not
allowed)
</summary>
<ul className="mt-1 space-y-0.5 pl-4">
{t.results.map((r) => (
<li key={r.action} className="text-[var(--text-secondary)]">
<span className="font-mono">{r.action}</span> — {r.verdict}
{r.note && (
<>
{" "}
({r.note}). Check again in a moment.
</>
)}
</li>
))}
</ul>
</details>
))}
<div className="flex gap-2 flex-wrap">
<Button size="sm" onClick={() => copy("workspace_user")}>
Copy workspace-user policy
</Button>
<Button size="sm" onClick={() => copy("environment_admin")}>
Copy admin policy
</Button>
</div>
{copyNote && (
<p role="status" className="text-xs text-[var(--text-secondary)]">
{copyNote}
</p>
)}
<p className="text-xs text-[var(--text-secondary)] leading-snug">
The simulation doesn't account for SCPs or session policies.
</p>
</div>
);
}
function ConnectionRow({ host }: { host: HostConnection }) {
const { loadSettings, setAppSettings } = useSettings();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(host.name);
const [region, setRegion] = useState(host.kind.default_region);
const [confirmRemove, setConfirmRemove] = useState(false);
const [busy, setBusy] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const [report, setReport] = useState<PermissionReport | null>(null);
const [failure, setFailure] = useState<Failure | null>(null);
const auth = host.kind.auth;
const rowRef = useRef<HTMLLIElement>(null);
const focusIn = (key: string) =>
rowRef.current?.querySelector<HTMLElement>(`[data-focus="${key}"]`)?.focus();
useEffect(() => {
if (confirmRemove) focusIn("confirm-remove");
}, [confirmRemove]);
const cancelRemove = () => {
setConfirmRemove(false);
requestAnimationFrame(() => focusIn("remove"));
};
const runCheck = async () => {
setBusy(true);
setFailure(null);
try {
setReport(await checkAwsPermissions(host.id));
} catch (e) {
setReport(null);
setFailure({ error: toAwsError(e), retry: runCheck });
} finally {
setBusy(false);
}
};
const signIn = async () => {
if (!failure) return;
const retry = failure.retry;
setSigningIn(true);
try {
await awsConnectionSsoLogin(host.id);
setFailure(null);
await retry();
} catch (e) {
setFailure({ error: toAwsError(e), retry });
} finally {
setSigningIn(false);
}
};
const save = async () => {
setBusy(true);
setFailure(null);
try {
await updateAwsConnection(host.id, name.trim(), region.trim());
await loadSettings();
setEditing(false);
} catch (e) {
setFailure({ error: toAwsError(e), retry: save });
} finally {
setBusy(false);
}
};
const remove = async () => {
setBusy(true);
try {
setAppSettings(await removeHostConnection(host.id));
} catch (e) {
setFailure({ error: toAwsError(e), retry: remove });
setConfirmRemove(false);
setBusy(false);
}
};
return (
<li
ref={rowRef}
data-testid={`host-${host.id}`}
className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2.5 space-y-1.5"
>
{editing ? (
<div className="space-y-2">
<Field label="Name">{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
<Field label="Default region">
{(id) => (
<input id={id} className={inputClass} list="aws-regions" value={region} onChange={(e) => setRegion(e.target.value)} />
)}
</Field>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={save} disabled={busy || !name.trim() || !region.trim()}>
Save changes
</Button>
<Button size="sm" onClick={() => { setEditing(false); setName(host.name); setRegion(host.kind.default_region); }}>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="min-w-0">
<div className="text-[13px] font-medium text-[var(--text-primary)]">{host.name}</div>
<div className="text-xs text-[var(--text-secondary)]">
<span>{auth.method === "profile" ? `Profile: ${auth.name}` : "Access keys"}</span>
{" · "}
<span>{host.kind.default_region}</span>
</div>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={runCheck} disabled={busy} aria-label={`Check ${host.name}`}>
Check
</Button>
<Button size="sm" onClick={() => setEditing(true)} aria-label={`Edit ${host.name}`}>
Edit
</Button>
{!confirmRemove && (
<Button data-focus="remove" size="sm" variant="danger" onClick={() => setConfirmRemove(true)} aria-label={`Remove ${host.name}`}>
Remove
</Button>
)}
</div>
</div>
)}
{confirmRemove && (
<div role="alertdialog" aria-label={`Remove ${host.name}?`} className="flex items-center gap-2 flex-wrap text-xs">
<span>Remove {host.name}? Its stored credentials are deleted.</span>
<Button data-focus="confirm-remove" size="sm" variant="danger" onClick={remove} disabled={busy} aria-label={`Confirm remove ${host.name}`}>
Confirm remove
</Button>
<Button size="sm" onClick={cancelRemove}>
Cancel
</Button>
</div>
)}
{busy && !editing && !confirmRemove && <p role="status" className="text-xs text-[var(--text-secondary)]">Working…</p>}
{failure && <ErrorLine failure={failure} onSignIn={signIn} signingIn={signingIn} />}
{report && <PermissionReportView report={report} />}
</li>
);
}
function AddConnectionForm() {
const { loadSettings } = useSettings();
const [profiles, setProfiles] = useState<string[]>([]);
const [auth, setAuth] = useState<AuthChoice>("profile");
const [name, setName] = useState("");
const [profile, setProfile] = useState("");
const [accessKeyId, setAccessKeyId] = useState("");
const [secret, setSecret] = useState("");
const [token, setToken] = useState("");
const [region, setRegion] = useState("us-east-1");
const [busy, setBusy] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const [signedInAs, setSignedInAs] = useState<string | null>(null);
const [failure, setFailure] = useState<Failure | null>(null);
const [profilesError, setProfilesError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
listAwsProfiles()
.then((p) => {
if (!cancelled) setProfiles(p);
})
.catch((e) => {
if (!cancelled) {
setProfiles([]);
setProfilesError(awsErrorMessage(toAwsError(e)));
}
});
return () => {
cancelled = true;
};
}, []);
const ready =
name.trim() !== "" &&
region.trim() !== "" &&
(auth === "profile" ? profile !== "" : accessKeyId.trim() !== "" && secret !== "");
/** Second step: confirm the new connection can sign in. Retried alone after an SSO login. */
const verify = async (id: string) => {
try {
const who = await awsConnectionIdentity(id);
setSignedInAs(who.arn);
setFailure(null);
} catch (e) {
setFailure({ error: toAwsError(e), retry: () => verify(id) });
}
};
/** Everything after a successful add. Retrying never re-runs the add. */
const afterAdd = async (id: string) => {
const refreshed = await loadSettings();
if (!refreshed) {
setFailure({
error: { kind: "other", message: "The connection was added, but the settings list could not be refreshed." },
retry: () => afterAdd(id),
});
return;
}
await verify(id);
};
const submit = async () => {
setBusy(true);
setFailure(null);
setSignedInAs(null);
try {
setPendingId(null);
const added =
auth === "profile"
? await addAwsProfileConnection(name.trim(), profile, region.trim())
: await addAwsKeysConnection(name.trim(), accessKeyId.trim(), secret, token.trim() || null, region.trim());
if (auth === "keys") {
setAccessKeyId("");
setSecret("");
setToken("");
}
setName("");
setPendingId(added.id);
await afterAdd(added.id);
} catch (e) {
setFailure({ error: toAwsError(e), retry: submit });
} finally {
setBusy(false);
}
};
const signIn = async () => {
if (!failure || !pendingId) return;
const retry = failure.retry;
setSigningIn(true);
try {
await awsConnectionSsoLogin(pendingId);
setFailure(null);
await retry();
} catch (e) {
setFailure({ error: toAwsError(e), retry });
} finally {
setSigningIn(false);
}
};
return (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (ready && !busy) void submit();
}}
>
<h4 className="text-[13px] font-semibold text-[var(--text-primary)]">Add AWS connection</h4>
<Field label="Name">
{(id) => <input id={id} className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />}
</Field>
<SegmentedControl<AuthChoice>
label="Authentication"
value={auth}
onChange={setAuth}
segments={[
{ value: "profile", label: "Profile" },
{ value: "keys", label: "Access keys" },
]}
/>
{auth === "profile" ? (
<Field
label="Profile"
hint={profilesError ? `Couldn't read your AWS profiles: ${profilesError}` : undefined}
>
{(id) => (
<select id={id} className={`${selectClass} w-full`} value={profile} onChange={(e) => setProfile(e.target.value)}>
<option value="">Select a profile…</option>
{profiles.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
)}
</Field>
) : (
<>
<Field label="Access key ID">
{(id) => (
<input id={id} type="password" autoComplete="off" className={inputClass} value={accessKeyId} onChange={(e) => setAccessKeyId(e.target.value)} />
)}
</Field>
<Field label="Secret access key">
{(id) => (
<input id={id} type="password" autoComplete="off" className={inputClass} value={secret} onChange={(e) => setSecret(e.target.value)} />
)}
</Field>
<Field label="Session token (optional)" hint="Only for temporary credentials.">
{(id) => (
<input id={id} type="password" autoComplete="off" className={inputClass} value={token} onChange={(e) => setToken(e.target.value)} />
)}
</Field>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Stored in the OS keychain and never shown again.
</p>
</>
)}
<Field label="Default region">
{(id) => (
<input id={id} className={inputClass} list="aws-regions" value={region} onChange={(e) => setRegion(e.target.value)} />
)}
</Field>
<Button type="submit" size="md" variant="primary" disabled={!ready || busy}>
{busy ? "Adding…" : "Add connection"}
</Button>
{signedInAs && (
<p role="status" className="text-xs text-[var(--success)]">
Signed in as {signedInAs}
</p>
)}
{failure && (
<ErrorLine failure={failure} onSignIn={pendingId ? signIn : undefined} signingIn={signingIn} />
)}
</form>
);
}
export default function HostsSettings() {
const { appSettings } = useSettings();
const hosts = appSettings?.hosts ?? [];
return (
<div className="space-y-3">
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Where project environments run. AWS connections are used to check what your credentials can do.
</p>
<ul className="space-y-2">
<li className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-2.5">
<div className="text-[13px] font-medium text-[var(--text-primary)]">Local Docker</div>
<div className="text-xs text-[var(--text-secondary)]">Always available</div>
</li>
{hosts.map((h) => (
<ConnectionRow key={h.id} host={h} />
))}
</ul>
<div className="pt-3 border-t border-[var(--border-color)]">
<AddConnectionForm />
</div>
<datalist id="aws-regions">
{COMMON_REGIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
</div>
);
}
@@ -36,6 +36,9 @@ const samplePreview: SettingsImportPreview = {
global_hook_install_count: 0,
global_plugin_install_count: 0,
marketplace_account_token_count: 0,
aws_connections: [],
aws_connection_key_count: 0,
removed_aws_connections: [],
};
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
@@ -80,6 +83,50 @@ describe("ImportSettingsModal", () => {
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
});
it("shows the AWS connections and key count, and flags overwrites", async () => {
previewSettingsImport.mockResolvedValue({
...samplePreview,
aws_connections: [
{
name: "Prod",
auth: "keys",
profile_name: null,
default_region: "us-west-2",
overwrites_existing: true,
overwrites_keys: true,
keys_check_failed: false,
local: { name: "Real Prod", auth: "keys", profile_name: null, default_region: "us-east-1" },
},
{
name: "Dev",
auth: "profile",
profile_name: "dev-profile",
default_region: "eu-west-1",
overwrites_existing: false,
overwrites_keys: false,
keys_check_failed: true,
local: null,
},
],
aws_connection_key_count: 1,
removed_aws_connections: [
{ name: "Old", auth: "profile", profile_name: "old-p", default_region: "ap-south-1" },
],
});
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(await screen.findByText(/AWS connection "Prod" \(access keys, us-west-2\)/)).toBeInTheDocument();
expect(screen.getByText(/AWS connection "Dev" \(profile dev-profile, eu-west-1\)/)).toBeInTheDocument();
expect(screen.getByText(/1 AWS access key set$/)).toBeInTheDocument();
expect(screen.getByText(/Overwrites the AWS access keys stored for your connection "Real Prod"/)).toBeInTheDocument();
expect(screen.getByText(/Replaces your connection "Real Prod" \(access keys, us-east-1\) with "Prod"/)).toBeInTheDocument();
expect(screen.getByText(/Could not check whether keys are stored for "Dev".*may overwrite them/)).toBeInTheDocument();
expect(screen.getByText(/Removes your AWS connection "Old" \(profile old-p, ap-south-1\)/)).toBeInTheDocument();
});
it("warns about a custom Docker image every time, not just on change", async () => {
previewSettingsImport.mockResolvedValue({
...samplePreview,
@@ -21,6 +21,7 @@ import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import MarketplaceSettings from "./MarketplaceSettings";
import HostsSettings from "./HostsSettings";
import CertificateSettings from "./CertificateSettings";
import ExportSettingsModal from "./ExportSettingsModal";
import ImportSettingsModal from "./ImportSettingsModal";
@@ -188,6 +189,10 @@ export default function SettingsPanel() {
<GatewaySettings />
</AccordionSection>
<AccordionSection id="hosts" title="Hosts" defaultOpen={false}>
<HostsSettings />
</AccordionSection>
<AccordionSection id="container" title="Container" defaultOpen={false}>
<DockerSettings />
</AccordionSection>
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import { toAwsError } from "./aws-error";
describe("toAwsError", () => {
it("passes a tagged AwsError through", () => {
const e = { kind: "sso_expired", profile: "dev" };
expect(toAwsError(e)).toBe(e);
});
it("wraps strings and Errors as other", () => {
expect(toAwsError("boom")).toEqual({ kind: "other", message: "boom" });
expect(toAwsError(new Error("bad"))).toEqual({ kind: "other", message: "bad" });
});
it("rejects unknown kinds, inherited names and null", () => {
expect(toAwsError({ kind: "nope" }).kind).toBe("other");
expect(toAwsError({ kind: "toString" }).kind).toBe("other");
expect(toAwsError(null).kind).toBe("other");
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { AwsError } from "./types";
// A `Record` over every kind: adding or removing a kind in `AwsError` fails to compile here.
const KINDS: Record<AwsError["kind"], true> = {
sso_expired: true,
profile_not_found: true,
keys_missing: true,
invalid_credentials: true,
access_denied: true,
other: true,
};
/** Narrow what an AWS command rejected with. Anything that is not a tagged `AwsError` becomes `other`. */
export function toAwsError(e: unknown): AwsError {
if (e && typeof e === "object" && "kind" in e) {
const kind = (e as { kind: unknown }).kind;
if (typeof kind === "string" && Object.prototype.hasOwnProperty.call(KINDS, kind)) {
return e as AwsError;
}
}
return { kind: "other", message: typeof e === "string" ? e : e instanceof Error ? e.message : "Unknown error" };
}
@@ -24,6 +24,9 @@ function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImport
global_hook_install_count: 0,
global_plugin_install_count: 0,
marketplace_account_token_count: 0,
aws_connections: [],
aws_connection_key_count: 0,
removed_aws_connections: [],
...overrides,
};
}
+37 -1
View File
@@ -1,4 +1,4 @@
import type { SettingsImportPreview } from "./types";
import type { AwsConnectionSummary, SettingsImportPreview } from "./types";
/** Named things a `SettingsImportPreview` says an import will change, for
* `ImportSettingsModal`'s confirmation list. Does not include anything
@@ -35,6 +35,14 @@ export function describeImport(preview: SettingsImportPreview): string[] {
const n = preview.marketplace_account_token_count;
items.push(`${n} marketplace account token${n === 1 ? "" : "s"}`);
}
for (const c of preview.aws_connections) {
const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim();
items.push(`AWS connection "${c.name}" (${auth}, ${c.default_region})`);
}
if (preview.aws_connection_key_count > 0) {
const n = preview.aws_connection_key_count;
items.push(`${n} AWS access key set${n === 1 ? "" : "s"}`);
}
return items;
}
@@ -82,6 +90,29 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
`Installs ${n} marketplace plugin${n === 1 ? "" : "s"} for all projects. Plugins can bring their own hooks, MCP servers and commands into every project container, and these skip the confirmation that lists what a plugin brings before a Marketplace tab install.`,
);
}
for (const c of preview.aws_connections) {
const localDesc = c.local ? describeAws(c.local) : null;
if (c.overwrites_existing && c.local) {
warnings.push(`Replaces your connection ${localDesc} with "${c.name}" (${c.auth === "keys" ? "access keys" : "profile " + (c.profile_name ?? "")}, ${c.default_region}).`);
}
if (c.overwrites_keys) {
warnings.push(
c.local
? `Overwrites the AWS access keys stored for your connection ${localDesc} (imported as "${c.name}").`
: `Overwrites AWS access keys already stored for the imported connection "${c.name}".`,
);
}
if (c.keys_check_failed) {
warnings.push(
`Could not check whether keys are stored for "${c.name}"${c.local ? ` (your connection ${localDesc})` : ""}; importing may overwrite them.`,
);
}
}
for (const r of preview.removed_aws_connections) {
warnings.push(
`Removes your AWS connection ${describeAws(r)}, which is not in the file. Any access keys stored for it stay in your keychain.`,
);
}
if (preview.image_source === "custom") {
warnings.push(
`Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
@@ -89,3 +120,8 @@ export function describeImportWarnings(preview: SettingsImportPreview): string[]
}
return warnings;
}
function describeAws(c: AwsConnectionSummary): string {
const auth = c.auth === "keys" ? "access keys" : `profile ${c.profile_name ?? ""}`.trim();
return `"${c.name}" (${auth}, ${c.default_region})`;
}
+35 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport } from "./types";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note, ViewerFile, ViewerPoll, ViewerSaved, ViewerState, FileDiff, InstallScope, ItemUpdate, Marketplace, MarketplaceAccount, MarketplaceItemRef, MarketplaceSnapshot, ProjectSyncResult, SyncReport, HostConnection, CallerIdentity, PermissionReport, Tier } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -44,6 +44,40 @@ export const detectAwsConfig = () =>
invoke<string | null>("detect_aws_config");
export const listAwsProfiles = () =>
invoke<string[]>("list_aws_profiles");
// AWS host connections. The commands returning `Promise<…>` below reject with an `AwsError`
// object (not a string); narrow a caught value with `toAwsError` from `./aws-error`.
export const addAwsProfileConnection = (name: string, profile: string, defaultRegion: string) =>
invoke<HostConnection>("add_aws_profile_connection", { name, profile, defaultRegion });
export const addAwsKeysConnection = (
name: string,
accessKeyId: string,
secretAccessKey: string,
sessionToken: string | null,
defaultRegion: string,
) =>
invoke<HostConnection>("add_aws_keys_connection", {
name,
accessKeyId,
secretAccessKey,
sessionToken,
defaultRegion,
});
export const updateAwsConnection = (id: string, name: string, defaultRegion: string) =>
invoke<HostConnection>("update_aws_connection", { id, name, defaultRegion });
/** Rejects with a string. Returns the settings after removal. */
export const removeHostConnection = (id: string) =>
invoke<AppSettings>("remove_host_connection", { id });
export const awsConnectionIdentity = (id: string) =>
invoke<CallerIdentity>("aws_connection_identity", { id });
/** Profile connections only; rejects with a string. */
export const awsConnectionSsoLogin = (id: string) =>
invoke<void>("aws_connection_sso_login", { id });
/** `region` omitted: the connection's default region. */
export const checkAwsPermissions = (id: string, region?: string) =>
invoke<PermissionReport>("check_aws_permissions", { id, region: region ?? null });
export const awsPolicyDocument = (tier: Tier) =>
invoke<string>("aws_policy_document", { tier });
/** Check a corporate CA path and report what would be installed. Never
* rejects for a bad path — the reason comes back in `error`. */
export const inspectCaCertPath = (path: string) =>
+85
View File
@@ -300,6 +300,7 @@ export interface AppSettings {
terminal_gpu_rendering: boolean | null;
marketplace_accounts: MarketplaceAccount[];
marketplaces: Marketplace[];
hosts: HostConnection[];
global_marketplace_installs: MarketplaceInstall[];
}
@@ -424,6 +425,34 @@ export interface SettingsImportPreview {
global_plugin_install_count: number;
/** Marketplace account tokens the import restores to the keychain. */
marketplace_account_token_count: number;
/** AWS connections the import brings (names cleaned; no key values). */
aws_connections: AwsConnectionPreview[];
/** AWS access-key entries the import restores to the keychain. */
aws_connection_key_count: number;
/** Local AWS connections the import drops (it replaces the whole list). */
removed_aws_connections: AwsConnectionSummary[];
}
export interface AwsConnectionSummary {
name: string;
auth: "profile" | "keys";
profile_name: string | null;
default_region: string;
}
export interface AwsConnectionPreview {
name: string;
auth: "profile" | "keys";
profile_name: string | null;
default_region: string;
/** A connection with this id exists here with different content. */
overwrites_existing: boolean;
/** The import carries keys for an id that already has keys stored here. */
overwrites_keys: boolean;
/** The keychain could not be asked whether keys are already stored. */
keys_check_failed: boolean;
/** The local connection with this id, when the import replaces it. */
local: AwsConnectionSummary | null;
}
/** What `apply_settings_import` returns: the settings that were actually
@@ -1099,3 +1128,59 @@ export interface ViewerPoll {
hash: string | null;
size: number | null;
}
export type AwsAuth =
| { method: "profile"; name: string }
| { method: "keys"; keyring_ref: string };
export interface HostConnection {
id: string;
name: string;
kind: { type: "aws"; auth: AwsAuth; default_region: string };
}
export type AwsErrorKind = AwsError["kind"];
/** What a failed AWS command rejects with (`aws::AwsError`, tagged on `kind`). */
export type AwsError =
| { kind: "sso_expired"; profile: string }
| { kind: "profile_not_found"; profile: string }
| { kind: "keys_missing" }
| { kind: "invalid_credentials"; message: string }
| { kind: "access_denied"; action: string; message: string }
| { kind: "other"; message: string };
export interface CallerIdentity {
account: string;
arn: string;
user_id: string;
}
export type Tier = "environment_admin" | "workspace_user";
export type Verdict = "allowed" | "denied" | "unverified";
export interface ActionResult {
action: string;
verdict: Verdict;
/** Set when the row is unverified because a call failed; blocks the tier's claim. */
note: string | null;
}
export interface TierResult {
tier: Tier;
results: ActionResult[];
}
export type PermissionMethod = "simulated" | { probed: { reason: string } };
export type PermissionSummary = "environment_admin" | "workspace_user" | "insufficient";
export interface PermissionReport {
identity: CallerIdentity;
region: string;
method: PermissionMethod;
summary: PermissionSummary;
/** `[workspace_user, environment_admin]`. */
tiers: TierResult[];
}
+278
View File
@@ -0,0 +1,278 @@
# Triple-C AWS policies
These are the IAM policies for running Triple-C workspaces on AWS (ECS). Attach them to the IAM
users, groups, roles or SSO permission sets of the people who use Triple-C. The app reports which
tier the signed-in identity has under **Settings → Hosts → Check**.
| File | What it is |
|---|---|
| `policies/workspace-user.json` | The **workspace user** tier: create, start, stop, connect to and delete **their own** workspaces in an existing environment |
| `policies/environment-admin.json` | The **environment admin** tier: deploy, update and delete the environment stack, and onboard users |
| `policies/permissions-boundary.json` | The **permissions boundary** every Triple-C role must carry. It is never attached to a person |
An environment admin needs **both** tier files. The admin policy adds environment management on
top of everything a workspace user can do. Explicit Deny statements in either file apply to anyone
who has it attached.
## Workspace user
Every per-user resource is tagged `triple-c:owner = ${aws:userid}`. That value is the `UserId`
printed by `aws sts get-caller-identity`. The policy uses this tag so that a user can only do the
following:
- **Run tasks.** The task definition must be a `triple-c-*` one tagged with the user's own owner
ID, and the cluster must be a `triple-c-*` one. The new task is tagged with the user's owner ID.
The user can pass only `triple-c-*` roles tagged with their owner ID, and only to ECS tasks.
- **Work with their own tasks.** They can stop them, `ecs execute-command` into them, and open an
SSM session to them. They can end or resume only sessions they started, and open data channels
(`ssmmessages:OpenDataChannel`) only on those.
- `ecs:ExecuteCommand` is authorized against the **cluster** as well as the task, so the policy
also allows it on `triple-c-*` clusters with no condition. The owner rule is on the task, and
both must pass.
- **Manage their own secrets.** They can create, update and delete `triple-c/*` secrets tagged with
their owner ID.
- A secret must be encrypted with an **environment KMS key**, meaning one tagged
`triple-c:component`. `CreateSecret` must name the key by its full ARN in `KmsKeyId` (a key
ID, an alias or no key at all is refused), and the user can use KMS
(`Encrypt`/`GenerateDataKey`/`Decrypt`) only through Secrets Manager and only with environment
keys. Secrets Manager needs `kms:Decrypt` as well as `GenerateDataKey` to create or update a
secret on a customer key.
- A secret on any other key is refused by an **explicit Deny**: `kms:Encrypt` and
`kms:GenerateDataKey*` through Secrets Manager on a key without `triple-c:component`. The Deny
is needed because the AWS-managed `aws/secretsmanager` key, or any customer key whose key
policy grants account principals directly, needs no identity-policy Allow; without the Deny
such a `CreateSecret` succeeds. Keeping every user secret on the environment key keeps it
under the key policy the environment stack controls. (The permissions boundary does *not*
stop a task role decrypting an `aws/secretsmanager` secret: that key's policy grants the
caller directly.)
- Every `TagResource` on a secret must carry `triple-c:owner = ${aws:userid}` in the request,
even when it only adds `triple-c:project`.
- A secret tagged `triple-c:component` belongs to the environment. A workspace user can never
tag, untag, overwrite or delete one; an explicit Deny enforces this.
- **Manage their own access points.** They can create and delete EFS access points tagged with
their owner ID, and only on the environment's file system, which is the one tagged
`triple-c:component`.
- **Set only the ordinary tag keys.** Every request that tags something may set only
`triple-c:owner`, `triple-c:project` and `triple-c:environment` (`aws:TagKeys`). This applies to
`RunTask`, the ECS and EFS tag-on-create calls, `CreateSecret`, and secret `TagResource`. A
workspace user can never set `triple-c:component`, which marks environment resources and which
the Denies and phase-4 detection trust.
- **Inspect their own principal, as far as IAM can say so.** `iam:GetRole` and
`iam:SimulatePrincipalPolicy`, which the app's permission check uses on the caller's own
principal. An IAM user may inspect only itself (`user/${aws:username}`, with or without a path).
"My own role" cannot be written: IAM refuses `${aws:PrincipalArn}` as a Resource (verified live,
"must be in ARN format or \"*\"") and has no role-name variable. So the `InspectOwnPrincipal`
statement names the roles people actually sign in with:
- `role/aws-reserved/sso.amazonaws.com/*`, the IAM Identity Center permission-set roles;
- `role/triple-c-*`.
A caller on any other role cannot read or simulate its role, so the permission check falls back
to probes and reports **Insufficient**, naming `iam:GetRole`. If your users sign in through other
roles, add those roles' ARNs or path prefixes (for example `arn:aws:iam::*:role/engineers/*`)
to `InspectOwnPrincipal` in both tier policies. Everything matched there can be read and
simulated by every holder of the policy, so keep it to the sign-in roles.
- **Read environment information.** This covers ECS, CloudFormation stacks, the `/triple-c/*` SSM
parameters, the secret list, EFS, and the `/triple-c/*` log groups. Listing is not a secret; the
app filters by owner for display.
## Environment admin
The admin tier manages `triple-c-*` CloudFormation stacks and the resources they create:
- `triple-c-*` IAM roles;
- ECS clusters, capacity providers and task definitions;
- security groups, EFS file systems and KMS keys;
- `/triple-c/*` log groups and `/triple-c/*` SSM parameters;
- ECR repositories and pull-through cache rules.
It is fenced in the following ways.
**Roles are bounded.** The admin can create a `triple-c-*` role, or change its policies, only when
the role carries the `triple-c-permissions-boundary` managed policy as its permissions boundary.
- The boundary is the most a Triple-C role can ever hold:
- ECR image pulls;
- writing `/triple-c/*` logs;
- ECS Exec channels;
- reading `triple-c/*` secrets;
- decrypting only through Secrets Manager, and only with environment (component-tagged) keys;
- mounting and writing only environment (component-tagged) file systems; whether that excludes
root access depends on access points fixing the POSIX user and root directory, which is only
true once phase 6 enforces them (see "Enforced access-point roots" under Phase 6 gaps);
- `sts:GetCallerIdentity`;
- invoking Bedrock models.
- The boundary grants nothing in IAM.
- Explicit Deny statements stop the admin from:
- removing the boundary from a `triple-c-*` role;
- setting any other boundary on one;
- editing or deleting the boundary policy itself.
Without these fences an admin could create a role with any permissions and become an account
administrator.
**No CloudFormation service roles.** The admin cannot create or update a `triple-c-*` stack, or
create a change set for one, with a service role (`RoleARN`).
**Destruction is tag-scoped.** Security groups, EFS file systems and KMS keys can be created only
with a `triple-c:component` tag. They can be changed or deleted only if they carry that tag.
Tagging is allowed only as part of the creating call, or on a resource that is already
Triple-C's.
**What is not scoped.** The scoping above does not cover every action. These actions apply
account-wide:
- `ecs:RegisterTaskDefinition` and `ecs:DeregisterTaskDefinition` on `*`. The admin can deregister
*any* task definition revision in the account. `DeregisterTaskDefinition` has no resource type
(IAM authorizes it against `*`), so it cannot be narrowed.
- `ecr:CreatePullThroughCacheRule` on `*`, which also has no resource type. The admin can add
pull-through cache rules for any prefix. This cannot delete or overwrite anything.
- `elasticfilesystem:CreateFileSystem` on `*` (no resource type), still only with a
`triple-c:component` request tag.
- `ec2:CreateSecurityGroup` in any VPC (the new group itself must be tagged), and `ec2:Describe*`.
- `cloudformation:*` on `triple-c-*` stacks. Whatever a template creates is limited by the actions
above, because CloudFormation runs with the caller's own permissions (see the invariants below).
## Invariants the templates must keep (phase 4)
The policies rely on the following. Phase 4's templates must establish them, and phase 4's
detection should flag any environment that breaks them.
1. **Every `triple-c-*` role carries `triple-c-permissions-boundary`.** The admin tier cannot
create one without it. A role created some other way, such as by an account administrator or by
hand, must still have the boundary. Detection should flag a `triple-c-*` role that has no
boundary.
2. **`triple-c-*` stacks are never deployed with a CloudFormation service role.** `UpdateStack`
and change sets on a stack that already has a service role reuse it without `iam:PassRole`,
and that role is not under the boundary. The admin tier denies setting `RoleARN`. Detection
should flag a `triple-c-*` stack whose `RoleARN` is set.
3. **Every environment-level `triple-c/*` secret carries `triple-c:component`.** A secret with
neither an owner tag nor a component tag could be claimed by any workspace user.
4. **Every environment KMS key and EFS file system carries `triple-c:component`.** The boundary
and the admin tier both rely on this tag.
5. **User secrets use the environment key.** The app must pass the environment KMS key's ARN as
`KmsKeyId` when it creates a secret. The workspace policy explicitly denies Secrets Manager
encryption with any key that lacks `triple-c:component`, including `aws/secretsmanager`.
## One-time setup: the boundary policy
An **account administrator** creates the boundary policy once, before anyone deploys an
environment. The environment-admin tier deliberately cannot do this.
```sh
aws iam create-policy --policy-name triple-c-permissions-boundary \
--policy-document file://policies/permissions-boundary.json
```
Create the policy **without a path**. The admin policy expects exactly
`arn:aws:iam::<account>:policy/triple-c-permissions-boundary`. Phase 4 decides how the app offers
this step; one option is to list it with the environment stack's prerequisites.
## Attaching the tier policies
Create each file as a **customer managed policy** and attach it. In the console, go to
**IAM → Policies → Create policy → JSON**, paste the file, and attach the policy to a user, group
or role. Inline policies do not fit: a role's inline policies share 10,240 characters and an
environment admin needs both tiers (about 11,500), and a user's share 2,048. A managed policy may
hold 6,144 characters, not counting whitespace; the workspace policy is about 6,110 (roughly 30
characters of headroom, so phase 4 must split `workspace-user.json` into two policies before
adding anything to it), and a unit test (`every_policy_fits_a_managed_policy`) fails before it
outgrows that. For IAM Identity Center, add the files as inline policies on the permission set
(the "up to 32,768 characters" limit is unverified) or reference the managed policies.
With the CLI:
```sh
aws iam create-policy --policy-name triple-c-workspace-user \
--policy-document file://policies/workspace-user.json
aws iam attach-user-policy --user-name <user> \
--policy-arn arn:aws:iam::<account>:policy/triple-c-workspace-user
```
## Known gaps
**Phase 4**
- **Per-user execution roles.** A workspace user can pass only roles tagged with their own owner
ID. Each user's onboarding stack must therefore create their execution role as well as their
task role. The alternative is a separate statement that allows the environment's shared
execution role.
- **The admin's full deploy permissions.** Deploying needs the following, added with the same
boundary and tag scoping once the templates exist:
- tag-on-create permissions for ECS, CloudWatch Logs, SSM and ECR;
- autoscaling and instance-profile actions for EC2 capacity;
- `cloudformation:ValidateTemplate`;
- whatever else the templates create.
- **Admin access to user data.** The admin can retag `triple-c-*` roles (`iam:TagRole`). The
boundary also allows every `triple-c/*` secret and every environment file system. As a result,
isolating users *from the admin* depends on the onboarding template's per-user policies, not on
IAM fences.
- **The workspace tier's secret Deny also applies to admins.** If the environment stack ever
manages component-tagged secrets through the admin's credentials, that Deny will block
`PutSecretValue`/`DeleteSecret` for them.
**Phase 5**
- **No secrets in task definitions or `RunTask` overrides.** Nothing sensitive (the agent token,
git credentials) may travel in task-definition environment variables or in `RunTask`
`containerOverrides`: every workspace user can read them through `ecs:Describe*` on `*`. Use
Secrets Manager (`triple-c/*`) references instead.
**Phase 6**
- **Enforced access-point roots.** A workspace user creates their own access points. The
`RootDirectory` and `PosixUser` must be enforced, so that an access point rooted at `/` cannot
read other users' data. Two ways to do this: an EFS file-system policy that pins each access
point to its owner's task role, or access points created by the onboarding stack rather than by
the user.
## Live verification (2026-11-14)
Every point the unit tests' small evaluator could not settle was checked against real IAM in a
disposable account (us-west-2): with the policy simulator, with calls made as throwaway roles that
hold the policies, and with deny probes (a call made as a role with no permissions names the
resource it was authorized against, and creates nothing). The repeatable part is
`app/src-tauri/src/aws/live_tests.rs`:
```sh
cd app/src-tauri
TRIPLEC_AWS_E2E_PROFILE=<admin profile of a disposable account> TRIPLEC_AWS_E2E_REGION=us-west-2 \
cargo test aws::live_tests -- --ignored --test-threads=1
```
It creates `triple-c-e2e-<id>-*` roles and policies, `triple-c/triple-c-e2e-<id>/*` secrets and
`triple-c-e2e-<id>` KMS keys, all tagged `triple-c-test=phase3`, and deletes them when each test
ends (keys are scheduled for deletion, 7 days). One test creates the real
`triple-c-permissions-boundary` policy if the account has none, and deletes it again.
✓ verified as written · ✗→fixed: the policy changed · — could not verify, and why.
| Point | Result |
|---|---|
| `${aws:PrincipalArn}` as a Resource | ✗→fixed. IAM refuses it ("must be in ARN format or \"*\""), so neither tier policy could be attached at all. Now SSO permission-set roles, `triple-c-*` roles and `user/${aws:username}`, `user/*/${aws:username}` (see the workspace user's own principal) |
| SSO role path | ✓ `iam:GetRole` returns `role/aws-reserved/sso.amazonaws.com/<region>/AWSReservedSSO_…`, and the check simulates that ARN. The SSO admin session checks as Environment admin, Simulated |
| `${aws:PrincipalAccount}` in `iam:PermissionsBoundary` | ✓ `CreateRole` with `triple-c-permissions-boundary` succeeds as the admin tier; without a boundary, or with another one, it is denied |
| Boundary fences | ✓ removing, replacing or editing the boundary is an explicit deny |
| Simulator and context | ✓ the simulator substitutes `${aws:userid}` and `${aws:username}` from context entries. It **does** apply the simulated principal's own permissions boundary (verified); it does not apply session policies. SCPs: not verifiable here |
| Simulator rate limit | ✗→fixed (app). `SimulatePrincipalPolicy` throttles ("Rate exceeded") within a full check; the check now uses adaptive retries and two requests at a time |
| KMS `CreateKey` with tags | ✓ allowed: tag-on-create passes the tag rule on `kms:TagResource`. Untagged `CreateKey`, and `CreateAlias`/`TagResource`/`ScheduleKeyDeletion` on an untagged key, are denied |
| ECS Exec on the cluster | ✗→fixed. `ecs:ExecuteCommand` is authorized against the cluster, then the task. Added `ExecOnTripleCClusters`; with only the cluster allowed, the call is denied on the task, so the owner rule still decides |
| ECS tag keys | ✓ (service reference) `aws:ResourceTag` is supported on tasks, clusters and task definitions. Live: — needs phase 4/5 ECS resources |
| SSM `StartSession` to a task | ✓ authorized against `arn:aws:ecs:<region>:<account>:task/<cluster>/<task-id>`, which `task/triple-c-*/*` matches. The tag condition on a real task: — needs phase 5 |
| SSO session ownership | ✓ (AWS docs) the `aws:ssmmessages:session-id` tag is the starter's `aws:userid` for users and role sessions alike. Live: — needs a session. Added `ssmmessages:OpenDataChannel` on own sessions, which AWS's current examples require |
| EC2 tag-on-create | ✓ `CreateSecurityGroup` (DryRun) is allowed with a `triple-c:component` tag and denied without one or with only other tags. `security-group-rule` resources on `AuthorizeSecurityGroup*`: — needs a security group |
| EFS | ✗→fixed: `CreateFileSystem` has no resource type, so `Resource` is now `*`. ✓ a tagged `CreateFileSystem`/`CreateAccessPoint` authorizes `elasticfilesystem:TagResource` first. `elasticfilesystem:CreateAction`, `CreateAccessPoint` against the file system's tags, `ClientMount`/`ClientWrite`: — need phase 6 file systems |
| Secrets tag-on-create | ✓ `CreateSecret` with tags authorizes `secretsmanager:TagResource` separately (refused without `TagOwnSecrets`), and that statement's `StringEqualsIfExists`/`Null` conditions pass at create |
| `secretsmanager:KmsKeyId` | ✓ carries the request's value: a key ARN passes, a bare key ID, an alias or no key is refused |
| Secrets Manager's KMS calls | ✓ made as the caller with `kms:ViaService`. ✗→fixed: `CreateSecret`/`PutSecretValue` on a customer key also need `kms:Decrypt`, which the policy lacked ("Access to KMS is not allowed"). `PutSecretValue` on an own secret now passes |
| `aws/secretsmanager` key | ✓ denied, and the Deny is what denies it: the same policy without `NeverEncryptSecretsWithOtherKeys` creates the secret |
| Secret ownership | ✓ another owner's or a component-tagged secret cannot be written, deleted or claimed; `triple-c:component` cannot be set. An untagged `triple-c/*` secret *can* be claimed (invariant 3) |
| `aws:TagKeys` | ✓ for `CreateSecret` and secret `TagResource`. `RunTask` (including `aws:ecs:*` and propagated tags), `CreateAccessPoint`, ECS/EFS tag-on-create: — need phase 4/5/6 resources |
| Boundary and key tags | ✓ a role under the boundary decrypts through Secrets Manager with a component-tagged key and is refused with an untagged customer key, so KMS supplies the key's `aws:ResourceTag` |
| `cloudformation:RoleArn` | ✓ present when `RoleARN` is given; the Deny fires. The admin tier cannot pass any role to CloudFormation anyway (`PassRole` is only to ECS tasks and EC2) |
| Task definition / pull-through scoping | ✓ both authorize against `*`; they cannot be narrowed |
Still open for the phases that create the resources: `RunTask`'s tag keys and `iam:PassRole`'s
`iam:ResourceTag` on a real run, the owner tag on a real task for `StopTask`/`ExecuteCommand`/
`StartSession`, session ownership for an SSO-started session, and the EFS and security-group-rule
points above. The simulator does not apply session policies, so the first real create is the final
check.
+374
View File
@@ -0,0 +1,374 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageEnvironmentStacks",
"Effect": "Allow",
"Action": [
"cloudformation:*"
],
"Resource": [
"arn:aws:cloudformation:*:*:stack/triple-c-*/*"
]
},
{
"Sid": "ManageBoundedTripleCRoles",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:PutRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:DeleteRolePolicy"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
],
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::${aws:PrincipalAccount}:policy/triple-c-permissions-boundary"
}
}
},
{
"Sid": "TagReadAndDeleteTripleCRoles",
"Effect": "Allow",
"Action": [
"iam:DeleteRole",
"iam:TagRole",
"iam:GetRole"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
]
},
{
"Sid": "PassTripleCRoles",
"Effect": "Allow",
"Action": [
"iam:PassRole"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
],
"Condition": {
"StringEquals": {
"iam:PassedToService": [
"ecs-tasks.amazonaws.com",
"ec2.amazonaws.com"
]
}
}
},
{
"Sid": "KeepTheBoundaryOnTripleCRoles",
"Effect": "Deny",
"Action": [
"iam:DeleteRolePermissionsBoundary"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
]
},
{
"Sid": "KeepTheBoundaryPolicyUnchanged",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion"
],
"Resource": [
"arn:aws:iam::*:policy/triple-c-permissions-boundary"
]
},
{
"Sid": "OnlyTheTripleCBoundaryOnTripleCRoles",
"Effect": "Deny",
"Action": [
"iam:PutRolePermissionsBoundary"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
],
"Condition": {
"StringNotEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::${aws:PrincipalAccount}:policy/triple-c-permissions-boundary"
}
}
},
{
"Sid": "NoServiceRoleForTripleCStacks",
"Effect": "Deny",
"Action": [
"cloudformation:CreateStack",
"cloudformation:UpdateStack",
"cloudformation:CreateChangeSet"
],
"Resource": [
"arn:aws:cloudformation:*:*:stack/triple-c-*/*"
],
"Condition": {
"Null": {
"cloudformation:RoleArn": "false"
}
}
},
{
"Sid": "InspectOwnPrincipal",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:SimulatePrincipalPolicy"
],
"Resource": [
"arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*",
"arn:aws:iam::*:role/triple-c-*",
"arn:aws:iam::*:user/${aws:username}",
"arn:aws:iam::*:user/*/${aws:username}"
]
},
{
"Sid": "ManageClusters",
"Effect": "Allow",
"Action": [
"ecs:CreateCluster",
"ecs:DeleteCluster",
"ecs:PutClusterCapacityProviders"
],
"Resource": [
"arn:aws:ecs:*:*:cluster/triple-c-*"
]
},
{
"Sid": "ManageCapacityProviders",
"Effect": "Allow",
"Action": [
"ecs:CreateCapacityProvider",
"ecs:DeleteCapacityProvider"
],
"Resource": [
"arn:aws:ecs:*:*:capacity-provider/triple-c-*"
]
},
{
"Sid": "ManageTaskDefinitions",
"Effect": "Allow",
"Action": [
"ecs:RegisterTaskDefinition",
"ecs:DeregisterTaskDefinition"
],
"Resource": [
"*"
]
},
{
"Sid": "CreateTaggedSecurityGroups",
"Effect": "Allow",
"Action": [
"ec2:CreateSecurityGroup"
],
"Resource": [
"arn:aws:ec2:*:*:security-group/*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"Sid": "CreateSecurityGroupsInVpcs",
"Effect": "Allow",
"Action": [
"ec2:CreateSecurityGroup"
],
"Resource": [
"arn:aws:ec2:*:*:vpc/*"
]
},
{
"Sid": "TagSecurityGroupsAtCreate",
"Effect": "Allow",
"Action": [
"ec2:CreateTags"
],
"Resource": [
"arn:aws:ec2:*:*:security-group/*"
],
"Condition": {
"StringEquals": {
"ec2:CreateAction": "CreateSecurityGroup"
}
}
},
{
"Sid": "ManageTripleCSecurityGroups",
"Effect": "Allow",
"Action": [
"ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroup*",
"ec2:RevokeSecurityGroup*",
"ec2:CreateTags"
],
"Resource": [
"arn:aws:ec2:*:*:security-group/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "DescribeEc2",
"Effect": "Allow",
"Action": [
"ec2:Describe*"
],
"Resource": [
"*"
]
},
{
"Sid": "CreateTaggedFileSystems",
"Effect": "Allow",
"Action": [
"elasticfilesystem:CreateFileSystem"
],
"Resource": [
"*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"Sid": "TagFileSystemsAtCreate",
"Effect": "Allow",
"Action": [
"elasticfilesystem:TagResource"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:file-system/*"
],
"Condition": {
"StringEquals": {
"elasticfilesystem:CreateAction": "CreateFileSystem"
}
}
},
{
"Sid": "ManageTripleCFileSystems",
"Effect": "Allow",
"Action": [
"elasticfilesystem:DeleteFileSystem",
"elasticfilesystem:CreateMountTarget",
"elasticfilesystem:DeleteMountTarget",
"elasticfilesystem:PutFileSystemPolicy",
"elasticfilesystem:TagResource"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:file-system/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "CreateTaggedKeys",
"Effect": "Allow",
"Action": [
"kms:CreateKey"
],
"Resource": [
"*"
],
"Condition": {
"Null": {
"aws:RequestTag/triple-c:component": "false"
}
}
},
{
"Sid": "ManageTripleCKeys",
"Effect": "Allow",
"Action": [
"kms:ScheduleKeyDeletion",
"kms:PutKeyPolicy",
"kms:TagResource",
"kms:CreateAlias",
"kms:DeleteAlias"
],
"Resource": [
"arn:aws:kms:*:*:key/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "ManageTripleCKeyAliases",
"Effect": "Allow",
"Action": [
"kms:CreateAlias",
"kms:DeleteAlias"
],
"Resource": [
"arn:aws:kms:*:*:alias/triple-c-*"
]
},
{
"Sid": "ManageLogGroups",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:PutRetentionPolicy"
],
"Resource": [
"arn:aws:logs:*:*:log-group:/triple-c/*"
]
},
{
"Sid": "ManageRepositories",
"Effect": "Allow",
"Action": [
"ecr:CreateRepository",
"ecr:DeleteRepository"
],
"Resource": [
"arn:aws:ecr:*:*:repository/triple-c-*",
"arn:aws:ecr:*:*:repository/triple-c/*"
]
},
{
"Sid": "CreatePullThroughCacheRules",
"Effect": "Allow",
"Action": [
"ecr:CreatePullThroughCacheRule"
],
"Resource": [
"*"
]
},
{
"Sid": "ManageEnvironmentParameters",
"Effect": "Allow",
"Action": [
"ssm:PutParameter",
"ssm:DeleteParameter"
],
"Resource": [
"arn:aws:ssm:*:*:parameter/triple-c/*"
]
}
]
}
+121
View File
@@ -0,0 +1,121 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EcrAuthorization",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": [
"*"
]
},
{
"Sid": "PullTripleCImages",
"Effect": "Allow",
"Action": [
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchCheckLayerAvailability",
"ecr:BatchImportUpstreamImage",
"ecr:CreateRepository"
],
"Resource": [
"arn:aws:ecr:*:*:repository/triple-c-*",
"arn:aws:ecr:*:*:repository/triple-c/*"
]
},
{
"Sid": "WriteTripleCLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:*:*:log-group:/triple-c/*"
]
},
{
"Sid": "EcsExecChannels",
"Effect": "Allow",
"Action": [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
],
"Resource": [
"*"
]
},
{
"Sid": "ReadTripleCSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
]
},
{
"Sid": "DecryptThroughSecretsManagerOnly",
"Effect": "Allow",
"Action": [
"kms:Decrypt"
],
"Resource": [
"arn:aws:kms:*:*:key/*"
],
"Condition": {
"StringLike": {
"kms:ViaService": "secretsmanager.*.amazonaws.com"
},
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "MountEnvironmentFileSystems",
"Effect": "Allow",
"Action": [
"elasticfilesystem:ClientMount",
"elasticfilesystem:ClientWrite"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:file-system/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "WhoAmI",
"Effect": "Allow",
"Action": [
"sts:GetCallerIdentity"
],
"Resource": [
"*"
]
},
{
"Sid": "InvokeBedrockModels",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": [
"arn:aws:bedrock:*::foundation-model/*",
"arn:aws:bedrock:*:*:inference-profile/*",
"arn:aws:bedrock:*:*:application-inference-profile/*"
]
}
]
}
+414
View File
@@ -0,0 +1,414 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RunOwnTasks",
"Effect": "Allow",
"Action": [
"ecs:RunTask"
],
"Resource": [
"arn:aws:ecs:*:*:task-definition/triple-c-*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}",
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
},
"ArnLike": {
"ecs:cluster": "arn:aws:ecs:*:*:cluster/triple-c-*"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "TagOwnTasksAtRunTask",
"Effect": "Allow",
"Action": [
"ecs:TagResource"
],
"Resource": [
"arn:aws:ecs:*:*:task/triple-c-*/*"
],
"Condition": {
"StringEquals": {
"ecs:CreateAction": "RunTask",
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "PassOwnTaskRoles",
"Effect": "Allow",
"Action": [
"iam:PassRole"
],
"Resource": [
"arn:aws:iam::*:role/triple-c-*"
],
"Condition": {
"StringEquals": {
"iam:ResourceTag/triple-c:owner": "${aws:userid}",
"iam:PassedToService": "ecs-tasks.amazonaws.com"
}
}
},
{
"Sid": "StopAndExecOwnTasks",
"Effect": "Allow",
"Action": [
"ecs:StopTask",
"ecs:ExecuteCommand"
],
"Resource": [
"arn:aws:ecs:*:*:task/triple-c-*/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "ExecOnTripleCClusters",
"Effect": "Allow",
"Action": [
"ecs:ExecuteCommand"
],
"Resource": [
"arn:aws:ecs:*:*:cluster/triple-c-*"
]
},
{
"Sid": "StartSessionsToOwnTasks",
"Effect": "Allow",
"Action": [
"ssm:StartSession"
],
"Resource": [
"arn:aws:ecs:*:*:task/triple-c-*/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "UsePortForwardingDocument",
"Effect": "Allow",
"Action": [
"ssm:StartSession"
],
"Resource": [
"arn:aws:ssm:*::document/AWS-StartPortForwardingSession"
]
},
{
"Sid": "UseEndOrResumeOwnSessions",
"Effect": "Allow",
"Action": [
"ssm:TerminateSession",
"ssm:ResumeSession",
"ssmmessages:OpenDataChannel"
],
"Resource": [
"arn:aws:ssm:*:*:session/*"
],
"Condition": {
"StringLike": {
"ssm:resourceTag/aws:ssmmessages:session-id": "${aws:userid}"
}
}
},
{
"Sid": "CreateOwnSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:CreateSecret"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
},
"ArnLike": {
"secretsmanager:KmsKeyId": "arn:aws:kms:*:*:key/*"
}
}
},
{
"Sid": "TagOwnSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:TagResource"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"StringEqualsIfExists": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
},
"Null": {
"aws:ResourceTag/triple-c:component": "true"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "ManageOwnSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:PutSecretValue",
"secretsmanager:DeleteSecret"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "NeverTouchEnvironmentSecrets",
"Effect": "Deny",
"Action": [
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"secretsmanager:PutSecretValue",
"secretsmanager:DeleteSecret"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:triple-c/*"
],
"Condition": {
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "CreateOwnAccessPoints",
"Effect": "Allow",
"Action": [
"elasticfilesystem:CreateAccessPoint"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:file-system/*"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"Null": {
"aws:ResourceTag/triple-c:component": "false"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "TagOwnAccessPointsAtCreate",
"Effect": "Allow",
"Action": [
"elasticfilesystem:TagResource"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:access-point/*"
],
"Condition": {
"StringEquals": {
"elasticfilesystem:CreateAction": "CreateAccessPoint",
"aws:RequestTag/triple-c:owner": "${aws:userid}"
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"triple-c:owner",
"triple-c:project",
"triple-c:environment"
]
}
}
},
{
"Sid": "DeleteOwnAccessPoints",
"Effect": "Allow",
"Action": [
"elasticfilesystem:DeleteAccessPoint"
],
"Resource": [
"arn:aws:elasticfilesystem:*:*:access-point/*"
],
"Condition": {
"StringEquals": {
"aws:ResourceTag/triple-c:owner": "${aws:userid}"
}
}
},
{
"Sid": "EncryptThroughSecretsManagerWithEnvironmentKeysOnly",
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:Decrypt"
],
"Resource": [
"arn:aws:kms:*:*:key/*"
],
"Condition": {
"StringLike": {
"kms:ViaService": "secretsmanager.*.amazonaws.com"
},
"Null": {
"aws:ResourceTag/triple-c:component": "false"
}
}
},
{
"Sid": "NeverEncryptSecretsWithOtherKeys",
"Effect": "Deny",
"Action": [
"kms:Encrypt",
"kms:GenerateDataKey*"
],
"Resource": [
"arn:aws:kms:*:*:key/*"
],
"Condition": {
"StringLike": {
"kms:ViaService": "secretsmanager.*.amazonaws.com"
},
"Null": {
"aws:ResourceTag/triple-c:component": "true"
}
}
},
{
"Sid": "ReadEcs",
"Effect": "Allow",
"Action": [
"ecs:Describe*",
"ecs:List*"
],
"Resource": [
"*"
]
},
{
"Sid": "ReadEnvironmentParameters",
"Effect": "Allow",
"Action": [
"ssm:GetParameter*"
],
"Resource": [
"arn:aws:ssm:*:*:parameter/triple-c/*"
]
},
{
"Sid": "ReadStacks",
"Effect": "Allow",
"Action": [
"cloudformation:DescribeStacks",
"cloudformation:ListStacks"
],
"Resource": [
"*"
]
},
{
"Sid": "ListSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:ListSecrets"
],
"Resource": [
"*"
]
},
{
"Sid": "ReadFileSystems",
"Effect": "Allow",
"Action": [
"elasticfilesystem:Describe*"
],
"Resource": [
"*"
]
},
{
"Sid": "ReadLogs",
"Effect": "Allow",
"Action": [
"logs:GetLogEvents",
"logs:FilterLogEvents"
],
"Resource": [
"arn:aws:logs:*:*:log-group:/triple-c/*"
]
},
{
"Sid": "InspectOwnPrincipal",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:SimulatePrincipalPolicy"
],
"Resource": [
"arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*",
"arn:aws:iam::*:role/triple-c-*",
"arn:aws:iam::*:user/${aws:username}",
"arn:aws:iam::*:user/*/${aws:username}"
]
},
{
"Sid": "WhoAmI",
"Effect": "Allow",
"Action": [
"sts:GetCallerIdentity"
],
"Resource": [
"*"
]
}
]
}
+2
View File
@@ -0,0 +1,2 @@
# Build output of the agent crate must never enter the image build context.
triple-c-agent/target
+32
View File
@@ -1,3 +1,31 @@
# ── triple-c-agent ─────────────────────────────────────────────────────────
# The in-container agent the AWS host reaches the container through
# (docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md). A static
# musl binary, so it needs nothing from the image below.
#
# The stage runs on the *build* machine's platform and cross-compiles for the
# target: CI builds amd64 and arm64 together, and compiling Rust for arm64
# under QEMU is many times slower. BuildKit predefines BUILDPLATFORM and
# TARGETARCH; the app's local build (the classic builder) passes them itself
# (docker/image.rs, `local_build_args`), because there an empty `--platform=`
# fails the build. Pure-Rust dependencies only, so rust-lld links aarch64
# with no C cross toolchain (see container/triple-c-agent/README.md).
ARG BUILDPLATFORM
FROM --platform=$BUILDPLATFORM rust:1.98-slim-bookworm AS agent-build
ARG TARGETARCH
RUN rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
WORKDIR /src
COPY triple-c-agent/Cargo.toml triple-c-agent/Cargo.lock ./
COPY triple-c-agent/src ./src
RUN case "$TARGETARCH" in \
amd64) target=x86_64-unknown-linux-musl ;; \
arm64) target=aarch64-unknown-linux-musl ;; \
*) echo "triple-c-agent: unsupported TARGETARCH '$TARGETARCH'" >&2; exit 1 ;; \
esac \
&& CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld \
cargo build --release --locked --target "$target" \
&& install -D -m 0755 "target/$target/release/triple-c-agent" /out/triple-c-agent
FROM ubuntu:24.04
# ── Provenance labels ────────────────────────────────────────────────────────
@@ -460,5 +488,9 @@ COPY triple-c-playwright-heal /usr/local/bin/triple-c-playwright-heal
RUN chmod +x /usr/local/bin/triple-c-playwright-heal
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
RUN chmod +x /usr/local/bin/triple-c-task-runner
# Root-owned in /usr/local/bin: entrypoint.sh starts it as root (only when a
# token is configured), and it rides the snapshot and migration like the other
# shims here.
COPY --from=agent-build /out/triple-c-agent /usr/local/bin/triple-c-agent
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+88 -2
View File
@@ -13,6 +13,50 @@
# upgradable content in /usr/local/bin or /opt, or seed it from here.
# See "Container Lifecycle" in the repo's CLAUDE.md.
# ── triple-c-agent token: off the environment before anything else runs ───
# The agent (started at the very end, see start_agent) authenticates every
# request with this token, and it runs as root, so the token is effectively
# root in this container. Given as an env var it would be inherited by every
# process this script starts (cron, the scheduler, Claude sessions spawned by
# the agent). So it moves to a root-only file *first* and leaves the
# environment; the agent reads the file. A `docker exec` session still sees
# the container's configured env — the local test harness passes it that way
# and accepts that; on AWS nothing is started by `docker exec`.
#
# The agent also takes its exec environment and default working directory
# from how *it* was started, and execs must see what `docker exec` would: the
# container's configured env and WorkingDir, not whatever this script has
# exported or cd'd into by the time it gets to start_agent. So both are
# snapshotted here, before anything else runs, and the agent is started from
# the snapshot (`env -i`). The agent drops its own TRIPLEC_AGENT_* settings.
# >>> agent_token
agent_cwd=$PWD
# The token goes on a tmpfs, never the container's writable layer: a file
# there would persist after stop and ride `docker commit` into the project's
# snapshot image (the commit's SECRET_ENV_KEYS scrub covers env, not files).
# /dev/shm is a tmpfs in Docker and on Fargate; anything else is refused.
if [ -n "${TRIPLEC_AGENT_TOKEN:-}" ]; then
if [ "$(stat -f -c %T /dev/shm 2>/dev/null)" = tmpfs ]; then
mkdir -p /dev/shm/triple-c
chown root:root /dev/shm/triple-c
chmod 700 /dev/shm/triple-c
( umask 077; printf '%s' "$TRIPLEC_AGENT_TOKEN" > /dev/shm/triple-c/agent-token )
export TRIPLEC_AGENT_TOKEN_FILE=/dev/shm/triple-c/agent-token
else
echo "triple-c-agent: /dev/shm is not a tmpfs; not writing the token to disk — agent disabled" >&2
fi
unset TRIPLEC_AGENT_TOKEN
fi
# The exec environment snapshot stays in this script's memory — it holds the
# container's whole configured env, API keys included, so it must not touch
# disk either. Minus what bash itself exports (PWD, OLDPWD, SHLVL, `_`):
# those are this script's, not the container's, and `docker exec` has none.
agent_env=()
if [ -n "${TRIPLEC_AGENT_TOKEN_FILE:-}" ]; then
mapfile -d '' agent_env < <(env -u PWD -u OLDPWD -u SHLVL -u _ -0)
fi
# <<< agent_token
# ── UID/GID remapping ──────────────────────────────────────────────────────
# Match the container's claude user to the host user's UID/GID so that
# bind-mounted files (project dir, docker socket) have correct ownership.
@@ -55,8 +99,15 @@ remap_uid_gid() {
remap_uid_gid
# Fix ownership of home directory after UID/GID change
chown -R claude:claude /home/claude
# Fix ownership of home directory after UID/GID change — only where it is
# wrong. A blanket `chown -R` rewrites every inode, and where /home/claude is
# the image's own copy rather than a volume (an ECS/Fargate task) that copies
# the whole directory up into the writable layer: measured at ~5 minutes on
# 1 vCPU before the agent could start. `find` only stats. `-h` and find's
# default no-follow match `chown -R`'s physical walk (it changes a symlink
# itself, never its target), and like `chown -R` this crosses into the nested
# ~/.claude volume.
find /home/claude \( ! -user claude -o ! -group claude \) -exec chown -h claude:claude {} +
# ── Corporate CA certificates ───────────────────────────────────────────────
# The host's CA material is bind-mounted read-only at /tmp/.host-ca. Triple-C
@@ -766,6 +817,41 @@ timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/ho
# already in trouble. The pause is `read -t` on a FIFO nobody writes —
# a builtin, so it still works when fork does not — with `sleep 1` as the
# fallback should the FIFO be unavailable.
# ── triple-c-agent ──────────────────────────────────────────────────────────
# The app's way into the container on hosts without a Docker API (AWS, over
# an SSM port-forward). Started only when a token was provided, and only now,
# after every setup step above, so "the agent answers /v1/health" also means
# "the container is set up" — the AWS host's readiness signal.
#
# Same survival rules as the keepalive below, for the same reason: it runs as
# root under a root supervisor loop, so a claude-user `pkill`/`kill -9 -1`
# gets EPERM, and a crash is restarted (throttled: a binary that exits at
# once must not spin a core).
# >>> start_agent
start_agent() {
[ -n "${TRIPLEC_AGENT_TOKEN_FILE:-}" ] || return 0
if [ ! -x /usr/local/bin/triple-c-agent ]; then
echo "triple-c-agent: token given but no agent binary in this image — not starting it" >&2
return 0
fi
(
# Keeps the supervisor alive through a stray HUP. The agent resets
# every signal disposition and the mask in each exec it spawns, so this
# does not leak into commands (an ignored SIGHUP otherwise survives
# exec and turns every exec into `nohup`).
trap '' HUP
cd "$agent_cwd" || cd /
while :; do
agent_started=$SECONDS
env -i "${agent_env[@]}" /usr/local/bin/triple-c-agent
echo "triple-c-agent exited ($?) — restarting" >&2
[ $((SECONDS - agent_started)) -ge 5 ] || sleep 5
done
) &
}
# <<< start_agent
start_agent
echo "Triple-C container ready."
keepalive_pid=""
stop_keepalive() {
+1301
View File
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
[package]
name = "triple-c-agent"
version = "0.1.0"
edition = "2021"
description = "In-container agent: docker-exec parity over authenticated HTTP/WebSocket"
publish = false
# Deliberately not part of any workspace: this binary is built for
# *-unknown-linux-musl and baked into the container image, on its own cadence.
# The empty table stops cargo from walking up and adopting it into one.
[workspace]
[lib]
name = "triple_c_agent"
path = "src/lib.rs"
[[bin]]
name = "triple-c-agent"
path = "src/main.rs"
# Pure-Rust only — no TLS, no openssl, nothing with a C build step — so the
# binary links statically for both musl targets without a C cross toolchain.
[dependencies]
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query", "ws"] }
base64 = "0.22"
bytes = "1"
futures-util = { version = "0.3", default-features = false, features = ["std", "sink"] }
# Already in the tree through axum, with these features; named directly for
# the accept loop in `lib.rs::serve` (header-read timeout needs a hyper timer).
hyper = { version = "1", default-features = false, features = ["http1", "server"] }
hyper-util = { version = "0.1", default-features = false, features = ["tokio", "server", "service"] }
libc = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tar = { version = "0.4", default-features = false }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "process", "io-util", "sync", "time", "fs"] }
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json"] }
tempfile = "3"
tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect"] }
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true
panic = "abort"
+112
View File
@@ -0,0 +1,112 @@
# triple-c-agent
The in-container agent: a small static binary that does over authenticated HTTP/WebSocket what
`host::ContainerTarget` does through the Docker API. It provides one-shot, attached and detached
execs, resize, exit status, file upload, archive download and TCP tunnels. The wire protocol is
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`, and that spec is the contract.
This crate implements it and does not extend it.
It is a standalone crate: not part of any workspace, with its own `Cargo.lock`.
## Running
| Variable | Meaning |
|---|---|
| `TRIPLEC_AGENT_TOKEN` | Bearer token, at least 32 bytes. Removed from the agent's env before anything is spawned, so no exec inherits it — but see below. |
| `TRIPLEC_AGENT_TOKEN_FILE` | Read when the env var is unset or empty. Must be owned by the agent's uid and not readable by others. Surrounding whitespace is trimmed. **Preferred.** |
| `TRIPLEC_AGENT_ADDR` | Listen address. Default `127.0.0.1:47900`. |
Removing `TRIPLEC_AGENT_TOKEN` only edits the process's libc environment. The kernel keeps the
environment the agent was started with, so `/proc/<agent pid>/environ` still shows the token to root
and to any process running as the agent's uid. Use `TRIPLEC_AGENT_TOKEN_FILE`; the entrypoint does.
Connections are HTTP/1.1. A client gets 10 seconds to send a complete request head, and at most 256
connections are served at once; past that, new connections wait in the listen backlog for a slot.
WebSocket sessions (attach, tunnel) stop counting once upgraded, and accept messages of at most
1 MiB.
Execs start with every signal at its default disposition and an empty signal mask, whatever the
agent itself inherited (the entrypoint's supervisor ignores `SIGHUP`), and their stdin/stdout/stderr
are owned by the exec's user unless they are `/dev/null`, as runc arranges for `docker exec`.
Without a token of at least 32 bytes the agent exits with status 2. There is no unauthenticated mode. On
startup it logs a single line, `triple-c-agent <version> listening on <addr>`, to stderr. The token
is never logged.
It must run **as root** (see CLAUDE.md, "Never hand the container's lifetime to a process the
`claude` user owns"). Execs then switch to the requested user. A non-root agent can only run execs
as itself and answers `403` for any other user. The tests rely on that.
## Tests
```bash
cd container/triple-c-agent
cargo test # everything, as the current user
cargo clippy --all-targets
# The root-only tests (user switch, upload chown, stdio ownership), as root:
sudo -E env "PATH=$PATH" CARGO_HOME="$HOME/.cargo" RUSTUP_HOME="$HOME/.rustup" \
cargo test -- --include-ignored
sudo chown -R "$(id -u):$(id -g)" target # sudo leaves root-owned build files behind
```
The integration tests (`tests/protocol.rs`) start the real server in-process on `127.0.0.1:0` and
drive every endpoint over real HTTP and WebSocket.
## Static release builds
Both targets build from an x86_64 Linux host with **no C cross toolchain**: every dependency is
pure Rust, and Rust's musl targets ship a self-contained CRT. aarch64 links with the `rust-lld`
that comes with the toolchain. Verified with rustc 1.98.0:
```bash
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
cd container/triple-c-agent
cargo build --release --target x86_64-unknown-linux-musl
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld \
cargo build --release --target aarch64-unknown-linux-musl
```
Output:
- `target/x86_64-unknown-linux-musl/release/triple-c-agent`: `static-pie linked`, about 1.3 MB.
- `target/aarch64-unknown-linux-musl/release/triple-c-agent`: `statically linked`, about 1.2 MB.
Check a build with `file <binary>`. It must say `statically linked` or `static-pie linked`, and never
`dynamically linked` or `interpreter`.
Do not add a dependency that needs a C compiler (`ring`, `openssl-sys`, anything using `cc`). The
aarch64 build would then need a cross C toolchain or `cargo-zigbuild`. TLS is out of scope for v1
anyway, because the transport is the SSM channel or loopback.
In the image, `container/Dockerfile`'s `agent-build` stage does this on the build platform for
`$TARGETARCH`; the app's local build passes those build args itself (`docker/image.rs`).
## Docker parity test
The requirement is that every operation through the agent comes back exactly as it does through
`docker exec`. `app/src-tauri/src/host/agent/live_tests.rs` checks that in one real container,
running each operation through both `ContainerTarget::Docker` and `ContainerTarget::Agent` and
comparing. It needs an image whose entrypoint starts the agent. To build one from the published
sandbox image plus this branch's agent and entrypoint:
```bash
cargo build --release --target x86_64-unknown-linux-musl # or aarch64, on an arm64 host
ctx=$(mktemp -d)
cp target/x86_64-unknown-linux-musl/release/triple-c-agent ../entrypoint.sh "$ctx"/
cat > "$ctx/Dockerfile" <<'DOCKERFILE'
FROM ghcr.io/shadowdao/triple-c-sandbox:latest
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY triple-c-agent /usr/local/bin/triple-c-agent
RUN chmod 0755 /usr/local/bin/entrypoint.sh /usr/local/bin/triple-c-agent
DOCKERFILE
docker build -t tc-agent-e2e:latest "$ctx"
cd ../../app/src-tauri
cargo test --lib the_agent_answers_exactly_as_docker_exec_does -- --ignored --nocapture
```
The test starts and removes its own container. It reaches the agent on the published loopback port
when run on the host, or on the container's bridge address when run from inside another container
on the same daemon. It has already caught one real divergence: `docker exec` sets only `HOME` for
the exec's user, never `USER`/`LOGNAME`.
+340
View File
@@ -0,0 +1,340 @@
//! `GET /v1/attach`: an exec with its stdio carried over a WebSocket.
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::response::Response;
use bytes::Bytes;
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, oneshot};
use crate::pty;
use crate::spawn::{self, ExecSpec, Io, PrepareError};
use crate::{AppState, CountGuard, MAX_WS_MESSAGE};
/// How long the client has to send its spec after the upgrade.
const SPEC_TIMEOUT: Duration = Duration::from_secs(30);
/// After a tty exec's own process exits, how long to keep draining the pty
/// for output still in flight. Waiting for EOF alone is not enough: a
/// background job that kept the terminal open would hold the attach (and its
/// exit frame) forever, while Docker ends the stream when the exec exits.
const TTY_DRAIN_IDLE: Duration = Duration::from_millis(100);
#[derive(Deserialize)]
struct AttachRequest {
#[serde(flatten)]
spec: ExecSpec,
#[serde(default)]
attach_stdin: bool,
#[serde(default)]
cols: Option<u16>,
#[serde(default)]
rows: Option<u16>,
}
/// What the pump sends to the socket.
enum Event {
Out(u8, Bytes),
Exit(i32),
}
/// What the socket sends to the process.
enum Input {
Data(Bytes),
}
pub async fn upgrade(State(st): State<Arc<AppState>>, ws: WebSocketUpgrade) -> Response {
ws.max_message_size(MAX_WS_MESSAGE)
.max_frame_size(MAX_WS_MESSAGE)
.on_upgrade(move |socket| session(socket, st))
}
fn text(v: serde_json::Value) -> Message {
Message::Text(v.to_string().into())
}
async fn fail(mut socket: WebSocket, msg: String) {
let _ = socket.send(text(serde_json::json!({ "type": "error", "error": msg }))).await;
let _ = socket.send(Message::Close(None)).await;
}
fn frame(channel: u8, data: &[u8]) -> Message {
let mut v = Vec::with_capacity(data.len() + 1);
v.push(channel);
v.extend_from_slice(data);
Message::Binary(v.into())
}
async fn session(mut socket: WebSocket, st: Arc<AppState>) {
// 1. The spec, as one text frame.
let first = match tokio::time::timeout(SPEC_TIMEOUT, socket.recv()).await {
Ok(Some(Ok(Message::Text(t)))) => t,
Ok(Some(Ok(_))) => return fail(socket, "the first frame must be a text ExecSpec".into()).await,
_ => return,
};
let req: AttachRequest = match serde_json::from_str(first.as_str()) {
Ok(r) => r,
Err(e) => return fail(socket, format!("invalid attach request: {e}")).await,
};
let tty = req.spec.tty;
let prepared = match spawn::prepare(&st.config, &req.spec, tty) {
Ok(p) => p,
Err(PrepareError::Refused(_, m)) => return fail(socket, m).await,
Err(PrepareError::CannotStart { code, message }) => {
return not_started(socket, &st, code, message).await
}
};
// 2. Start it. A tty exec gets the pty slave on all three fds; stdin
// stays the pty even with `attach_stdin: false` (the client's bytes are
// just ignored), because `stty`, `read -s` and friends need a terminal on
// fd 0 — the same as a Docker tty exec without `-i`.
let (child, master) = if tty {
let (master, slave) = match pty::open(req.cols.unwrap_or(80), req.rows.unwrap_or(24)) {
Ok(p) => p,
Err(e) => return fail(socket, format!("cannot open a pty: {e}")).await,
};
let fds = (|| Ok::<_, std::io::Error>((slave.try_clone()?, slave.try_clone()?, slave)))();
let (a, b, c) = match fds {
Ok(f) => f,
Err(e) => return fail(socket, format!("cannot open a pty: {e}")).await,
};
// The slave copies are moved into the Command and closed when this
// returns, so the master sees EOF once the *child's* copies close.
match prepared.spawn(Io::Fd(a), Io::Fd(b), Io::Fd(c), true) {
Ok(child) => (child, Some(Arc::new(master))),
Err(PrepareError::CannotStart { code, message }) => {
return not_started(socket, &st, code, message).await
}
Err(PrepareError::Refused(_, m)) => return fail(socket, m).await,
}
} else {
let stdin = if req.attach_stdin { Io::Piped } else { Io::Null };
match prepared.spawn(stdin, Io::Piped, Io::Piped, false) {
Ok(child) => (child, None),
Err(PrepareError::CannotStart { code, message }) => {
return not_started(socket, &st, code, message).await
}
Err(PrepareError::Refused(_, m)) => return fail(socket, m).await,
}
};
let exec_id = st.execs.insert_running(master.clone());
if socket
.send(text(serde_json::json!({ "type": "started", "exec_id": exec_id })))
.await
.is_err()
{
// Gone before it began; still let the process run to an exit code.
}
// 3. The pump owns the child and its output for the process's whole life,
// independent of the socket: if the client leaves, it keeps draining (and
// discarding) output so the process is never wedged on a full pipe, and
// it records the exit code either way.
let (ev_tx, mut ev_rx) = mpsc::channel::<Event>(64);
let (in_tx, in_rx) = mpsc::unbounded_channel::<Input>();
let (hangup_tx, hangup_rx) = oneshot::channel::<()>();
let guard = CountGuard::execs(&st);
{
let st = st.clone();
let exec_id = exec_id.clone();
let master = master.clone();
tokio::spawn(async move {
let _active = guard;
let code = match master {
Some(m) => pump_tty(child, m, in_rx, hangup_rx, req.attach_stdin, &ev_tx).await,
None => pump_pipes(child, in_rx, &ev_tx).await,
};
st.execs.finish(&exec_id, code);
let _ = ev_tx.send(Event::Exit(code)).await;
});
}
// 4. Shuttle between socket and pump. Unbounded stdin on purpose: a child
// that is not reading stdin must not stop us forwarding its output, or a
// heavy two-way stream deadlocks. The client is authenticated; how much it
// queues is its own business, as with Docker's hijacked connection.
// The loop only `break`s when the client has gone; the normal end (exit
// frame sent) `return`s from inside it.
let mut in_tx = Some(in_tx);
loop {
tokio::select! {
ev = ev_rx.recv() => match ev {
Some(Event::Out(ch, data)) => {
st.activity.touch();
if socket.send(frame(ch, &data)).await.is_err() {
break;
}
}
Some(Event::Exit(code)) => {
let _ = socket.send(text(serde_json::json!({ "type": "exit", "code": code }))).await;
let _ = socket.send(Message::Close(None)).await;
return;
}
None => return,
},
msg = socket.recv() => match msg {
Some(Ok(Message::Binary(data))) => {
st.activity.touch();
if req.attach_stdin {
if let Some(tx) = &in_tx {
let _ = tx.send(Input::Data(data));
}
}
}
Some(Ok(Message::Text(t))) => {
let is_close = serde_json::from_str::<serde_json::Value>(t.as_str())
.ok()
.is_some_and(|v| v["type"] == "close_stdin");
if is_close {
// Dropping the sender ends the writer, which drops the
// child's stdin: EOF, like Docker's CloseWrite.
in_tx = None;
}
}
Some(Ok(Message::Ping(_) | Message::Pong(_))) => {}
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
},
}
}
// 5. The client went away, as a dropped `docker exec` does. A tty exec
// gets SIGHUP on its process group — what a terminal hangup delivers. A
// non-tty exec only loses stdin and is NOT killed: Docker does not kill
// it either, and `gh_login`/`setup-token` clean up after themselves with
// their own `pkill`. The pump keeps running and records the exit code.
//
// The pump sends the SIGHUP, not this task: only the pump knows whether it
// has already reaped the process, and once it has, the pid — which is the
// process-group id — may belong to someone else. If the pump is already
// gone the send just fails.
drop(in_tx);
if tty {
let _ = hangup_tx.send(());
}
}
/// The command could not start: register it as finished, and tell the client
/// the way a Docker attach would show it — started, the reason on stderr,
/// then the exit code.
async fn not_started(mut socket: WebSocket, st: &Arc<AppState>, code: i32, message: String) {
let id = st.execs.insert_finished(code);
let _ = socket.send(text(serde_json::json!({ "type": "started", "exec_id": id }))).await;
let _ = socket.send(frame(2, format!("{message}\n").as_bytes())).await;
let _ = socket.send(text(serde_json::json!({ "type": "exit", "code": code }))).await;
let _ = socket.send(Message::Close(None)).await;
}
/// Send output unless the socket side has gone; output after that is drained
/// and discarded so the process never blocks on a full pipe.
async fn emit(tx: &mpsc::Sender<Event>, ch: u8, data: &[u8]) {
let _ = tx.send(Event::Out(ch, Bytes::copy_from_slice(data))).await;
}
async fn pump_pipes(
mut child: tokio::process::Child,
mut input: mpsc::UnboundedReceiver<Input>,
tx: &mpsc::Sender<Event>,
) -> i32 {
let mut stdout = child.stdout.take().expect("piped stdout");
let mut stderr = child.stderr.take().expect("piped stderr");
if let Some(mut stdin) = child.stdin.take() {
tokio::spawn(async move {
while let Some(Input::Data(d)) = input.recv().await {
if stdin.write_all(&d).await.is_err() {
break;
}
let _ = stdin.flush().await;
}
// `stdin` drops here: EOF for the child.
});
}
let (mut ob, mut eb) = (vec![0u8; 16 * 1024], vec![0u8; 16 * 1024]);
let (mut out_open, mut err_open) = (true, true);
let mut code: Option<i32> = None;
// The exit code is taken as soon as the process exits (so `GET
// /v1/exec/{id}` is not held up by a grandchild still holding a pipe), but
// the exit *frame* waits for both streams to end: "all output has been
// sent" first, as the spec requires.
while out_open || err_open || code.is_none() {
tokio::select! {
r = stdout.read(&mut ob), if out_open => match r {
Ok(n) if n > 0 => emit(tx, 1, &ob[..n]).await,
_ => out_open = false,
},
r = stderr.read(&mut eb), if err_open => match r {
Ok(n) if n > 0 => emit(tx, 2, &eb[..n]).await,
_ => err_open = false,
},
s = child.wait(), if code.is_none() => {
code = Some(s.map(spawn::exit_code).unwrap_or(255));
}
}
}
code.unwrap_or(255)
}
async fn pump_tty(
mut child: tokio::process::Child,
master: Arc<pty::PtyMaster>,
mut input: mpsc::UnboundedReceiver<Input>,
hangup: oneshot::Receiver<()>,
attach_stdin: bool,
tx: &mpsc::Sender<Event>,
) -> i32 {
// `Some` until the client has gone (or the session ended without saying
// so), so a completed receiver is never polled again.
let mut hangup = Some(hangup);
if attach_stdin {
let master = master.clone();
tokio::spawn(async move {
while let Some(Input::Data(d)) = input.recv().await {
if master.write_all(&d).await.is_err() {
break;
}
}
// A tty has no EOF to give: closing stdin on a terminal is not
// a thing, and the pty must stay open for output. Docker's
// CloseWrite on a tty exec is likewise not an EOF to the process.
});
}
let mut buf = vec![0u8; 16 * 1024];
let mut code: Option<i32> = None;
loop {
if let Some(c) = code {
match tokio::time::timeout(TTY_DRAIN_IDLE, master.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => emit(tx, 1, &buf[..n]).await,
_ => return c,
}
continue;
}
tokio::select! {
r = master.read(&mut buf) => match r {
// Everything is channel 1 on a tty: Docker's `Console` stream
// has no stderr, and the app maps it to stdout.
Ok(n) if n > 0 => emit(tx, 1, &buf[..n]).await,
_ => {
let s = child.wait().await;
return s.map(spawn::exit_code).unwrap_or(255);
}
},
s = child.wait() => {
code = Some(s.map(spawn::exit_code).unwrap_or(255));
}
// Only reachable while `code` is `None`: `child.wait()` has not
// returned, so the process is unreaped (alive or a zombie) and its
// pid cannot have been reused for another process group.
r = async { hangup.as_mut().expect("guarded by is_some").await }, if hangup.is_some() => {
hangup = None;
if r.is_ok() {
spawn::signal_group(child.id(), libc::SIGHUP);
}
}
}
}
}
+280
View File
@@ -0,0 +1,280 @@
//! One-shot and detached execs, exec status, and resize.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use bytes::Bytes;
use base64::Engine;
use serde::Deserialize;
use tokio::io::AsyncReadExt;
use crate::pty::PtyMaster;
use crate::spawn::{self, ExecSpec, Io, PrepareError};
use crate::{error, AppState, CountGuard, MAX_ONESHOT_OUTPUT};
/// How long a finished exec stays answerable. The spec promises "at least 10
/// minutes": the app polls `GET /v1/exec/{id}` after an attach ends, and a
/// slow poll (a laptop waking from sleep) must not turn a real exit code into
/// an unknown id.
const KEEP_FINISHED: Duration = Duration::from_secs(10 * 60);
pub struct ExecEntry {
pub running: bool,
pub exit_code: Option<i32>,
finished_at: Option<Instant>,
/// Present only for tty execs that are still running — what `resize`
/// needs. Dropped on exit so the terminal is released.
pub pty: Option<Arc<PtyMaster>>,
}
/// Exec ids → status, for `detached` and `attach` execs.
#[derive(Default)]
pub struct Registry {
map: Mutex<HashMap<String, ExecEntry>>,
}
impl Registry {
/// Register a running exec and return its id. Expired entries are pruned
/// on the way in, which bounds the map by the insert rate × 10 minutes.
pub fn insert_running(&self, pty: Option<Arc<PtyMaster>>) -> String {
self.insert(ExecEntry {
running: true,
exit_code: None,
finished_at: None,
pty,
})
}
/// Register an exec that never started (126/127), so its id answers like
/// any finished exec — which is how Docker presents it.
pub fn insert_finished(&self, code: i32) -> String {
self.insert(ExecEntry {
running: false,
exit_code: Some(code),
finished_at: Some(Instant::now()),
pty: None,
})
}
fn insert(&self, entry: ExecEntry) -> String {
let id = new_id();
let mut map = self.map.lock().unwrap();
map.retain(|_, e| e.finished_at.is_none_or(|t| t.elapsed() < KEEP_FINISHED));
map.insert(id.clone(), entry);
id
}
pub fn finish(&self, id: &str, code: i32) {
if let Some(e) = self.map.lock().unwrap().get_mut(id) {
e.running = false;
e.exit_code = Some(code);
e.finished_at = Some(Instant::now());
e.pty = None;
}
}
fn with<R>(&self, id: &str, f: impl FnOnce(&ExecEntry) -> R) -> Option<R> {
self.map.lock().unwrap().get(id).map(f)
}
}
/// 64 hex characters from the kernel's CSPRNG — Docker's exec id shape. Ids
/// are not secrets (the token is the boundary), but guessable ones would let
/// one client poke at another's execs for no reason.
fn new_id() -> String {
let mut bytes = [0u8; 32];
let mut filled = 0;
while filled < bytes.len() {
let n = unsafe {
libc::getrandom(bytes[filled..].as_mut_ptr().cast(), bytes.len() - filled, 0)
};
if n > 0 {
filled += n as usize;
} else if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
// getrandom(2) cannot fail on a supported kernel once the pool is
// initialised; panicking beats handing out predictable ids.
panic!("getrandom failed: {}", std::io::Error::last_os_error());
}
}
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Map a spec refusal onto an HTTP error response.
pub fn refusal(status: StatusCode, msg: String) -> Response {
error(status, msg)
}
/// Parse a JSON body without insisting on `Content-Type: application/json`.
/// The spec says bodies are JSON and says nothing about the header, so a
/// client that omits it (curl `-d`, a hand-rolled hyper request) must not get
/// a 415 for a body that is perfectly good JSON.
fn parse_json<T: serde::de::DeserializeOwned>(body: &[u8]) -> Result<T, String> {
serde_json::from_slice(body).map_err(|e| format!("invalid request body: {e}"))
}
#[derive(Deserialize)]
pub struct OneshotRequest {
#[serde(flatten)]
spec: ExecSpec,
#[serde(default)]
limit: Option<usize>,
}
/// `POST /v1/exec`.
pub async fn oneshot(
State(st): State<Arc<AppState>>,
body: Bytes,
) -> Response {
let req: OneshotRequest = match parse_json(&body) {
Ok(r) => r,
Err(e) => return error(StatusCode::BAD_REQUEST, e),
};
let limit = req.limit.unwrap_or(MAX_ONESHOT_OUTPUT);
// A one-shot never has a tty (spec), whatever the spec's `tty` says.
let prepared = spawn::prepare(&st.config, &req.spec, false);
let child = prepared.and_then(|p| p.spawn(Io::Null, Io::Piped, Io::Piped, false));
let mut child = match child {
Ok(c) => c,
Err(PrepareError::Refused(s, m)) => return refusal(s, m),
Err(PrepareError::CannotStart { code, message }) => {
// Docker's shape for an exec that could not start: a finished exec
// with 126/127 and the reason on stderr — not an API error.
return oneshot_response(&[(false, format!("{message}\n").into_bytes())], code);
}
};
let _active = CountGuard::execs(&st);
let pid = child.id();
let mut stdout = child.stdout.take().expect("piped stdout");
let mut stderr = child.stderr.take().expect("piped stderr");
// (is_stdout, bytes) in arrival order; adjacent reads from the same
// stream are merged, which changes nothing a client can rebuild.
let mut chunks: Vec<(bool, Vec<u8>)> = Vec::new();
let mut total = 0usize;
let (mut out_open, mut err_open) = (true, true);
let mut ob = vec![0u8; 16 * 1024];
let mut eb = vec![0u8; 16 * 1024];
while out_open || err_open {
let (is_out, n) = tokio::select! {
r = stdout.read(&mut ob), if out_open => (true, r.unwrap_or(0)),
r = stderr.read(&mut eb), if err_open => (false, r.unwrap_or(0)),
};
if n == 0 {
if is_out { out_open = false } else { err_open = false }
continue;
}
// The same test as `docker/exec.rs`'s `push_capped`: refuse the chunk
// that would take the total past `limit`, so exactly `limit` bytes is
// still a success on both transports.
if total + n > limit {
// Kill the whole process group, not just the pid: a pipeline or a
// backgrounded writer would otherwise keep running with nobody
// reading. Reaped in the background so the response is not held
// hostage by a process stuck in uninterruptible sleep.
spawn::signal_group(pid, libc::SIGKILL);
tokio::spawn(async move {
let _ = child.wait().await;
});
// Byte-for-byte the Docker path's message, so the client can pass
// it through and `OUTPUT_LIMIT_MARKER` checks keep matching.
return error(
StatusCode::PAYLOAD_TOO_LARGE,
format!("OUTPUT_LIMIT: Command output exceeded {limit} bytes and was abandoned"),
);
}
total += n;
let data = if is_out { &ob[..n] } else { &eb[..n] };
match chunks.last_mut() {
Some((s, buf)) if *s == is_out => buf.extend_from_slice(data),
_ => chunks.push((is_out, data.to_vec())),
}
}
let code = match child.wait().await {
Ok(status) => spawn::exit_code(status),
Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("wait failed: {e}")),
};
oneshot_response(&chunks, code)
}
fn oneshot_response(chunks: &[(bool, Vec<u8>)], code: i32) -> Response {
let b64 = base64::engine::general_purpose::STANDARD;
let chunks: Vec<_> = chunks
.iter()
.map(|(is_out, d)| serde_json::json!({ "s": if *is_out { "out" } else { "err" }, "d": b64.encode(d) }))
.collect();
Json(serde_json::json!({ "chunks": chunks, "exit_code": code })).into_response()
}
/// `POST /v1/exec/detached`: stdin, stdout and stderr all `/dev/null`.
pub async fn detached(
State(st): State<Arc<AppState>>,
body: Bytes,
) -> Response {
let spec: ExecSpec = match parse_json(&body) {
Ok(r) => r,
Err(e) => return error(StatusCode::BAD_REQUEST, e),
};
let child = spawn::prepare(&st.config, &spec, false)
.and_then(|p| p.spawn(Io::Null, Io::Null, Io::Null, false));
let id = match child {
Ok(mut child) => {
let id = st.execs.insert_running(None);
let guard = CountGuard::execs(&st);
let (st2, id2) = (st.clone(), id.clone());
tokio::spawn(async move {
let _active = guard;
let code = child.wait().await.map(spawn::exit_code).unwrap_or(255);
st2.execs.finish(&id2, code);
});
id
}
Err(PrepareError::Refused(s, m)) => return refusal(s, m),
// Docker accepts a detached exec of a missing binary and reports the
// failure as its exit code; the output has nowhere to go.
Err(PrepareError::CannotStart { code, .. }) => st.execs.insert_finished(code),
};
Json(serde_json::json!({ "exec_id": id })).into_response()
}
/// `GET /v1/exec/{id}`.
pub async fn status(State(st): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
match st.execs.with(&id, |e| (e.running, e.exit_code)) {
Some((running, code)) => {
Json(serde_json::json!({ "running": running, "exit_code": code })).into_response()
}
None => error(StatusCode::NOT_FOUND, format!("no such exec: {id}")),
}
}
#[derive(Deserialize)]
pub struct ResizeRequest {
cols: u16,
rows: u16,
}
/// `POST /v1/exec/{id}/resize`: `204`, or `409` for anything that is not a
/// running tty exec.
pub async fn resize(
State(st): State<Arc<AppState>>,
Path(id): Path<String>,
body: Bytes,
) -> Response {
let req: ResizeRequest = match parse_json(&body) {
Ok(r) => r,
Err(e) => return error(StatusCode::BAD_REQUEST, e),
};
let pty = match st.execs.with(&id, |e| e.pty.clone()) {
None => return error(StatusCode::NOT_FOUND, format!("no such exec: {id}")),
Some(None) => return error(StatusCode::CONFLICT, "exec is not a running tty exec"),
Some(Some(p)) => p,
};
match pty.resize(req.cols, req.rows) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error(StatusCode::INTERNAL_SERVER_ERROR, format!("resize failed: {e}")),
}
}
+265
View File
@@ -0,0 +1,265 @@
//! `PUT /v1/files` and `GET /v1/archive`: the agent's side of the app's file
//! upload and Docker's archive download.
use std::io::{self, Write};
use std::os::fd::AsRawFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::rejection::QueryRejection;
use axum::extract::{Query, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use bytes::Bytes;
use futures_util::StreamExt;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
use crate::{error, AppState};
fn query_rejection(r: QueryRejection) -> Response {
error(StatusCode::BAD_REQUEST, format!("invalid query: {}", r.body_text()))
}
#[derive(Deserialize)]
pub struct PutQuery {
dir: String,
name: String,
/// Octal, e.g. `644`. Defaults to 0644, the mode the app's uploads use.
#[serde(default)]
mode: Option<String>,
#[serde(default)]
uid: Option<u32>,
#[serde(default)]
gid: Option<u32>,
}
/// A name the spec allows: one path component, nothing that walks.
fn valid_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\0')
}
/// `PUT /v1/files?dir=&name=&mode=&uid=&gid=`.
///
/// Mirrors what Docker's archive extractor does with the app's single-file
/// tar (`build_single_file_tar`): an existing file is unlinked and a new one
/// created — never written through, so a hard link or a file another process
/// holds open keeps its old content — then given `mode` and `uid:gid`, mtime
/// now. `dir` is not created: the app has already checked it, and silently
/// making directories is how a typo becomes a stray tree.
pub async fn put_file(
State(st): State<Arc<AppState>>,
q: Result<Query<PutQuery>, QueryRejection>,
body: Body,
) -> Response {
let Query(q) = match q {
Ok(q) => q,
Err(r) => return query_rejection(r),
};
if !q.dir.starts_with('/') {
return error(StatusCode::BAD_REQUEST, "dir must be an absolute path");
}
if !valid_name(&q.name) {
return error(StatusCode::BAD_REQUEST, "name must be a single path component");
}
let mode = match q.mode.as_deref() {
None => 0o644,
Some(m) => match u32::from_str_radix(m, 8) {
Ok(m) if m <= 0o7777 => m,
_ => return error(StatusCode::BAD_REQUEST, format!("invalid octal mode: {m}")),
},
};
let (euid, egid) = unsafe { (libc::geteuid(), libc::getegid()) };
let uid = q.uid.unwrap_or(euid);
let gid = q.gid.unwrap_or(egid);
// Same rule as execs: a non-root agent (tests) can only act as itself.
// Checked before touching the filesystem, so a refusal changes nothing.
if euid != 0 && (uid != euid || gid != egid) {
return error(StatusCode::FORBIDDEN, "the agent is not root and cannot chown to another user");
}
// `dir` is resolved following symlinks, and the write runs as root. That
// is deliberate parity, not an oversight: Docker's archive upload — what
// this replaces — also resolves symlinks inside the container. It is not
// a privilege boundary either: the `claude` user who could plant a
// symlink has passwordless sudo in this image (Dockerfile, sudoers.d), so
// redirecting a root write gains nothing `sudo` doesn't. The final
// component is still opened O_NOFOLLOW|O_CREAT|O_EXCL below, so an
// existing symlink *at* the name is replaced, never written through.
// Revisit with openat2(RESOLVE_NO_SYMLINKS) if that sudo ever goes.
match std::fs::metadata(&q.dir) {
Ok(m) if m.is_dir() => {}
Ok(_) => return error(StatusCode::BAD_REQUEST, format!("{} is not a directory", q.dir)),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return error(StatusCode::NOT_FOUND, format!("{} does not exist", q.dir))
}
Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("{}: {e}", q.dir)),
}
let path = PathBuf::from(&q.dir).join(&q.name);
let shown = format!("{}/{}", q.dir.trim_end_matches('/'), q.name);
// Unlink, then create — tar extraction's replace. A directory in the way
// is refused rather than removed recursively.
match std::fs::symlink_metadata(&path) {
Ok(m) if m.is_dir() => {
return error(StatusCode::CONFLICT, format!("{shown} is a directory"));
}
Ok(_) => {
if let Err(e) = std::fs::remove_file(&path) {
return error(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot replace {shown}: {e}"));
}
}
Err(_) => {}
}
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&path);
let file = match file {
Ok(f) => f,
Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot create {shown}: {e}")),
};
// Owner before mode: chown clears setuid/setgid bits, so the mode has to
// land last to survive. fchmod rather than the open mode so the umask
// does not get a say — tar extraction as root restores modes exactly.
let fd = file.as_raw_fd();
let set = unsafe {
(euid != 0 || libc::fchown(fd, uid, gid) == 0) && libc::fchmod(fd, mode as libc::mode_t) == 0
};
if !set {
let e = io::Error::last_os_error();
let _ = std::fs::remove_file(&path);
return error(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot set owner/mode on {shown}: {e}"));
}
let mut file = tokio::fs::File::from_std(file);
let mut stream = body.into_data_stream();
let mut written: u64 = 0;
let fail = |status, msg: String| {
let _ = std::fs::remove_file(&path);
error(status, msg)
};
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => return fail(StatusCode::BAD_REQUEST, format!("upload interrupted: {e}")),
};
written += chunk.len() as u64;
if written > st.config.max_file_bytes {
return fail(
StatusCode::PAYLOAD_TOO_LARGE,
format!("File too large to upload (limit {} MB)", st.config.max_file_bytes / (1024 * 1024)),
);
}
if let Err(e) = file.write_all(&chunk).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot write {shown}: {e}"));
}
}
if let Err(e) = file.flush().await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot write {shown}: {e}"));
}
Json(serde_json::json!({ "path": shown })).into_response()
}
#[derive(Deserialize)]
pub struct ArchiveQuery {
path: String,
}
/// `GET /v1/archive?path=`: a streamed tar whose root member is
/// `basename(path)`, recursing into directories — the shape of Docker's
/// `GET /containers/{id}/archive`, which is what the app's download parses.
///
/// Symlinks are archived as links, not followed, at the root as well as
/// inside: Docker does the same, and following them would let a link in the
/// tree pull in files from anywhere in the container.
pub async fn archive(q: Result<Query<ArchiveQuery>, QueryRejection>) -> Response {
let Query(q) = match q {
Ok(q) => q,
Err(r) => return query_rejection(r),
};
if !q.path.starts_with('/') {
return error(StatusCode::BAD_REQUEST, "path must be absolute");
}
let path = PathBuf::from(&q.path);
let meta = match std::fs::symlink_metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return error(StatusCode::NOT_FOUND, format!("{} does not exist", q.path))
}
Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("{}: {e}", q.path)),
};
let root_name = path
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
// tar is a synchronous writer, so it runs on a blocking thread feeding a
// small channel. When the client disconnects, the body (and receiver) is
// dropped, the next send fails, and the walk stops — "dropping the
// connection cancels the read".
let (tx, mut rx) = mpsc::channel::<io::Result<Bytes>>(8);
tokio::task::spawn_blocking(move || {
let mut w = ChannelWriter { tx: tx.clone(), buf: Vec::with_capacity(CHUNK) };
let res = (|| {
let mut b = tar::Builder::new(&mut w);
b.follow_symlinks(false);
b.mode(tar::HeaderMode::Complete);
if meta.is_dir() {
b.append_dir_all(&root_name, &path)?;
} else {
b.append_path_with_name(&path, &root_name)?;
}
b.finish()
})();
let res = res.and_then(|()| w.flush());
if let Err(e) = res {
// Surface as a body error: the client sees a truncated, failed
// download rather than a short archive that looks complete.
let _ = tx.blocking_send(Err(e));
}
});
let stream = futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx));
Response::builder()
.header(header::CONTENT_TYPE, "application/x-tar")
.body(Body::from_stream(stream))
.unwrap_or_else(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "response build failed"))
}
const CHUNK: usize = 64 * 1024;
struct ChannelWriter {
tx: mpsc::Sender<io::Result<Bytes>>,
buf: Vec<u8>,
}
impl ChannelWriter {
fn send(&mut self) -> io::Result<()> {
if self.buf.is_empty() {
return Ok(());
}
let data = Bytes::from(std::mem::replace(&mut self.buf, Vec::with_capacity(CHUNK)));
self.tx
.blocking_send(Ok(data))
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client went away"))
}
}
impl Write for ChannelWriter {
fn write(&mut self, data: &[u8]) -> io::Result<usize> {
self.buf.extend_from_slice(data);
if self.buf.len() >= CHUNK {
self.send()?;
}
Ok(data.len())
}
fn flush(&mut self) -> io::Result<()> {
self.send()
}
}
+440
View File
@@ -0,0 +1,440 @@
//! `triple-c-agent`: everything `host::ContainerTarget` does through the Docker
//! API, done from inside the container over authenticated HTTP/WebSocket.
//!
//! The contract is `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`.
//! **Docker parity is the requirement**: the same `ExecSpec` must behave the
//! same here as through `docker exec`, because the app's call sites were
//! written against Docker and are not going to grow a second set of rules.
//! Each place this code reproduces a Docker behaviour says so where it does it.
//!
//! The library exists so the integration tests can run the real server
//! in-process on an ephemeral port; `main.rs` only reads the environment.
mod attach;
mod exec;
mod files;
mod pty;
mod spawn;
mod tunnel;
mod users;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::extract::{Request, State};
use axum::http::{header, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};
/// The wire protocol version reported by `/v1/health`.
pub const PROTOCOL_VERSION: u32 = 1;
/// Upload ceiling for `PUT /v1/files`: the app's `MAX_DROP_BYTES`
/// (`docker/exec.rs`). The app checks it before sending, so this only has to
/// stop a body that lies about itself.
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
/// The default one-shot budget when a request omits `limit` — the app's
/// `MAX_ONESHOT_OUTPUT`. Every real client call sends its own.
pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
/// The shortest token the agent will accept. There is no unauthenticated mode,
/// and a short token is close enough to none that it is refused the same way.
pub const MIN_TOKEN_BYTES: usize = 32;
/// Name of the env var carrying the token. `main` removes it from the
/// process's environment after reading it, so no exec inherits it — but that
/// only edits libc's copy: the kernel's record of the environment the process
/// was started with (`/proc/<pid>/environ`, readable by root and by the
/// agent's own uid) still holds it. Prefer [`TOKEN_FILE_ENV`], which is what
/// the entrypoint uses.
pub const TOKEN_ENV: &str = "TRIPLEC_AGENT_TOKEN";
pub const TOKEN_FILE_ENV: &str = "TRIPLEC_AGENT_TOKEN_FILE";
pub const ADDR_ENV: &str = "TRIPLEC_AGENT_ADDR";
pub const DEFAULT_ADDR: &str = "127.0.0.1:47900";
/// How long a client gets to send a complete request head. Until its token
/// has been checked a connection is anonymous, so this is what stops a
/// slowloris from holding sockets open forever.
pub const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// Concurrent HTTP connections. Far above what the app opens; a bound on
/// what an unauthenticated caller can make the agent hold.
pub const MAX_CONNECTIONS: usize = 256;
/// The largest WebSocket message (and frame) the agent accepts on attach and
/// tunnel sockets. The app sends stdin in frames of at most 64 KiB.
pub(crate) const MAX_WS_MESSAGE: usize = 1024 * 1024;
/// Everything the server needs, captured once at startup.
#[derive(Clone)]
pub struct Config {
/// The bearer token every request must carry.
pub token: String,
/// Where an exec with `working_dir: null` runs: the agent's own cwd at
/// startup, which is the container's `WorkingDir` — what `docker exec`
/// without `-w` uses.
pub default_cwd: PathBuf,
/// The env every exec starts from: the agent's own at startup, minus the
/// token. That is the container's configured env, which is what a
/// `docker exec` inherits.
pub base_env: Vec<(OsString, OsString)>,
/// `PUT /v1/files` ceiling. [`MAX_DROP_BYTES`] in production; a field only
/// so tests can exercise the refusal without a 256 MiB body.
pub max_file_bytes: u64,
/// [`HEADER_READ_TIMEOUT`] in production; a field so tests need not wait.
pub header_read_timeout: Duration,
/// [`MAX_CONNECTIONS`] in production.
pub max_connections: usize,
}
impl Config {
/// Capture the process's own cwd and env. Call after the token has been
/// removed from the environment; the token is filtered here as well, so a
/// caller that forgets cannot leak it into execs.
pub fn from_process(token: String) -> std::io::Result<Config> {
Ok(Config {
token,
default_cwd: std::env::current_dir()?,
// None of the agent's own settings (`TRIPLEC_AGENT_*`) reach an
// exec: the token is a secret, and the token-file path and listen
// address are the agent's business — `docker exec` sees neither.
base_env: std::env::vars_os()
.filter(|(k, _)| !k.to_string_lossy().starts_with("TRIPLEC_AGENT_"))
.collect(),
max_file_bytes: MAX_DROP_BYTES,
header_read_timeout: HEADER_READ_TIMEOUT,
max_connections: MAX_CONNECTIONS,
})
}
}
/// Read the token as the spec says: the env var first, then the file. Refuses
/// anything empty or shorter than [`MIN_TOKEN_BYTES`] — the agent has no
/// unauthenticated mode to fall back to.
///
/// Surrounding whitespace is trimmed: a token file written by `echo` or a
/// secrets manager usually ends in a newline, and a trailing `\n` the client
/// never sends would be a token nobody can present.
pub fn load_token(env_token: Option<OsString>, file: Option<PathBuf>) -> Result<String, String> {
let (raw, source) = match env_token.filter(|t| !t.is_empty()) {
Some(t) => (
t.into_string()
.map_err(|_| format!("{TOKEN_ENV} is not valid UTF-8"))?,
TOKEN_ENV.to_string(),
),
None => match file {
Some(path) => (
read_private_file(&path)
.map_err(|e| format!("cannot use {TOKEN_FILE_ENV} ({}): {e}", path.display()))?,
format!("{TOKEN_FILE_ENV} ({})", path.display()),
),
None => {
return Err(format!(
"no token: set {TOKEN_ENV} or {TOKEN_FILE_ENV}; refusing to start unauthenticated"
))
}
},
};
let token = raw.trim().to_string();
if token.len() < MIN_TOKEN_BYTES {
return Err(format!(
"token from {source} is {} bytes; at least {MIN_TOKEN_BYTES} are required",
token.len()
));
}
Ok(token)
}
/// Read a secret file, refusing one that anyone but its owner could read or
/// that this process does not own — as `ssh` does for key files. The token
/// is effectively root in the container; a world-readable copy of it would be
/// a way in for any process, not just root's. Checked on the opened file, so
/// the file judged is the file read.
fn read_private_file(path: &std::path::Path) -> Result<String, String> {
use std::io::Read;
use std::os::unix::fs::MetadataExt;
let mut f = std::fs::File::open(path).map_err(|e| e.to_string())?;
let meta = f.metadata().map_err(|e| e.to_string())?;
let euid = unsafe { libc::geteuid() };
if meta.uid() != euid {
return Err(format!("owned by uid {}, not this process's uid {euid}", meta.uid()));
}
if meta.mode() & 0o077 != 0 {
return Err(format!(
"mode {:o} lets others read it; it must be 0600 or stricter",
meta.mode() & 0o777
));
}
let mut s = String::new();
f.read_to_string(&mut s).map_err(|e| e.to_string())?;
Ok(s)
}
/// Shared server state.
pub(crate) struct AppState {
pub config: Config,
pub execs: exec::Registry,
pub activity: Activity,
}
/// What `/v1/activity` reports; the input to idle auto-stop.
pub(crate) struct Activity {
last_unix: AtomicI64,
pub execs: AtomicI64,
pub tunnels: AtomicI64,
}
impl Activity {
pub fn touch(&self) {
self.last_unix.store(now_unix(), Ordering::Relaxed);
}
}
/// Increments a counter for as long as it lives, so an exec or tunnel that
/// ends by any path — including a panic or a dropped task — stops counting.
pub(crate) struct CountGuard(Arc<AppState>, fn(&Activity) -> &AtomicI64);
impl CountGuard {
pub fn execs(st: &Arc<AppState>) -> CountGuard {
st.activity.execs.fetch_add(1, Ordering::Relaxed);
CountGuard(st.clone(), |a| &a.execs)
}
pub fn tunnels(st: &Arc<AppState>) -> CountGuard {
st.activity.tunnels.fetch_add(1, Ordering::Relaxed);
CountGuard(st.clone(), |a| &a.tunnels)
}
}
impl Drop for CountGuard {
fn drop(&mut self) {
(self.1)(&self.0.activity).fetch_sub(1, Ordering::Relaxed);
}
}
pub(crate) fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// The spec's error shape: `{"error": "<message>"}` with a JSON content type.
pub(crate) fn error(status: StatusCode, msg: impl Into<String>) -> Response {
(status, Json(serde_json::json!({ "error": msg.into() }))).into_response()
}
/// Every route, behind the token gate.
fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/v1/health", get(health))
.route("/v1/activity", get(activity))
.route("/v1/exec", post(exec::oneshot))
.route("/v1/exec/detached", post(exec::detached))
.route("/v1/exec/{id}", get(exec::status))
.route("/v1/exec/{id}/resize", post(exec::resize))
.route("/v1/attach", get(attach::upgrade))
.route(
"/v1/files",
// The handler streams the body and enforces its own ceiling, so
// axum's 2 MB default must not cut it off first.
put(files::put_file).layer(axum::extract::DefaultBodyLimit::disable()),
)
.route("/v1/archive", get(files::archive))
.route("/v1/tunnel", get(tunnel::upgrade))
.fallback(|| async { error(StatusCode::NOT_FOUND, "no such endpoint") })
// `layer`, not `route_layer`: the fallback must be behind the token
// too, or an unauthenticated caller could map the API by 404 vs 401.
.layer(middleware::from_fn_with_state(state.clone(), auth))
.with_state(state)
}
/// Serve the protocol on `listener`. Runs until the task is dropped: accept
/// errors (a full fd table, a connection reset before accept) are transient
/// and retried, as `axum::serve` does.
///
/// Our own accept loop rather than `axum::serve`, because that gives hyper no
/// timer, so hyper's header-read timeout never fires and an anonymous
/// connection that trickles (or never sends) its request head is held
/// forever. Here every connection gets [`Config::header_read_timeout`] to
/// deliver a complete head, and at most [`Config::max_connections`] are
/// served at once. HTTP/1.1 only — all the protocol needs — with upgrades.
///
/// At the cap the loop **waits** for a slot before accepting, rather than
/// accepting and dropping: excess clients queue in the kernel's backlog and
/// are served as soon as a slot frees, which the header timeout guarantees
/// for any slot an idle or slow client holds. Dropping would instead fail the
/// app's own requests outright whenever the cap is reached.
///
/// A connection's slot is released when hyper is done with it. A WebSocket
/// upgrade ends hyper's part (the socket then belongs to the session task),
/// so attach and tunnel sessions do not count against the cap — and they only
/// exist past the token check.
pub async fn serve(listener: tokio::net::TcpListener, config: Config) -> std::io::Result<()> {
use hyper_util::rt::{TokioIo, TokioTimer};
use hyper_util::service::TowerToHyperService;
let header_timeout = config.header_read_timeout;
let slots = Arc::new(tokio::sync::Semaphore::new(config.max_connections.max(1)));
let state = Arc::new(AppState {
config,
execs: exec::Registry::default(),
activity: Activity {
last_unix: AtomicI64::new(now_unix()),
execs: AtomicI64::new(0),
tunnels: AtomicI64::new(0),
},
});
let app = router(state);
loop {
let permit = slots.clone().acquire_owned().await.expect("the semaphore is never closed");
let stream = match listener.accept().await {
Ok((stream, _)) => stream,
Err(e) => {
// ECONNABORTED and friends are per-connection; EMFILE/ENFILE
// clear as connections close. Either way, back off and retry.
if !matches!(
e.kind(),
std::io::ErrorKind::ConnectionAborted | std::io::ErrorKind::ConnectionReset
) {
eprintln!("triple-c-agent: accept failed: {e}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
continue;
}
};
let service = TowerToHyperService::new(app.clone());
tokio::spawn(async move {
let _slot = permit;
let conn = hyper::server::conn::http1::Builder::new()
.timer(TokioTimer::new())
.header_read_timeout(header_timeout)
.serve_connection(TokioIo::new(stream), service)
.with_upgrades();
// An error here is the client's (reset, malformed request, header
// timeout); there is nobody to report it to.
let _ = conn.await;
});
}
}
/// Bearer-token gate on every request. A failure is `401` with an **empty**
/// body (spec): nothing about the API is disclosed to a caller without the
/// token.
async fn auth(State(st): State<Arc<AppState>>, req: Request, next: Next) -> Response {
let presented = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.as_bytes().strip_prefix(b"Bearer "));
let ok = presented.is_some_and(|p| constant_time_eq(p, st.config.token.as_bytes()));
if !ok {
return StatusCode::UNAUTHORIZED.into_response();
}
// `health` and `activity` are the idle monitor's own probes; counting them
// would mean an idle container could never look idle.
let path = req.uri().path();
if path != "/v1/health" && path != "/v1/activity" {
st.activity.touch();
}
next.run(req).await
}
/// Compare without an early exit, so response timing says nothing about how
/// many leading bytes of a guess were right. The length is not secret (the
/// minimum is public and tokens are generated at a fixed size), but it is
/// folded in rather than short-circuited anyway.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
let mut diff: usize = a.len() ^ b.len();
for i in 0..a.len().max(b.len()) {
let x = a.get(i).copied().unwrap_or(0);
let y = b.get(i).copied().unwrap_or(0);
diff |= (x ^ y) as usize;
}
diff == 0
}
async fn health() -> Response {
Json(serde_json::json!({
"agent": "triple-c-agent",
"version": env!("CARGO_PKG_VERSION"),
"protocol": PROTOCOL_VERSION,
}))
.into_response()
}
async fn activity(State(st): State<Arc<AppState>>) -> Response {
Json(serde_json::json!({
"last_activity_unix": st.activity.last_unix.load(Ordering::Relaxed),
"active_execs": st.activity.execs.load(Ordering::Relaxed),
"active_tunnels": st.activity.tunnels.load(Ordering::Relaxed),
}))
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_eq_matches_plain_equality() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"abcd"));
assert!(!constant_time_eq(b"", b"a"));
// A length difference of exactly 256 must not wrap to "equal length".
assert!(!constant_time_eq(&[0u8; 256], b""));
}
#[test]
fn a_token_file_others_can_read_is_refused() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "y".repeat(40)).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let e = load_token(None, Some(path.clone())).unwrap_err();
assert!(e.contains("0600"), "{e}");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(load_token(None, Some(path.clone())).unwrap(), "y".repeat(40));
}
#[test]
fn the_token_never_reaches_the_exec_base_env() {
// `main` removes the var before capturing; this is the second guard,
// for a caller that forgets.
std::env::set_var(TOKEN_ENV, "x".repeat(40));
std::env::set_var(TOKEN_FILE_ENV, "/run/triple-c/agent-token");
let cfg = Config::from_process("t".into()).unwrap();
std::env::remove_var(TOKEN_ENV);
std::env::remove_var(TOKEN_FILE_ENV);
assert!(cfg.base_env.iter().all(|(k, _)| !k.to_string_lossy().starts_with("TRIPLEC_AGENT_")));
assert!(cfg.base_env.iter().any(|(k, _)| k == "PATH"));
}
#[test]
fn token_rules() {
let long = "x".repeat(32);
assert_eq!(load_token(Some(long.clone().into()), None).unwrap(), long);
assert!(load_token(Some("short".into()), None).is_err());
assert!(load_token(None, None).is_err());
// An empty env var falls through to the file.
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("tok");
std::fs::write(&f, format!("{long}\n")).unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600)).unwrap();
}
assert_eq!(load_token(Some("".into()), Some(f.clone())).unwrap(), long);
std::fs::write(&f, "\n").unwrap();
assert!(load_token(None, Some(f)).is_err());
assert!(load_token(None, Some(dir.path().join("missing"))).is_err());
}
}
+68
View File
@@ -0,0 +1,68 @@
//! `triple-c-agent` entry point: read the environment, drop the token from
//! it, bind, serve. Everything else is in the library.
use std::path::PathBuf;
use std::process::ExitCode;
use triple_c_agent::{load_token, serve, Config, ADDR_ENV, DEFAULT_ADDR, TOKEN_ENV, TOKEN_FILE_ENV};
fn main() -> ExitCode {
let token = match load_token(
std::env::var_os(TOKEN_ENV),
std::env::var_os(TOKEN_FILE_ENV).map(PathBuf::from),
) {
Ok(t) => t,
Err(e) => {
eprintln!("triple-c-agent: {e}");
return ExitCode::from(2);
}
};
// Before the runtime starts any thread and before anything is spawned:
// removing an env var is only sound while the process is single-threaded,
// and every exec's base env is captured from this process's env below.
//
// This keeps the token out of every exec's env, and that is all it does:
// it edits libc's copy of the environment, not the block the kernel
// recorded at exec time, so `/proc/<agent pid>/environ` still shows the
// token to root and to anything running as the agent's uid. Use
// TRIPLEC_AGENT_TOKEN_FILE (what the entrypoint does) to avoid that.
std::env::remove_var(TOKEN_ENV);
// Loopback by default. A non-loopback address is only ever bound when
// someone wrote it into TRIPLEC_AGENT_ADDR on purpose.
let addr = std::env::var(ADDR_ENV).unwrap_or_else(|_| DEFAULT_ADDR.to_string());
let config = match Config::from_process(token) {
Ok(c) => c,
Err(e) => {
eprintln!("triple-c-agent: cannot read startup state: {e}");
return ExitCode::from(2);
}
};
let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => {
eprintln!("triple-c-agent: cannot start runtime: {e}");
return ExitCode::from(2);
}
};
rt.block_on(async move {
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("triple-c-agent: cannot bind {addr}: {e}");
return ExitCode::from(2);
}
};
let shown = listener.local_addr().map(|a| a.to_string()).unwrap_or(addr);
// One line, never the token.
eprintln!("triple-c-agent {} listening on {shown}", env!("CARGO_PKG_VERSION"));
match serve(listener, config).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("triple-c-agent: server failed: {e}");
ExitCode::FAILURE
}
}
})
}
+124
View File
@@ -0,0 +1,124 @@
//! Pseudo-terminals for `tty: true` execs.
use std::ffi::CStr;
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use tokio::io::unix::AsyncFd;
/// The agent's end of a pty. Shared (via `Arc`) by the output pump, the stdin
/// writer and the exec registry, which needs it for `resize`.
pub struct PtyMaster {
fd: AsyncFd<OwnedFd>,
}
/// Open a pty sized `cols`×`rows`, returning the master and the slave the
/// child gets as stdin/stdout/stderr.
///
/// Both ends are opened `O_CLOEXEC` *atomically* (`posix_openpt`, then `open`
/// on the slave path) rather than with `openpty` and a later `FD_CLOEXEC`:
/// execs are spawned concurrently from other threads, and a master fd leaked
/// into an unrelated child would keep this terminal open after its own process
/// exits — the attach would never see EOF.
pub fn open(cols: u16, rows: u16) -> io::Result<(PtyMaster, OwnedFd)> {
unsafe {
let m = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC);
if m < 0 {
return Err(io::Error::last_os_error());
}
let master = OwnedFd::from_raw_fd(m);
if libc::grantpt(m) != 0 || libc::unlockpt(m) != 0 {
return Err(io::Error::last_os_error());
}
let mut name = [0 as libc::c_char; 128];
let rc = libc::ptsname_r(m, name.as_mut_ptr(), name.len());
if rc != 0 {
return Err(io::Error::from_raw_os_error(rc));
}
let s = libc::open(
CStr::from_ptr(name.as_ptr()).as_ptr(),
libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC,
);
if s < 0 {
return Err(io::Error::last_os_error());
}
let slave = OwnedFd::from_raw_fd(s);
set_size(m, cols, rows)?;
let flags = libc::fcntl(m, libc::F_GETFL);
if flags < 0 || libc::fcntl(m, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
return Err(io::Error::last_os_error());
}
// Sound: `master` is an `OwnedFd`, open for as long as the AsyncFd
// owns it, and `as_raw_fd` always returns that same descriptor.
Ok((PtyMaster { fd: AsyncFd::register(master)? }, slave))
}
}
fn set_size(fd: libc::c_int, cols: u16, rows: u16) -> io::Result<()> {
let ws = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
if unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &ws) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
impl PtyMaster {
/// `TIOCSWINSZ` on the master; the kernel delivers `SIGWINCH` to the
/// terminal's foreground process group, as Docker's resize does.
pub fn resize(&self, cols: u16, rows: u16) -> io::Result<()> {
set_size(self.fd.get_ref().as_raw_fd(), cols, rows)
}
/// Read output. `Ok(0)` is end of stream: Linux reports "every slave fd is
/// closed" on the master as `EIO`, which is folded into EOF here.
pub async fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
loop {
let mut guard = self.fd.readable().await?;
let res = guard.try_io(|inner| {
let n = unsafe {
libc::read(inner.get_ref().as_raw_fd(), buf.as_mut_ptr().cast(), buf.len())
};
if n < 0 {
Err(io::Error::last_os_error())
} else {
Ok(n as usize)
}
});
match res {
Ok(Ok(n)) => return Ok(n),
Ok(Err(e)) if e.raw_os_error() == Some(libc::EIO) => return Ok(0),
Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue,
Ok(Err(e)) => return Err(e),
Err(_would_block) => continue,
}
}
}
pub async fn write_all(&self, mut data: &[u8]) -> io::Result<()> {
while !data.is_empty() {
let mut guard = self.fd.writable().await?;
let res = guard.try_io(|inner| {
let n = unsafe {
libc::write(inner.get_ref().as_raw_fd(), data.as_ptr().cast(), data.len())
};
if n < 0 {
Err(io::Error::last_os_error())
} else {
Ok(n as usize)
}
});
match res {
Ok(Ok(n)) => data = &data[n..],
Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue,
Ok(Err(e)) => return Err(e),
Err(_would_block) => continue,
}
}
Ok(())
}
}
+424
View File
@@ -0,0 +1,424 @@
//! Turning an `ExecSpec` into a running process the way `docker exec` would.
use std::ffi::{CString, OsStr, OsString};
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use axum::http::StatusCode;
use serde::Deserialize;
use crate::users::{self, UserInfo};
use crate::Config;
/// The spec's ExecSpec. `user` may be omitted, meaning the agent's own user —
/// as an exec with no `User` runs as the container's configured user (root).
#[derive(Debug, Clone, Deserialize)]
pub struct ExecSpec {
pub cmd: Vec<String>,
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub tty: bool,
}
/// Docker's default `PATH` when the container env has none
/// (`CreateDaemonEnvironment` → `oci.DefaultPathEnv`).
const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
/// An exec ready to start.
pub struct Prepared {
program: PathBuf,
argv: Vec<String>,
env: Vec<(OsString, OsString)>,
cwd: CString,
user: UserInfo,
/// Only when running as root. A non-root agent can only ever run as
/// itself (anything else is refused with 403 before we get here), and
/// calling `setgroups` unprivileged would fail the exec.
switch_ids: bool,
}
/// Why a spec could not become a process.
pub enum PrepareError {
/// An HTTP-level refusal (bad request, wrong user for a non-root agent).
Refused(StatusCode, String),
/// The command cannot start. Docker reports this as a *finished* exec with
/// 126/127 and the reason on stderr, not as an API error, so callers turn
/// it into exactly that.
CannotStart { code: i32, message: String },
}
impl PrepareError {
fn cannot_start(code: i32, message: String) -> Self {
PrepareError::CannotStart { code, message }
}
}
/// Validate a spec and resolve user, env, working directory and program.
pub fn prepare(cfg: &Config, spec: &ExecSpec, tty: bool) -> Result<Prepared, PrepareError> {
if spec.cmd.is_empty() || spec.cmd[0].is_empty() {
return Err(PrepareError::Refused(StatusCode::BAD_REQUEST, "cmd must be non-empty".into()));
}
if spec.cmd.iter().any(|a| a.contains('\0')) {
return Err(PrepareError::Refused(StatusCode::BAD_REQUEST, "cmd contains a NUL byte".into()));
}
let me = users::current();
let is_root = unsafe { libc::geteuid() } == 0;
let user = match spec.user.as_deref().filter(|u| !u.is_empty()) {
Some(name) => users::lookup(name).ok_or_else(|| {
// runc's wording, which is what `docker exec -u nobody-here` prints.
PrepareError::cannot_start(
126,
format!("unable to find user {name}: no matching entries in passwd file"),
)
})?,
None => me.clone().ok_or_else(|| {
PrepareError::Refused(
StatusCode::INTERNAL_SERVER_ERROR,
"the agent's own user has no passwd entry".into(),
)
})?,
};
if !is_root && user.uid != unsafe { libc::geteuid() } {
return Err(PrepareError::Refused(
StatusCode::FORBIDDEN,
format!("the agent is not root and cannot run as {}", user.name),
));
}
let env = merge_env(cfg, spec, &user, tty);
// `working_dir: null` is `docker exec` without `-w`: the container's
// WorkingDir, which is the agent's cwd at startup. Checked here so a bad
// directory is a clean 126 with runc's message rather than an errno that
// happens to collide with "no such binary".
let cwd = match &spec.working_dir {
Some(d) if !d.is_empty() => PathBuf::from(d),
_ => cfg.default_cwd.clone(),
};
if !cwd.is_dir() {
return Err(PrepareError::cannot_start(
126,
format!(
"chdir to cwd (\"{}\") set in config.json failed: no such file or directory",
cwd.display()
),
));
}
let program = resolve_program(&spec.cmd[0], &env, &cwd).ok_or_else(|| {
// runc's text for a missing binary; Docker reports it with 127 the
// way a shell does, and the app's callers check that code.
PrepareError::cannot_start(
127,
format!(
"OCI runtime exec failed: exec failed: unable to start container process: exec: \"{}\": executable file not found in $PATH: unknown",
spec.cmd[0]
),
)
})?;
Ok(Prepared {
program,
argv: spec.cmd.clone(),
env,
cwd: CString::new(cwd.as_os_str().as_bytes()).map_err(|_| {
PrepareError::Refused(StatusCode::BAD_REQUEST, "working_dir contains a NUL byte".into())
})?,
switch_ids: is_root,
user,
})
}
/// Build the exec's environment.
///
/// Order, matching Docker's `ReplaceOrAppendEnvValues(CreateDaemonEnvironment(…), spec.Env)`:
/// the base env; `PATH` defaulted if absent and `TERM=xterm` for a tty if
/// absent (both Docker daemon defaults); then the spec's entries, later ones
/// winning. An entry with no `=` *removes* that variable, as Docker does.
/// Finally `HOME` comes from passwd unless the **spec** set it — the agent's
/// own `HOME=/root` must not leak into a `claude` exec. Only `HOME`: runc sets
/// nothing else for an exec, so `USER`/`LOGNAME` stay unset unless the
/// container's env or the spec has them (verified against `docker exec` by
/// the app's parity test, `host::agent::live_tests`).
fn merge_env(cfg: &Config, spec: &ExecSpec, user: &UserInfo, tty: bool) -> Vec<(OsString, OsString)> {
let mut env: Vec<(OsString, OsString)> = cfg.base_env.clone();
fn set(env: &mut Vec<(OsString, OsString)>, k: &OsStr, v: &OsStr) {
match env.iter_mut().find(|(ek, _)| ek == k) {
Some(slot) => slot.1 = v.to_os_string(),
None => env.push((k.to_os_string(), v.to_os_string())),
}
}
if !env.iter().any(|(k, _)| k == "PATH") {
set(&mut env, OsStr::new("PATH"), OsStr::new(DEFAULT_PATH));
}
if tty && !env.iter().any(|(k, _)| k == "TERM") {
set(&mut env, OsStr::new("TERM"), OsStr::new("xterm"));
}
let mut spec_set: Vec<&str> = Vec::new();
for entry in &spec.env {
match entry.split_once('=') {
Some((k, v)) => {
set(&mut env, OsStr::new(k), OsStr::new(v));
spec_set.push(k);
}
None => {
env.retain(|(k, _)| k != entry.as_str());
spec_set.push(entry);
}
}
}
if !spec_set.contains(&"HOME") {
set(&mut env, OsStr::new("HOME"), OsStr::new(&user.home));
}
env.retain(|(k, v)| {
!k.is_empty() && !k.as_bytes().contains(&b'=') && !k.as_bytes().contains(&0) && !v.as_bytes().contains(&0)
});
env
}
/// `cmd[0]` through the **merged** env's `PATH` (spec), like runc's
/// `LookPath`. A name containing `/` is used as given (relative to the
/// working directory, as it will be after `chdir`).
fn resolve_program(cmd0: &str, env: &[(OsString, OsString)], cwd: &Path) -> Option<PathBuf> {
if cmd0.contains('/') {
return Some(PathBuf::from(cmd0));
}
let path = env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone())?;
for dir in std::env::split_paths(&path) {
let dir = if dir.as_os_str().is_empty() { cwd.to_path_buf() } else { cwd.join(dir) };
let candidate = dir.join(cmd0);
if let Ok(meta) = std::fs::metadata(&candidate) {
if meta.is_file() && meta.permissions().mode() & 0o111 != 0 {
return Some(candidate);
}
}
}
None
}
/// What the child's stdio should be.
pub enum Io {
Null,
Piped,
Fd(std::os::fd::OwnedFd),
}
impl Io {
fn stdio(self) -> Stdio {
match self {
Io::Null => Stdio::null(),
Io::Piped => Stdio::piped(),
Io::Fd(fd) => Stdio::from(fd),
}
}
}
impl Prepared {
/// Spawn. `controlling_tty` makes stdin (which must be a pty slave) the
/// session's controlling terminal.
///
/// Every exec runs in **its own session, hence its own process group**
/// (`setsid`), so the output-limit kill and the disconnect `SIGHUP` reach
/// everything it started, not just the first pid.
pub fn spawn(
&self,
stdin: Io,
stdout: Io,
stderr: Io,
controlling_tty: bool,
) -> Result<tokio::process::Child, PrepareError> {
let mut cmd = std::process::Command::new(&self.program);
cmd.arg0(&self.argv[0])
.args(&self.argv[1..])
.env_clear()
.envs(self.env.iter().map(|(k, v)| (k, v)))
.stdin(stdin.stdio())
.stdout(stdout.stdio())
.stderr(stderr.stdio());
let switch = self.switch_ids;
let (uid, gid) = (self.user.uid, self.user.gid);
let groups = self.user.groups.clone();
let cwd = self.cwd.clone();
let dev_null = dev_null_rdev();
// Runs in the forked child: only async-signal-safe syscalls, nothing
// that allocates (everything it touches was built above).
//
// Order matters. Signal state first: dispositions and mask back to
// the defaults. `docker exec` children start from runc's clean state,
// but ours would otherwise inherit whatever started the agent — the
// entrypoint's supervisor runs it under `trap '' HUP`, and an ignored
// disposition survives `exec`, so every tty session would ignore the
// hangup a client disconnect sends and outlive its client. (Handled
// signals already reset on exec; std clears the mask too, but that
// is std's detail, so we do not rely on it.) Then `setsid` (new
// session and process group). Then the controlling tty, which needs a
// session leader. Then stdio ownership, while still root. Then gid,
// supplementary groups (what `initgroups` sets — resolved in the
// parent, see `UserInfo::groups`), and uid last, since dropping uid
// first would forfeit the right to change the other two. `chdir` comes
// after the switch so a directory the user cannot enter fails the way
// it does under runc, rather than succeeding as root.
unsafe {
cmd.pre_exec(move || {
reset_signals()?;
if libc::setsid() < 0 {
return Err(io::Error::last_os_error());
}
if controlling_tty && libc::ioctl(0, libc::TIOCSCTTY, 0) < 0 {
return Err(io::Error::last_os_error());
}
if switch {
fix_stdio_ownership(uid, dev_null)?;
if libc::setgid(gid) < 0 {
return Err(io::Error::last_os_error());
}
if libc::setgroups(groups.len() as _, groups.as_ptr()) < 0 {
return Err(io::Error::last_os_error());
}
if libc::setuid(uid) < 0 {
return Err(io::Error::last_os_error());
}
}
if libc::chdir(cwd.as_ptr()) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
// Never `kill_on_drop`: a dropped `docker exec` stream does not kill
// the process, and neither does losing our handle to it.
tokio::process::Command::from(cmd).spawn().map_err(|e| {
// ENOENT here means a relative or explicit path that was not
// there (PATH lookups were checked already): 127 like a shell.
// Anything else — EACCES, ENOEXEC, a failed setuid — is 126,
// "found but could not run".
let code = if e.kind() == io::ErrorKind::NotFound { 127 } else { 126 };
PrepareError::cannot_start(
code,
format!(
"OCI runtime exec failed: exec failed: unable to start container process: exec: \"{}\": {e}: unknown",
self.argv[0]
),
)
})
}
}
/// Linux's `_NSIG`: signals are 1..=64. libc does not export it.
const NSIG: libc::c_int = 65;
/// Every catchable signal to `SIG_DFL` and an empty signal mask — the state a
/// `docker exec` child starts in. For the forked child only.
///
/// Raw `rt_sigaction`/`rt_sigprocmask` syscalls, not the libc wrappers: libc
/// refuses to touch the real-time signals it reserves for itself (glibc's 32
/// and 33), and those can arrive ignored too — seen in practice, SigIgn bit
/// 32 set under a test harness. The syscalls also take no libc locks, which
/// matters between fork and exec. An all-zero kernel `struct sigaction` is
/// `SIG_DFL` with no flags, no restorer and an empty mask on every Linux
/// architecture, whatever its field order; its size is 4 words.
///
/// SIGKILL and SIGSTOP cannot be changed and are skipped. Any failure fails
/// the exec rather than running a child in an unknown signal state.
unsafe fn reset_signals() -> io::Result<()> {
const SIGSET_BYTES: libc::size_t = 8; // the kernel's sigset_t: 64 bits
let dfl = [0u64; 4];
for sig in 1..NSIG {
if sig == libc::SIGKILL || sig == libc::SIGSTOP {
continue;
}
let r = libc::syscall(
libc::SYS_rt_sigaction,
sig as libc::c_long,
dfl.as_ptr(),
std::ptr::null_mut::<u64>(),
SIGSET_BYTES,
);
if r < 0 {
return Err(io::Error::last_os_error());
}
}
let empty: u64 = 0;
let r = libc::syscall(
libc::SYS_rt_sigprocmask,
libc::SIG_SETMASK as libc::c_long,
&empty as *const u64,
std::ptr::null_mut::<u64>(),
SIGSET_BYTES,
);
if r < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
/// The device number of `/dev/null`, read in the parent (`stat` is not
/// something to do between fork and exec when it can be done before).
/// Falls back to Linux's fixed 1:3.
fn dev_null_rdev() -> libc::dev_t {
use std::os::unix::fs::MetadataExt;
std::fs::metadata("/dev/null")
.map(|m| m.rdev() as libc::dev_t)
.unwrap_or_else(|_| libc::makedev(1, 3))
}
/// runc's `fixStdioPermissions`: give fds 0–2 to the exec's user, so it can
/// reopen them — `echo x > /dev/stderr`, `tee /dev/stdout`, and on a tty the
/// slave itself (gpg's pinentry, `screen`, `script`). Without it they stay
/// `root`'s: a pipe is `prw------- root` and a pty slave `root tty`.
///
/// Like runc: only the uid changes (the gid is left to the mount), `/dev/null`
/// is skipped (chowning it would hand the device to the user), so is an fd
/// the user already owns, and `EINVAL`/`EPERM`/`EROFS` are ignored — an owner
/// outside our user namespace or a read-only mount is not worth failing the
/// exec over. For the forked child only: `fstat` and `fchown` are
/// async-signal-safe.
unsafe fn fix_stdio_ownership(uid: libc::uid_t, dev_null: libc::dev_t) -> io::Result<()> {
for fd in 0..=2 {
let mut st: libc::stat = std::mem::zeroed();
if libc::fstat(fd, &mut st) < 0 {
return Err(io::Error::last_os_error());
}
let is_dev_null = st.st_mode & libc::S_IFMT == libc::S_IFCHR && st.st_rdev == dev_null;
if st.st_uid == uid || is_dev_null {
continue;
}
if libc::fchown(fd, uid, libc::gid_t::MAX) < 0 {
let e = io::Error::last_os_error();
if !matches!(e.raw_os_error(), Some(libc::EINVAL | libc::EPERM | libc::EROFS)) {
return Err(e);
}
}
}
Ok(())
}
/// Docker's exit code for a process: its status, or 128 + signal when a
/// signal killed it (what `docker inspect` and a shell both report).
pub fn exit_code(status: ExitStatus) -> i32 {
match (status.code(), status.signal()) {
(Some(c), _) => c,
(None, Some(s)) => 128 + s,
(None, None) => 255,
}
}
/// Signal a whole exec: its pid is its process-group id (it called `setsid`).
pub fn signal_group(pid: Option<u32>, sig: libc::c_int) {
if let Some(pid) = pid.filter(|&p| p > 0) {
unsafe {
libc::kill(-(pid as libc::pid_t), sig);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
//! `GET /v1/tunnel?port=`: a WebSocket piped to `127.0.0.1:<port>` inside the
//! container — what the auth bridge and browser view do with `socat` through
//! the Docker API today.
use std::sync::Arc;
use std::time::Duration;
use axum::extract::rejection::QueryRejection;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Response;
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::{error, AppState, CountGuard, MAX_WS_MESSAGE};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Deserialize)]
pub struct TunnelQuery {
port: u16,
}
pub async fn upgrade(
State(st): State<Arc<AppState>>,
q: Result<Query<TunnelQuery>, QueryRejection>,
ws: WebSocketUpgrade,
) -> Response {
let port = match q {
Ok(Query(q)) if q.port != 0 => q.port,
Ok(_) => return error(StatusCode::BAD_REQUEST, "port must be 1-65535"),
Err(r) => return error(StatusCode::BAD_REQUEST, format!("invalid query: {}", r.body_text())),
};
ws.max_message_size(MAX_WS_MESSAGE)
.max_frame_size(MAX_WS_MESSAGE)
.on_upgrade(move |socket| session(socket, st, port))
}
async fn session(mut socket: WebSocket, st: Arc<AppState>, port: u16) {
// Loopback only, by construction: the tunnel reaches in-container
// listeners, never the network the container can see.
let tcp = match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(("127.0.0.1", port))).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => return fail(socket, format!("cannot connect to 127.0.0.1:{port}: {e}")).await,
Err(_) => return fail(socket, format!("timed out connecting to 127.0.0.1:{port}")).await,
};
let _ = tcp.set_nodelay(true);
let _active = CountGuard::tunnels(&st);
let (mut rd, mut wr) = tcp.into_split();
let mut buf = vec![0u8; 32 * 1024];
// When either side ends, the other is closed (spec): TCP EOF sends a
// WebSocket close; a WebSocket close drops the TCP connection.
loop {
tokio::select! {
r = rd.read(&mut buf) => match r {
Ok(n) if n > 0 => {
st.activity.touch();
if socket.send(Message::Binary(buf[..n].to_vec().into())).await.is_err() {
return;
}
}
_ => {
let _ = socket.send(Message::Close(None)).await;
return;
}
},
m = socket.recv() => match m {
Some(Ok(Message::Binary(d))) => {
st.activity.touch();
if wr.write_all(&d).await.is_err() {
let _ = socket.send(Message::Close(None)).await;
return;
}
}
Some(Ok(Message::Text(_) | Message::Ping(_) | Message::Pong(_))) => {}
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => return,
},
}
}
}
async fn fail(mut socket: WebSocket, msg: String) {
let _ = socket
.send(Message::Text(serde_json::json!({ "type": "error", "error": msg }).to_string().into()))
.await;
let _ = socket.send(Message::Close(None)).await;
}
+131
View File
@@ -0,0 +1,131 @@
//! User resolution through `/etc/passwd` and `/etc/group`.
//!
//! The static musl build has no NSS, so `getpwnam_r` and `getgrouplist` read
//! the container's files directly — which is exactly the database `docker exec
//! --user` resolves against (runc parses `/etc/passwd` itself), so the two
//! cannot disagree about who a name is.
use std::ffi::{CStr, CString};
#[derive(Debug, Clone)]
pub struct UserInfo {
pub name: String,
pub uid: libc::uid_t,
pub gid: libc::gid_t,
pub home: String,
/// Supplementary groups, primary included — what `initgroups` would set.
/// Resolved here, in the parent, because `pre_exec` runs between `fork`
/// and `exec` in a multi-threaded process where reading files and
/// allocating are not safe; the child only calls `setgroups` on this list.
pub groups: Vec<libc::gid_t>,
}
/// Resolve a user name (or, as Docker also allows, a numeric uid).
///
/// A numeric uid with no passwd entry still resolves, with gid 0 and HOME `/`,
/// because that is what runc does for `--user 1234` in an image without that
/// user.
pub fn lookup(name: &str) -> Option<UserInfo> {
let cname = CString::new(name).ok()?;
if let Some(u) = getpw(|pwd, buf, len, res| unsafe {
libc::getpwnam_r(cname.as_ptr(), pwd, buf, len, res)
}) {
return Some(u);
}
let uid: libc::uid_t = name.parse().ok()?;
if let Some(u) = getpw(|pwd, buf, len, res| unsafe { libc::getpwuid_r(uid, pwd, buf, len, res) }) {
return Some(u);
}
Some(UserInfo {
name: name.to_string(),
uid,
gid: 0,
home: "/".to_string(),
groups: vec![0],
})
}
/// The user the agent itself runs as.
pub fn current() -> Option<UserInfo> {
let uid = unsafe { libc::geteuid() };
getpw(|pwd, buf, len, res| unsafe { libc::getpwuid_r(uid, pwd, buf, len, res) })
}
fn getpw(
call: impl Fn(*mut libc::passwd, *mut libc::c_char, libc::size_t, *mut *mut libc::passwd) -> libc::c_int,
) -> Option<UserInfo> {
let mut size = 1024usize;
loop {
let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
let mut buf = vec![0 as libc::c_char; size];
let mut res: *mut libc::passwd = std::ptr::null_mut();
let rc = call(&mut pwd, buf.as_mut_ptr(), buf.len(), &mut res);
if rc == libc::ERANGE && size < (1 << 20) {
size *= 4;
continue;
}
if rc != 0 || res.is_null() {
return None;
}
let name = unsafe { CStr::from_ptr(pwd.pw_name) }.to_string_lossy().into_owned();
let home = unsafe { CStr::from_ptr(pwd.pw_dir) }.to_string_lossy().into_owned();
let groups = group_list(&name, pwd.pw_gid);
return Some(UserInfo {
name,
uid: pwd.pw_uid,
gid: pwd.pw_gid,
home,
groups,
});
}
}
fn group_list(name: &str, gid: libc::gid_t) -> Vec<libc::gid_t> {
let Ok(cname) = CString::new(name) else {
return vec![gid];
};
let mut n: libc::c_int = 32;
loop {
let mut groups = vec![0 as libc::gid_t; n as usize];
let mut count = n;
let rc = unsafe { libc::getgrouplist(cname.as_ptr(), gid, groups.as_mut_ptr(), &mut count) };
if rc >= 0 {
groups.truncate(count.max(0) as usize);
return groups;
}
// -1 means "too small" and `count` now holds the size needed (glibc
// and musl both). Guard against a libc that does not update it.
if count <= n {
if n >= 65536 {
return vec![gid];
}
n *= 2;
} else {
n = count;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_the_current_user_by_name_and_uid() {
let me = current().expect("current user has a passwd entry");
let by_name = lookup(&me.name).unwrap();
assert_eq!(by_name.uid, me.uid);
assert!(by_name.groups.contains(&me.gid));
let by_uid = lookup(&me.uid.to_string()).unwrap();
assert_eq!(by_uid.name, me.name);
}
#[test]
fn unknown_names_do_not_resolve() {
assert!(lookup("no-such-user-triple-c").is_none());
assert!(lookup("bad\0name").is_none());
// A bare uid nobody owns still resolves, as runc does.
let u = lookup("4000123").unwrap();
assert_eq!((u.uid, u.gid, u.home.as_str()), (4000123, 0, "/"));
}
}
@@ -0,0 +1,167 @@
//! Execs must start with default signal dispositions and an empty mask, no
//! matter what the agent itself inherited — the clean state a `docker exec`
//! child gets from runc.
//!
//! In production the entrypoint's supervisor runs the agent under
//! `trap '' HUP`, and an ignored disposition survives `exec`. These tests
//! reproduce that by ignoring SIGHUP (and SIGUSR1) in *this* test process,
//! which is where the in-process server spawns from. A disposition is
//! process-wide, which is why this is its own test binary rather than part of
//! `protocol.rs`.
use std::net::SocketAddr;
use std::sync::Once;
use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message;
use triple_c_agent::{serve, Config};
const TOKEN: &str = "test-token-0123456789abcdef0123456789abcdef";
fn ignore_signals_like_the_supervisor() {
static ONCE: Once = Once::new();
ONCE.call_once(|| unsafe {
libc::signal(libc::SIGHUP, libc::SIG_IGN);
libc::signal(libc::SIGUSR1, libc::SIG_IGN);
});
}
/// A `/proc/<pid>/status` mask field (`SigIgn`, `SigBlk`, …) as a bitmask.
fn mask(status: &str, field: &str) -> u64 {
let line = status
.lines()
.find(|l| l.starts_with(&format!("{field}:")))
.unwrap_or_else(|| panic!("no {field} in status"));
u64::from_str_radix(line.split_whitespace().nth(1).unwrap(), 16).unwrap()
}
fn bit(sig: libc::c_int) -> u64 {
1 << (sig - 1)
}
struct Agent {
addr: SocketAddr,
http: reqwest::Client,
_cwd: tempfile::TempDir,
user: String,
}
async fn start() -> Agent {
ignore_signals_like_the_supervisor();
// The precondition, checked rather than assumed.
let own = std::fs::read_to_string("/proc/self/status").unwrap();
assert_ne!(mask(&own, "SigIgn") & bit(libc::SIGHUP), 0, "the test process must ignore SIGHUP");
let cwd = tempfile::tempdir().unwrap();
let config = Config {
token: TOKEN.to_string(),
default_cwd: cwd.path().to_path_buf(),
base_env: vec![("PATH".into(), "/usr/local/bin:/usr/bin:/bin".into())],
max_file_bytes: 1024,
header_read_timeout: triple_c_agent::HEADER_READ_TIMEOUT,
max_connections: triple_c_agent::MAX_CONNECTIONS,
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(serve(listener, config));
let user = String::from_utf8(std::process::Command::new("id").arg("-un").output().unwrap().stdout)
.unwrap()
.trim()
.to_string();
Agent { addr, http: reqwest::Client::new(), _cwd: cwd, user }
}
impl Agent {
async fn get(&self, path: &str) -> Value {
let r = self.http.get(format!("http://{}{path}", self.addr)).bearer_auth(TOKEN).send().await.unwrap();
assert_eq!(r.status().as_u16(), 200);
r.json().await.unwrap()
}
}
fn process_gone(pid: i32) -> bool {
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Err(_) => true,
Ok(s) => s.rsplit(')').next().unwrap_or("").trim_start().starts_with('Z'),
}
}
#[tokio::test]
async fn execs_start_with_no_ignored_or_blocked_signals() {
let a = start().await;
let r = a
.http
.post(format!("http://{}/v1/exec", a.addr))
.bearer_auth(TOKEN)
.json(&json!({ "cmd": ["cat", "/proc/self/status"], "user": a.user }))
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200);
let v: Value = r.json().await.unwrap();
assert_eq!(v["exit_code"], 0, "{v}");
use base64::Engine;
let status: String = v["chunks"]
.as_array()
.unwrap()
.iter()
.filter(|c| c["s"] == "out")
.map(|c| {
let b = base64::engine::general_purpose::STANDARD.decode(c["d"].as_str().unwrap()).unwrap();
String::from_utf8(b).unwrap()
})
.collect();
assert_eq!(mask(&status, "SigIgn"), 0, "inherited ignored signals:\n{status}");
assert_eq!(mask(&status, "SigBlk"), 0, "inherited blocked signals:\n{status}");
}
#[tokio::test]
async fn a_dropped_tty_attach_still_hangs_up_when_the_agent_ignores_sighup() {
let a = start().await;
let mut req = format!("ws://{}/v1/attach", a.addr).into_client_request().unwrap();
req.headers_mut().insert("Authorization", format!("Bearer {TOKEN}").parse().unwrap());
let mut ws = tokio_tungstenite::connect_async(req).await.unwrap().0;
let spec = json!({
"cmd": ["sh", "-c", "echo pid=$$; exec sleep 600"],
"user": a.user, "tty": true, "attach_stdin": true,
});
ws.send(Message::Text(spec.to_string().into())).await.unwrap();
let mut out = String::new();
let pid: i32 = loop {
match tokio::time::timeout(Duration::from_secs(10), ws.next()).await.expect("timed out") {
Some(Ok(Message::Text(_))) => {} // "started"
Some(Ok(Message::Binary(b))) => {
out.push_str(&String::from_utf8_lossy(&b[1..]));
if let Some(rest) = out.split("pid=").nth(1) {
if let Some(line) = rest.split_once(['\r', '\n']) {
break line.0.trim().parse().unwrap();
}
}
}
other => panic!("unexpected {other:?}"),
}
};
assert_eq!(a.get("/v1/activity").await["active_execs"], 1);
// Let `exec sleep` happen, so the process we watch is the sleep.
tokio::time::sleep(Duration::from_millis(200)).await;
drop(ws);
let deadline = Instant::now() + Duration::from_secs(5);
while !process_gone(pid) {
assert!(Instant::now() < deadline, "sleep (pid {pid}) survived the client disconnect");
tokio::time::sleep(Duration::from_millis(50)).await;
}
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let v = a.get("/v1/activity").await;
if v["active_execs"] == 0 {
break;
}
assert!(Instant::now() < deadline, "active_execs never returned to 0: {v}");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more