Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 d73a19a9e7 docs: note the stop guard order, background polls, clone skip and trap order
Secret Scan / scan (push) Successful in 18s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-windows (pull_request) In progress
Build App (Preview) / build-macos (pull_request) Successful in 5m55s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) In progress
Build App (Preview) / test (pull_request) Successful in 6m21s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / build-linux (pull_request) In progress
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:08:05 -08:00
shadowdaoandClaude Opus 5.5 c467308513 fix(aws): keep the access point id when a removal keeps the saved files
remove_aws_resources cleared access_point_id even without discard_data, though the
access point stays; the project could then move hosts and orphan it. The id is
now kept, so a later start reuses it, a later removal with "Also remove the
access point" deletes it, and moving the project is refused until then (the
message and the Host section say so).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:08:05 -08:00
shadowdaoandClaude Opus 5.5 be2825d344 fix(aws): the file viewer's change poll no longer counts as workspace activity
Its 2 s sha256sum poll now carries X-Triple-C-Background, so an open viewer does
not keep an idle AWS workspace up. Opening and saving a file stay foreground;
Docker is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:08:05 -08:00
shadowdaoandClaude Opus 5.5 33eb63d907 fix(aws): forget a stale stopped mark on a project's unattached target id
The death watch's stopped marks are keyed by target id, and an unattached AWS
project's id (http://unattached.invalid/<id>/) is reused by every later unattached
spell, but only an endpoint URL's mark was ever removed. A stale mark could make
the watch report a live, billed task as stopped. Registering a tunnel now clears
the project's placeholder mark, and reconcile forgets it before asking ECS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:08:05 -08:00
shadowdaoandClaude Opus 5.5 884a665e7d fix(aws): close terminals before the stop guard's push and bound it in the container
The guard pushed and recorded `pushed` while terminals (and Claude in them) were
still open; the TERM hook then skipped its push, so later edits were lost. Stop now
closes the project's exec sessions and auth bridge first, then pushes, records the
marker and stops the task. A failed push still aborts the stop with the status
restored; the auth bridge is re-armed and the error says the terminals were closed.

The script also runs under `/usr/bin/timeout -k 5` ending 10 s before the client's
timeout: the agent does not kill an exec its client gave up on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:07:54 -08:00
shadowdaoandClaude Opus 5.5 170ba1e94d fix(container): skip repos still cloning, re-check idleness after the idle push, trap TERM before background work
- clone_repos records `<folder>\tcloning` as each clone begins; triple-c-wip-push
  skips a folder whose last state is `cloning` while the clone is not done (dry run
  reports "cloning": true; RepoState/TS carry it, the stop guard lists it as
  "still cloning — nothing to push" and never asks because of it).
- The idle watcher re-checks quiet() after a successful push and goes back to
  watching if someone came back during it.
- The entrypoint installs its stop trap before the idle watcher and the background
  clone start, so a TERM in between still runs aws_before_stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 13:07:53 -08:00
shadowdaoandClaude Opus 5.5 276f5378a4 fix(aws): give the permission check room for throttled retries and say it can be slow
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 5m56s
Build App (Preview) / build-windows (pull_request) Successful in 10m38s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 25m25s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / build-linux (pull_request) Successful in 8m42s
Build App (Preview) / test (pull_request) Successful in 22m44s
The connection-wide 30 s operation limit counts retry backoff, so a
throttled SimulatePrincipalPolicy got about six of its ten adaptive
attempts. The IAM client used by the check now has a 120 s operation
limit, and Settings tells the user a check can take a few minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:54:34 -08:00
shadowdaoandClaude Opus 5.5 3c1c276769 docs(aws): note the idle-stop changes are not yet re-verified live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:37:58 -08:00
shadowdaoandClaude Opus 5.5 8a3c552958 test(aws): the live idle stop holds a terminal open and polls in the background
The idle wait now keeps an attach open (silent) and runs the auth
bridge's background exec every 2 s; the task must still stop itself and
ECS must report it as an idle stop (exit 75 -> StopKind::Idle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:36:23 -08:00
shadowdaoandClaude Opus 5.5 ce020f79a1 fix(aws): the e2e script uses only the named profile and always cleans up
It unsets every caller AWS_* variable (keeping only where the profile's
files are, now documented), sets AWS_PROFILE explicitly, attempts the ECR
delete in the EXIT trap unconditionally (RepositoryNotFound tolerated,
bounded at 120 s), and exits 130/143 on INT/TERM (M21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:35:50 -08:00
shadowdaoandClaude Opus 5.5 24a82bd4c8 fix(aws): warn that Bedrock profile auth can't work in an AWS workspace
The task has no ~/.aws from this machine and SSO can't refresh there;
the note points at static keys or a bearer token (m10). The auth bridge
stays enabled on AWS (its poll is now a background request).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:34:34 -08:00
shadowdaoandClaude Opus 5.5 7f113f58ac fix(aws): refuse credentials in repo URLs and HEAD as a branch
- validate_repo_url rejects https URLs with userinfo (user:pass@ or
  token@), pointing at the project's Git HTTPS token setting (m4).
- validate_branch rejects HEAD (m5).
- The FIXED_ENVIRONMENT doc comment sits on FIXED_ENVIRONMENT again (m6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:34:03 -08:00
shadowdaoandClaude Opus 5.5 2772647690 fix(aws): reconcile AWS projects at startup without waiting for Docker
- New reconcile_aws_projects command (handler, capability, TS wrapper)
  that App runs on mount regardless of Docker; reconcile_project_statuses
  now skips AWS projects. Projects reconcile concurrently, 4 at a time,
  each still bounded at 15 s.
- reconcile_task falls back to find_project_task when the stored task is
  gone or stopped, before calling the project Gone (m3).
- An AWS project with no registered tunnel gets a placeholder target id
  (http://unattached.invalid/<id>/) instead of the last run's loopback
  URL, so a reused port can't match the wrong project; the death watch
  still reports it by that id until a re-attach registers (m9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:33:22 -08:00
shadowdaoandClaude Opus 5.5 5e8e67a0a4 fix(aws): say why a task stopped, and bound every AWS call
- classify_stop is wired into the death watch: stopped_among returns
  each task's StopKind, the tunnel registry keeps it, and ContainerExited
  carries a StopNote. Spot -> 'Spot interruption'; TaskFailedToStart ->
  Error with the reason; the entrypoint's idle exit (75, read from the
  workspace container's exit code) -> 'stopped after being idle'; a user
  stop is applied silently (P8). is_quota_error, dead, moved to tests.
- The SDK config has a TimeoutConfig (30 s operation / 10 s attempt); the
  watch's per-group poll and StopTask also have tokio bounds.
- Watcher tests cover silent sessions, unsaved work with the WIP push on
  and off, a failing push and the idle exit status; docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:31:28 -08:00
shadowdaoandClaude Opus 5.5 151b7690df fix(aws): the auth bridge's poll no longer keeps a workspace up
The auth bridge reads /proc/net every 2 s; on the agent that now goes
through ContainerTarget::exec_oneshot_background, which sends
X-Triple-C-Background: 1 so the agent does not count it as activity.
Docker behaviour is unchanged. The idle-stop hint says what idle means
now (R11) and what happens to unsaved work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:31:28 -08:00
shadowdaoandClaude Opus 5.5 95b68ed068 fix(container): idle stop ignores silent sessions and never loses work
- The agent marks requests with X-Triple-C-Background: 1 (and the
  attach/tunnel sessions they open) as not activity (R11).
- triple-c-idle-watch no longer requires zero open execs/tunnels: idle is
  last_activity older than N minutes, no claude, no scheduler job, no
  running clone, and no unsaved git work. With the WIP push on, it pushes
  as claude before signalling and stays up (backing off) when the push
  fails; with it off, dirty repos keep the workspace up.
- An idle stop exits 75 so ECS reports it distinctly from a crash; the
  TERM trap no longer pushes again after the watcher's push.
- start_clone_repos writes done from an EXIT trap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:31:19 -08:00
shadowdaoandClaude Opus 5.5 3f986368aa fix(aws): removal deletes the secret at once and words the access point honestly
Removing a project's AWS resources now deletes its secret with
ForceDeleteWithoutRecovery (R10) so the next start can create it again,
and the EFS option is renamed: it removes the access point, while the
files stay on the user's own file system until offboarding (R9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 12:10:52 -08:00
shadowdaoandClaude Sonnet 5.5 55ec7e33fe docs(aws): fix policy sizes and a constant name
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 11:59:31 -08:00
shadowdaoandClaude Sonnet 5.5 595897a5fd docs(aws): phase 5 Fargate workspaces
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 11:58:42 -08:00
shadowdaoandClaude Opus 5.5 084fa6efe4 test(aws): guard the live workspace test against hangs and leaks
Review round 1 of Task 9:
- the phase-5 body runs under a 45-minute timeout and each exec under
  180 s, so a hang becomes a panic the guard catches; the body future is
  boxed (moved into the timeout it overflowed the test thread's stack,
  which aborts without any guard);
- scripts/aws-e2e-workspace.sh builds and pushes the image to a throwaway
  ECR repository, runs the test and deletes the repository in an exit
  trap; the test's guard deletes it too (TRIPLEC_E2E_ECR_REPO);
- the guard survives a poisoned lock, closes the tunnel and restores
  AWS_CONFIG_FILE;
- new live probe: a bare elasticfilesystem:TagResource on the user's own
  file system is denied; README records it and the final run (R8 removal
  end state), and the admin command uses xargs -r.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 11:56:40 -08:00
shadowdaoandClaude Opus 5.5 1700cc7985 fix(aws): leave task definitions to admins on removal
Ruling R8. ECS authorizes ecs:DeregisterTaskDefinition against * only,
with no tag condition (verified live), so the workspace tier's
RetireOwnTaskDefinitions could never match, and allowing it on * would
let any user deregister any revision in the account. DeleteTaskDefinitions
is resource-level but deletes only INACTIVE revisions, so it goes too.

Removing an AWS project now stops the task, schedules the secret and
(with discard) deletes the access point, and leaves the task definition
revisions registered, saying so in the log: they hold no secret values,
cost nothing, and RunOwnTasks still needs the owner tag. aws/README.md
tells admins how to deregister them. The policy statement, its checks and
owner-scoped rows, the RuntimeApi methods, the fakes, the removal UI copy
and the live test's final assertion follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 11:34:52 -08:00
shadowdaoandClaude Opus 5.5 e901a08ab6 test(aws): live Fargate workspace lifecycle
aws::live_tests::environment::a_fargate_workspace_round_trips (ignored):
a public environment deployed by the environment admin, a role holding
only the workspace-user files onboarded to it and driving ProjectHost::Aws
through a profile connection: start (Hybrid, 1 vCPU / 2 GB, one public
repo), exec, background clone, ~/.claude persistence across stop/start,
attach without a second RunTask, the stop guard's dry run, the SIGTERM
WIP path, idle stop at 5 minutes, bubblewrap, denials on another owner's
revision and secret, and project removal. TRIPLEC_E2E_IMAGE names the
image; a private ECR image gets a test-only pull policy on the exec role.
A guard stops tasks, retires revisions, deletes access points and the
secret before the fixture destroys the modules.

aws/README.md gains the phase 5 results; the spec's To verify items are
answered. Open: DeregisterTaskDefinition is authorized against * only,
so the workspace tier cannot retire revisions (the test's last assertion
fails until that is decided).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 11:30:33 -08:00
shadowdaoandClaude Opus 5.5 1eed994da7 fix(aws): tag access points at create against their file system
EFS authorizes the elasticfilesystem:TagResource that a tagged
CreateAccessPoint needs against the parent file system's ARN, not the new
access point's: a workspace user's first Hybrid start was denied
("not authorized to perform: elasticfilesystem:TagResource on the
specified resource"). Verified live with a probe role: access-point/*
is denied; file-system/* is allowed, as is * with each of
elasticfilesystem:CreateAction, aws:RequestTag and aws:TagKeys.

TagOwnAccessPointsAtCreate now names file-system/* with the same
conditions (CreateAction = CreateAccessPoint, owner request tag, the
three tag keys); its check and owner-scoped test follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 10:46:59 -08:00
shadowdaoandClaude Opus 5.5 074cb28848 docs(aws): correct the 0.25 vCPU Fargate memory sizes in the phase 5 plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 10:31:06 -08:00
shadowdaoandClaude Opus 5.5 08b6d341f2 fix(aws): stop guard review round 2
A failed StopTask removes the stop marker it recorded, so a later stop
pushes as configured. The unchecked guard's Stop anyway sends no choice
and records nothing, so the workspace's own shutdown push still runs as
the dialog says. The idle watcher keeps a pushed/discard marker instead
of overwriting it; the remove-resources dialog sends discard only when
there may be saved files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 10:30:56 -08:00
shadowdaoandClaude Opus 5.5 8441a096c8 fix(aws): stop guard review round 1
The stop guard's choice now reaches the workspace: after a successful
push (or for Stop without pushing) the app records pushed/discard in
/run/triple-c/stop-reason as root, and the entrypoint's shutdown hook
then skips its own push; a marker that can't be written is reported
and the stop goes on. The push runs before the auth bridge is stopped;
a guarded stop of a workspace that already stopped succeeds. The
Fargate table allows only 512/1024/2048 MiB with 256 CPU, pinned on
both sides by a shared fixture. Removal's missing-connection refusal
carries a stable prefix. OpenAI Compatible is allowed on AWS, with a
warning for local addresses and the app's own gateway. HostSection
locks resource actions unless Stopped, the add dialog drops the
environment on a region change, and the repository editor keeps rows
in progress across a refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 10:26:53 -08:00
shadowdaoandClaude Opus 5.5 eb7c6c7b21 feat(aws): AWS project settings and the stop guard
Add Project picks Local or an AWS connection, a region and a ready,
joined environment, with repositories instead of folders. The Config
tab's Host section edits CPU/memory (valid pairs only), architecture,
storage, ephemeral size, Spot, idle stop, WIP push and repositories, and
can remove the AWS resources or move the project back to this machine.
Docker access, VPN, port mappings and the gateway backend are disabled on
AWS with the reason; the sandbox shows Off on AWS for now. Stop on an AWS
workspace with unsaved work asks first (Push to WIP branch preselected);
a failed start shows the task's last log lines; removing an AWS project
can delete its saved files, and asks again when its connection is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 10:15:14 -08:00
shadowdaoandClaude Opus 5.5 51042f5d9e feat(aws): stop guard, task log and AWS resource removal commands
check_unsaved_work runs triple-c-wip-push --dry-run through the agent as
claude (checked:false with the reason when the workspace can't be asked);
stop_project_container takes wip: push runs the push first and a failure
aborts the stop with its output. aws_task_log reads the current or last
task's stream in the environment record's log group. remove_aws_resources
removes a stopped project's AWS resources and clears its runtime handles,
so it can move off AWS. add_project takes the host fields; an AWS project
takes repositories, no folders, and no runtime handles from the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 10:15:14 -08:00
shadowdaoandClaude Opus 5.5 b1ac78613c fix(container): aws mode review round 1
Clone repos in the background after the agent starts (progress in
/run/triple-c-clone; an unfinished clone keeps the workspace from idling),
give the task definition stopTimeout 120 for the SIGTERM WIP push, reserve
the TRIPLEC_ env prefix on both hosts, and stop root writing through
claude-controlled links (~/.claude.json written as claude only via the exact
Hybrid link; ~/.ssh merged only through the exact Hybrid link, Docker paths
unchanged). Keep the user's ssh command and add BatchMode, cover dot-folders,
refuse undecodable or over-long file names by name, and upload LFS objects
before the --no-verify push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 09:59:00 -08:00
shadowdaoandClaude Opus 5.5 51f71212d2 feat(container): aws mode — secret files, repo clone, Hybrid home, WIP push, idle stop
With TRIPLEC_HOST=aws the entrypoint decodes TRIPLEC_FILES into
/tmp/.host-ssh and /tmp/.host-ca and unsets it (and TRIPLEC_REPOS) before
the agent's environment snapshot; links ~/.claude, ~/.claude.json and ~/.ssh
into /mnt/triple-c-home for Hybrid storage; clones TRIPLEC_REPOS as claude
after git credentials and ~/.ssh are set up; runs triple-c-wip-push from the
TERM trap; and starts triple-c-idle-watch, which signals the entrypoint
itself (PID 1 is docker-init under initProcessEnabled). Docker mode is
unchanged: every new step is gated on TRIPLEC_HOST=aws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 09:46:03 -08:00
shadowdaoandClaude Opus 5.5 8d45a93d63 fix(aws): a start cancels a pending re-attach; keep an unstoppable task recorded
ensure_started cancels a background re-attach instead of waiting for it,
and every register bumps the project's generation, so a late re-attach can
never replace a newer tunnel. A task StopTask refused after a failed start
keeps its ARN. A re-attach that gives up clears the pending flag only if
still current; close_if_task checks and takes under one lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 09:30:17 -08:00
shadowdaoandClaude Opus 5.5 37e94b1ee6 fix(aws): record a task as soon as it exists; repair a raced token; guard re-attach
A start stores the task ARN when RunTask returns (or the task is found),
reconcile finds an unrecorded live task, and a failed attach leaves the
project in Error rather than hiding a running task. The winner of a start
race puts its agent token back in the secret. A background re-attach
registers only if the project still runs that task and was not stopped
meanwhile. Removing a project whose connection is gone needs explicit
confirmation. Names come from tofu_modules; files are read with a hard cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 09:25:53 -08:00
shadowdaoandClaude Opus 5.5 e05f7e3cd8 feat(aws): start, attach and stop a project on its AWS environment
ProjectHost::Aws and the tunnel registry: start looks for the project's
live task before preparing (a running task is attached to with the token
read back from its secret, never rotated), waits for a new access point,
runs, waits, tunnels and checks the agent; a task the start ran is stopped
on any later failure. Stop, remove (resources; data only on request),
reconcile (background re-attach) and the AWS death watch, with exits
confirmed through the registry. Docker-only Reset, migration, staleness
and container info refuse or skip AWS projects; an AWS project with
resources can't be moved off its host. Shutdown closes every tunnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 09:15:37 -08:00
shadowdaoandClaude Opus 5.5 7fd6fa6aaa feat(aws): environment record, agent token, access point wait and resource removal
RuntimeApi (GetParameter, GetRole, GetSecretValue, DescribeAccessPoints,
task definition retirement, DeleteSecret with a 7-day window,
DeleteAccessPoint) with the decisions in tested functions over it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 09:15:37 -08:00
shadowdaoandClaude Opus 5.5 e43ed26e20 feat(aws): run, find, stop and watch workspace tasks
aws/tasks.rs: TaskApi (+ SdkTaskApi) with RunTask, DescribeTasks (tags),
ListTasks by family, StopTask and a GetLogEvents tail. find_or_run reuses
the project's live task only when its tags name the caller and the
project, and otherwise runs the exact family:revision with the three
ownership tags (FARGATE, or the FARGATE_SPOT capacity provider for Spot),
with a client token so SDK retries cannot start a second task.
wait_ready polls with injectable timing and fails with the stop reason
and the last 50 log lines. Quota refusals become AwsError::QuotaExceeded
and are never retried. classify_stop maps stop codes per the spec.

host/watch.rs: ExitSource::Aws polls an injected project-to-task lookup
every 15 s, per connection+region+cluster, 100 tasks per call; a group
that can't be asked is skipped. has_exited's Agent arm is unchanged (R3).
container_watch::run/spawn take their ExitSource.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 08:56:11 -08:00
shadowdaoandClaude Opus 5.5 30d6b26a42 fix(aws): fingerprint the built task definition; harden workspace lookups
- The fingerprint hashes a canonical rendering of the built request
  (Debug-redacted environment and HashMap log options rendered
  separately, labels excluded) instead of the plan plus a template
  constant, so a builder change can't silently keep old revisions.
- Page ListTaskDefinitions so a longer family can't crowd out the
  caller's revisions.
- Project ids that aren't [A-Za-z0-9-] get a hash suffix in names, so
  ids that sanitise alike can't share a secret.
- Name a secret scheduled for deletion; only mount an available file
  system; drop the unused FileSystemRef.arn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 08:46:32 -08:00
shadowdaoandClaude Opus 5.5 64ff3baab2 feat(aws): per-project secret, access point and task definition
aws::workspace prepares an AWS project before its task runs: one
Secrets Manager secret holding every project-shaped value (P1; files
as one TRIPLEC_FILES key per R1), the Hybrid EFS access point, and a
task definition whose environment is only TRIPLEC_HOST and COLORTERM
(R2), with every payload key as a valueFrom secret reference.

A revision is reused only when it carries the caller's owner tag, this
project's tag and the expected fingerprint label (other users can
register into any triple-c-* family), always by its exact
family:revision ARN. Payload keys and secrets entries are sorted so
unchanged settings never register a new revision. Names (family,
secret, access point root, log stream prefix, file system token) live
in tofu_modules and use owner_key.

Adds aws-sdk-efs and aws-sdk-cloudwatchlogs (released with the pinned
aws-sdk-ecs; lock gains only their own entries).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 08:39:58 -08:00
shadowdaoandClaude Opus 5.5 7320aaa5d4 fix(aws): document register-into-family residual risk, correct sizes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 08:27:48 -08:00
shadowdaoandClaude Opus 5.5 d1883310dd feat(aws): workspace users register their own task definitions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 08:26:08 -08:00
shadowdaoandClaude Sonnet 5.5 01e7b2752a refactor(docker): build container env in one host-neutral function
workspace_env() builds every project-shaped KEY=VALUE pair (backends, CA vars,
MANAGED_AUTH_KEYS blanking, custom env, instructions, Claude Code settings);
create_container keeps COLORTERM, HOST_UID/GID and the host lookups (CA
resolution, keychain token) and formats the pairs. Golden vectors recorded from
the pre-refactor block pin the order and values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 08:22:13 -08:00
shadowdaoandClaude Opus 5.5 0fc99738bb feat(aws): project model for the AWS host
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 08:16:56 -08:00
shadowdaoandClaude Opus 5.5 a674f6f196 docs(aws): phase 5 plan — Fargate workspaces
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 07:47:15 -08:00
jknapp 2d094b4748 Merge pull request 'AWS host phase 4: environments and onboarding with OpenTofu' (#81) from feat/aws-environments into main
Secret Scan / scan (push) Successful in 6s
2026-11-15 15:39:32 +00:00
shadowdaoandClaude Opus 5.5 be6f4f05a4 test(aws): live lifecycle checks Joined from the completion output and NotJoined after removal
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 5m28s
Build App (Preview) / build-windows (pull_request) Successful in 10m8s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / test (pull_request) Successful in 7m16s
Build App (Preview) / build-linux (pull_request) Successful in 14m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 00:06:26 -08:00
shadowdaoandClaude Opus 5.5 6911b9de27 fix(aws): final review round for the environment stacks
Critical
- Owner key is sha256("<region>:<aws:userid>") (12 hex): IAM role names are
  account-global and environments regional, so the same user and env name
  in two regions no longer collide. Every caller, the onboarding module's
  owner_key validation, docs and a two-region test.

Important
- A failed onboarding is Incomplete, not Joined: Joined needs the task role
  and, where the bucket is readable, the state's new onboarding_complete
  output (depends_on every resource); a failed or errored local run or a
  stale lock is Incomplete. Join stays as the retry; Follow, Unlock (own
  key only, stale, conditional delete) and Push saved state work on the
  caller's own onboarding.
- errored.tfstate: the runner logs an Error line naming it, follow's
  summary says so, materialize refuses while it exists, and the new
  push_aws_saved_state command runs `tofu state push errored.tfstate`
  through the same runner (file set aside once pushed); UI on the row.
- Bundle README: the bucket is created with the component tag in
  --create-bucket-configuration (the policy's RequestTag condition), plus
  the TLS-only bucket policy; the boundary bundle names an account admin
  WITHOUT the environment-admin files.
- Destroy-incomplete refuses while any users/<env>/ state remains.
- existing network: data.aws_subnet lookups and a precondition (same VPC,
  different AZs) on the task security group.

Minor
- The TLS statement is added to an existing bucket policy, never put over it.
- Deploy progress is emitted to the main window only.
- materialize checks and writes under start.lock.
- UI: managed-elsewhere label and explanation, Incomplete explained,
  Repair/Destroy disabled under a held lock ("Unlock first").
- Cargo.toml libc comment; README: Cancel unavailable on Windows, session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 00:01:32 -08:00
shadowdaoandClaude Opus 5.5 0b3b100a91 fix(aws): review round for the live environment tests and detection
- Live tests never delete a boundary or bucket they did not create: the
  boundary goes only through its module's destroy (no direct-delete
  fallback), ownership is taken only on NoSuchEntity, and the bucket is
  deleted only if this run created it.
- Detection reads at most 200 state objects of up to 2 KiB to find empty
  ones, and a failed read keeps the entry instead of failing the scan.
- Docs: DisassociateAddress on a live association is expected-denied, not
  verified (probe listed); the e2e admin role carries no boundary, and
  why; the spec's tenancy text follows D2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 23:38:21 -08:00
shadowdaoandClaude Opus 5.5 ba88323feb test(aws): live environment lifecycle with OpenTofu as an environment admin; docs
The environment live tests apply the real modules through the real
detached runner (the built app binary) as a role holding only the admin
files: state bucket, public deploy, scan, self-onboard, update with no
changes, destroy; the conditional lock delete (412 on a stale ETag); and
a private-nat deploy. A guard destroys what is left as the account admin
and deletes the throwaway bucket. CLAUDE.md and the spec describe the
OpenTofu design (D2-D10) in place of CloudFormation; phase 4 is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 23:31:49 -08:00
shadowdaoandClaude Opus 5.5 5f38e0d0d2 fix(aws): an empty state is no deployment
tofu destroy leaves the state object behind, empty, so a destroyed
environment kept scanning as Incomplete and a removed onboarding as
Joined. Detection reads small state objects and drops those that manage
no resources. Also lets a test scan and create a throwaway state bucket
(scan_in_bucket, ensure_named_state_bucket).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 23:31:49 -08:00
shadowdaoandClaude Opus 5.5 2f5305eeb1 fix(aws): admin policy gaps found by the live OpenTofu run
- s3:TagResource with s3:CreateBucket: a create with tags authorizes it
  (same request-tag and in-account conditions).
- ec2:CreateSecurityGroup on the VPC: EC2 shows the VPC no request tags,
  so that side is unconditioned; the group itself still needs the tag.
- ec2:DisassociateAddress on the literal */* only (${*}/${*}): destroying
  the NAT gateway's address disassociates it after the association is
  gone. The evaluator learns ${*}.

Checks, MODULE_CALLS and aws/README.md (including the Environments
section and the phase-4 live results) change with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 23:31:49 -08:00
shadowdaoandClaude Opus 5.5 bc23fc2dee fix(aws): environments panel acts on the scanned region; runs keyed per region
Rows and actions belong to the scan they came from: a region change hides
the old rows, detection in flight disables actions, and every action and
confirmation targets scan.region. Runs, pending state and errors are keyed
by region as well as state key. A boundary run that fails in the apply now
offers the boundary bundle with its summary; waiting-for-admin shows only
after a bundle is saved; clearer Destroy copy. Tests for each, and no act()
warnings in the new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:56:36 -08:00
shadowdaoandClaude Opus 5.5 cf9b5eab3a feat(aws): environments panel — detect, deploy, update, repair, join
EnvironmentsPanel under each AWS connection: region select and detection,
environment rows with state, lock owner/age, module version and the
detected actions (Join, Update, Repair, Destroy, Follow, Unlock), typed
confirmations for Destroy and Unlock, self-onboarding for environment
admins and the onboarding bundle for everyone else (R15).
DeployEnvironmentModal (name rule mirrored from Rust, D4 networks with
cost lines, boundary-first step), AdminBundleModal (D5 bundles) and
DeployProgress, whose hook subscribes before invoking and buffers events
by state key until the op id is known.

awsErrorMessage moves to lib/aws-error; PolicyFiles to its own module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:50:23 -08:00
shadowdaoandClaude Opus 5.5 a8b83ca63a fix(aws): follow checks the launch slot under the same lock it installs in
The work-folder registry is a pure Slots<T> whose launch, follow and
clean-up transitions each run under one lock acquisition, with tests;
HeadObject errors name the action as s3:GetObject (HeadObject).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:41:29 -08:00
shadowdaoandClaude Opus 5.5 964ab993c8 fix(aws): gate progress behind the op id, conditional unlock, launch slots
Followers hold their first event until the command returns its op id; a
re-follow replays the whole log. Unlock re-reads the lock and deletes it
only if its ETag is unchanged and it is still stale. A launch claims its
work folder so overlapping launches and follows are refused. Repair,
update and destroy refuse under a held lock; destroy's guard is a tested
helper; the boundary deploy refuses a boundary that exists without state
here; the state read for a VPC id is capped while streaming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:38:18 -08:00
shadowdaoandClaude Opus 5.5 3324e3d6fe feat(aws): environment commands with streamed OpenTofu progress
Eleven commands (detect, deploy, update, repair, destroy-incomplete,
unlock, follow, cancel, onboard, boundary, admin bundle) that re-run
detection server-side before acting, start detached OpenTofu runs off
the async runtime, and stream each run's log on aws-deploy-progress
with one follower per deployment. State keys from the frontend are
parsed back into a Deployment; anything state_key() can't produce is
refused. The admin bundle is written into a folder picked from Rust and
never overwrites unrelated files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:29:57 -08:00
shadowdaoandClaude Opus 5.5 97e6a3e15a fix(aws): never offer Destroy on a working environment; surface credential 403s; keep bucket tags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:20:34 -08:00
shadowdaoandClaude Opus 5.5 f26388969b feat(aws): state bucket, and detection of environments, locks and onboarding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:16:05 -08:00
shadowdaoandClaude Opus 5.5 b7cdf02859 fix(aws): never kill a runner that holds the lock; serialise starts
start_detached stops waiting once the runner holds apply.lock and returns
Started { pid: Option } (no PID: Cancel unavailable), killing only a
runner that never took the lock nor finished. The check, clear, spawn and
wait run under an exclusive start.lock, and a concurrent start or a held
apply.lock is StartError::AlreadyRunning with nothing touched. The runner
installs its SIGINT forwarder before writing apply.pid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:05:31 -08:00
shadowdaoandClaude Opus 5.5 9ad8b99d3c fix(aws): runner liveness by lock, Cancel via the runner, clean tofu env
The runner holds an exclusive lock on apply.lock for its life and writes
its own PID, so a reused PID is never a running deploy; start_detached
waits for the lock (or kills the spawn). Cancel signals the runner alone,
which forwards SIGINT to tofu, so an AppImage's FUSE server in the same
group is never hit. tofu gets exactly child_env's variables with any
AppImage launcher changes undone, an app-owned CLI config, endpoint URLs
from AWS config ignored, and no AWS config files for keys connections.
Keys connections without keys are an error; init runs with -json so its
errors become the summary; Windows allowlist and AWS_CA_BUNDLE added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 22:00:41 -08:00
shadowdaoandClaude Opus 5.5 a000d64577 feat(aws): pinned OpenTofu download and a detached apply runner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:46:43 -08:00
shadowdaoandClaude Opus 5.5 02c8616512 fix(aws): pin the state bucket to the account, tag-scope network parents, drop v1-unused grants
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:34:57 -08:00
shadowdaoandClaude Opus 5.5 7dbf568696 feat(aws): admin policies cover the Terraform modules; drop CloudFormation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:25:48 -08:00
shadowdaoandClaude Opus 5.5 31c0a918e6 fix(aws): EC2-safe SG descriptions, deny other EFS principals, format-check the record
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:08:32 -08:00
shadowdaoandClaude Opus 5.5 f061ddb9a4 feat(aws): onboarding and permissions-boundary Terraform modules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:03:20 -08:00
shadowdaoandClaude Opus 5.5 6192b7c1e8 fix(aws): keep environment subnet AZs stable; state_key takes a Deployment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:54:42 -08:00
shadowdaoandClaude Opus 5.5 3583bee31e feat(aws): environment Terraform module and naming
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:50:55 -08:00
shadowdaoandClaude Opus 5.5 fe7642ee2c docs: phase 4 plan switches to OpenTofu with S3 state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:43:51 -08:00
shadowdaoandClaude Opus 5.5 f924040f9e docs: phase 4 plan (environment + onboarding stacks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:34:00 -08:00
jknapp 3bc0e9fc3b Merge pull request 'AWS host phase 3: AWS connections and tiered permission check' (#80) from feat/aws-connection into main
Secret Scan / scan (push) Successful in 30s
2026-11-15 04:14:59 +00:00
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) / create-release (pull_request) Successful in 10s
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
Build App (Preview) / compute-version (pull_request) Successful in 14s
Build App (Preview) / build-linux (pull_request) Successful in 11m3s
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 (push) Successful in 5s
Build STT Container / build-stt-container (pull_request) Successful in 2m33s
Build Container / build-container (pull_request) Successful in 18m56s
Secret Scan / scan (pull_request) Successful in 12s
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
Build Container / build-container (pull_request) Successful in 1m57s
Build STT Container / build-stt-container (pull_request) Successful in 4m12s
Secret Scan / scan (pull_request) Successful in 10s
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)
Secret Scan / scan (push) Successful in 7s
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) / prune-previews (pull_request) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 10s
Build App (Preview) / test (pull_request) Successful in 10m59s
Build App (Preview) / build-linux (pull_request) Successful in 15m27s
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
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 / compute-version (push) Successful in 12s
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) / create-release (pull_request) Successful in 6s
Build App (Preview) / build-macos (pull_request) Successful in 3m36s
Build App (Preview) / build-windows (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 13s
Secret Scan / scan (pull_request) Successful in 1m15s
Build App (Preview) / test (pull_request) Successful in 9m14s
Build App (Preview) / build-linux (pull_request) Successful in 6m2s
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
Secret Scan / scan (push) Successful in 7s
Build App / build-macos (push) Successful in 3m19s
Build App / build-windows (push) Successful in 7m6s
Build App / compute-version (push) Successful in 5s
Build App / build-linux (push) Failing after 26s
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
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 3m16s
Build App / build-windows (push) Successful in 7m20s
Build App / compute-version (push) Successful in 6s
Build App / build-linux (push) Successful in 6m18s
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
Build Container / build-container (pull_request) Successful in 11m9s
Build App (Preview) / create-release (pull_request) Successful in 7s
Build App (Preview) / build-macos (pull_request) Successful in 3m20s
Build App (Preview) / build-windows (pull_request) Successful in 6m30s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 14s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build App (Preview) / test (pull_request) Successful in 6m25s
Build App (Preview) / build-linux (pull_request) Successful in 8m27s
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) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 3m14s
Build App (Preview) / build-windows (pull_request) Successful in 6m19s
Build App (Preview) / prune-previews (pull_request) Successful in 14s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / test (pull_request) Successful in 4m24s
Build App (Preview) / build-linux (pull_request) Successful in 8m32s
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
jknapp 3ec100e06c Merge pull request 'Host abstraction, phase 1a: ContainerTarget for container-interior operations' (#74) from refactor/project-host into main
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 3m16s
Build App / build-windows (push) Successful in 7m3s
Build App / compute-version (push) Successful in 5s
Build App / build-linux (push) Successful in 6m24s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 21s
2026-11-13 19:12:23 +00:00
shadowdaoandClaude Opus 5.5 a19643e0e8 Finish phase 1a: empty the boundary allowlist, sessions hand out their target
Secret Scan / scan (push) Successful in 13s
Build App (Preview) / create-release (pull_request) Successful in 13s
Build App (Preview) / build-macos (pull_request) Successful in 3m40s
Build App (Preview) / build-windows (pull_request) Successful in 18m30s
Build App (Preview) / prune-previews (pull_request) Successful in 11s
Build App (Preview) / compute-version (pull_request) Successful in 12s
Secret Scan / scan (pull_request) Successful in 12s
Build App (Preview) / test (pull_request) Successful in 5m39s
Build App (Preview) / build-linux (pull_request) Successful in 6m1s
- ExecSessionManager::get_target replaces get_container_id, so image paste,
  file drop and the audio bridge reuse the session's own target instead of
  rebuilding a Docker one; the web terminal builds its target per project.
- Drop the error-prefix rewriting helper: download/backup/run-now errors now
  carry the host layer's own message after their prefix.
- Remove the now-unused create_attached_exec and MAX_ONESHOT_OUTPUT re-export.
- The boundary test's allowlist is down to migration (Docker-only) and
  container_watch (phase 1b). CLAUDE.md documents the host layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 10:32:46 -08:00
shadowdao 32a4d345f2 Merge branches 'worktree-agent-ab480d282b24cdf47', 'worktree-agent-a7c3b605eebffe568' and 'worktree-agent-a4dba14b3fcf8be1d' into refactor/project-host 2026-11-13 10:31:14 -08:00
shadowdaoandClaude Opus 5.5 229dc2cefc Route auth bridge, browser view and terminal commands through ContainerTarget
Phase 1a of the AWS host work: these files no longer name bollard or
docker::exec; every exec, upload and is-running check goes through
host::ContainerTarget. No behaviour change: same user, working dir, env,
tty and stdin attachment for every exec.

- auth_bridge: the poller, reconcile and PortForward hold a ContainerTarget
  (AuthBridgeManager::start keeps its container_id signature).
  tunnel_connection_with_prelude keeps its String signature for the
  browser-view proxy and wraps the new tunnel_into_with_prelude.
- browser_view: the supervisor and viewer helpers take a ContainerTarget;
  detect::detect_in is the target-taking form of detect; install.rs's
  run_step uses ContainerTarget::attach with an ExecSpec equal to the old
  create_attached_exec_as call. page.rs builds the target at each exec.
- terminal_commands: open_terminal_session uses ContainerTarget::for_project;
  session-derived calls build ContainerTarget::docker from the session's id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 10:30:52 -08:00
shadowdaoandClaude Opus 5.5 41010a9c50 Route file and inspect commands through host::ContainerTarget
file_commands.rs and inspect_commands.rs no longer name bollard,
docker::exec or get_docker. The archive read, running checks, attached
download/backup execs, one-shot execs, uploads and the detached
scheduler run all go through ContainerTarget, with each exec's options
(user, env, no working dir, no TTY, no stdin) and user-facing error
wording preserved.

fetch_container_file and require_running keep their container-id
signatures because file_viewer_commands.rs calls them with an id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 10:28:27 -08:00
shadowdaoandClaude Opus 5.5 737a608dd5 Route auth-token, file viewer and marketplace execs through ContainerTarget
setup-token, the viewer's poll/probe execs, the marketplace sync and the
container gh login now reach the container via host::ContainerTarget
instead of docker::exec / is_container_running. No behaviour change: same
user, working dir, env, tty and stdin attachment for every exec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 10:28:02 -08:00
shadowdaoandClaude Opus 5.5 209b8855a4 Add host::ContainerTarget, the host-neutral way into a project container
Phase 1 of the AWS host work (docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md).

- host::ContainerTarget (one Docker variant today) wraps every
  container-interior operation: one-shot/attached/detached execs, resize,
  exit status, uploads, archive download, is_running. It delegates to
  docker/exec.rs with no behaviour change.
- AttachedExec now yields host-neutral ExecChunk instead of bollard's
  LogOutput; docker/exec.rs converts at the boundary.
- ExecSessionManager moves to host::sessions and holds a ContainerTarget.
- A boundary test lists the files that still reach into containers through
  bollard / docker::exec directly; follow-up commits empty it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 10:23:41 -08:00
jknapp 3a4e298849 Merge pull request 'Design: run projects on AWS ECS / Fargate' (#73) from docs/aws-ecs-host-design into main
Secret Scan / scan (push) Successful in 7s
2026-11-13 18:18:07 +00:00
shadowdaoandClaude Opus 5.5 1118cfa458 Add design spec for running projects on AWS ECS / Fargate
Secret Scan / scan (push) Successful in 12s
Secret Scan / scan (pull_request) Successful in 10s
Per-project host selection (Local Docker or AWS), AWS-CLI-style login,
tiered permission check, detect-then-join-or-deploy environments,
strict per-user tenancy by default, Secrets Manager delivery, Hybrid
EFS/ephemeral storage, in-container agent over SSM port forwarding,
and a phased plan starting with a ProjectHost abstraction refactor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 10:17:19 -08:00
jknapp d4a14d2ded Fix speech-to-text microphone on Linux AppImage (#72)
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 3m14s
Build App / build-windows (push) Successful in 8m16s
Build App / compute-version (push) Successful in 5s
Build App / build-linux (push) Successful in 8m17s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 19s
2026-11-05 14:42:32 +00:00
jknapp 4532412869 Keep containers alive against claude-user kills; mark self-exited containers Stopped (#71)
Secret Scan / scan (push) Successful in 10s
Build App / build-macos (push) Successful in 3m40s
Build App / build-windows (push) Successful in 7m38s
Build Container / build-container (push) Successful in 5m2s
Build App / compute-version (push) Successful in 21s
Build App / build-linux (push) Successful in 6m24s
Build App / create-tag (push) Successful in 11s
Build App / sync-to-github (push) Successful in 11s
2026-11-03 00:41:10 +00:00
jknapp e5b73d8fa5 Keep the migration manifest probe under its output limit on large workspaces (#70)
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 3m15s
Build App / build-windows (push) Successful in 7m38s
Build App / compute-version (push) Successful in 5s
Build App / build-linux (push) Successful in 5m45s
Build App / create-tag (push) Successful in 12s
Build App / sync-to-github (push) Successful in 24s
2026-11-03 00:31:38 +00:00
jknapp 1fc1f44c35 Move existing gateways onto the pinned LiteLLM after an app update (#69)
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 3m13s
Build App / build-windows (push) Successful in 7m21s
Build App / compute-version (push) Successful in 6s
Build App / build-linux (push) Successful in 5m52s
Build App / create-tag (push) Successful in 17s
Build App / sync-to-github (push) Successful in 32s
2026-11-03 00:24:29 +00:00
jknappandClaude Opus 5.5 e223a3f64b Security updates + terminal URL, /tmp file-link and copy fixes (#68)
Secret Scan / scan (push) Successful in 8s
Build App / build-macos (push) Successful in 3m14s
Build App / build-windows (push) Successful in 7m23s
Build App / compute-version (push) Successful in 22s
Build STT Container / build-stt-container (push) Successful in 4m12s
Build App / build-linux (push) Successful in 7m29s
Build App / create-tag (push) Successful in 10s
Build App / sync-to-github (push) Successful in 10s
Build Container / build-container (push) Successful in 18m41s
npm/cargo/LiteLLM/STT/sandbox security updates; URL trailing punctuation, hard-wrapped /tmp file links, UTF-8 OSC 52 and copy cleanup; CI fixes for AppImage permissions, STT gha cache and needrestart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:23:37 +00:00
jknapp af59dc8bec Resume a session as a Claude tab so Shift+Enter inserts a newline (#67)
Secret Scan / scan (push) Successful in 6s
Build App / build-macos (push) Successful in 3m25s
Build App / build-windows (push) Successful in 7m1s
Build App / compute-version (push) Successful in 9s
Build App / build-linux (push) Successful in 7m44s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 18s
2026-10-17 02:22:13 +00:00
jknapp 412abd9348 Keep container SSH keys across restarts (#66)
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m27s
Build App / build-windows (push) Successful in 7m1s
Build App / compute-version (push) Successful in 5s
Build App / build-linux (push) Successful in 8m42s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 18s
Build Container / build-container (push) Successful in 10m58s
2026-10-16 19:46:23 +00:00
jknapp ee1bb691e6 Triple-C marketplace: agents, skills, commands, hooks and plugins for all or individual containers (#65)
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m55s
Build App / build-windows (push) Successful in 7m11s
Build App / compute-version (push) Successful in 4s
Build App / build-linux (push) Successful in 9m53s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 56s
2026-09-27 23:43:27 +00:00
jknapp 292fc907fb Shared login: press Enter separately from the pasted code (#64)
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m45s
Build App / build-windows (push) Successful in 5m35s
Build App / compute-version (push) Successful in 4s
Build App / build-linux (push) Successful in 7m33s
Build App / create-tag (push) Successful in 10s
Build App / sync-to-github (push) Successful in 1m13s
2026-09-27 13:29:29 +00:00
jknappandClaude Opus 5.5 0d117e97fe Add Auto permission mode (#63)
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m4s
Build App / build-windows (push) Successful in 5m48s
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m58s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 2m27s
Adds Claude Code's auto permission mode as a fifth option between Accept Edits and Bypass, across terminals, resumed sessions, the tab badge, the scheduler task runner and docs. Warns that Auto falls back to prompting when unavailable for the model/backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:02:06 +00:00
jknappandClaude Opus 5.5 f8e3ec1150 CI: sign Windows releases with Azure Artifact Signing (#62)
Secret Scan / scan (push) Successful in 7s
Build App / build-macos (push) Successful in 3m6s
Build App / build-windows (push) Successful in 5m52s
Build App / compute-version (push) Successful in 17s
Build App / build-linux (push) Successful in 5m15s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 1m50s
Windows releases are now signed with Azure Artifact Signing: the app binary, the MSI, the NSIS installer and its uninstaller, 5 signatures per release. Build-time WiX and NSIS DLLs are skipped. "Verify signatures" fails a release unless the installers, the binaries inside the MSI, and the logged app-binary and uninstaller signatures are all valid and timestamped.

Previews are not signed and don't reference the signing secrets. The sign command reaches Tauri through `--config`; the v2 CLI never read TAURI_CONFIG, so the old inline override was a no-op.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 02:09:48 +00:00
jknappandClaude Opus 5.5 85901d8a80 CI: run vitest and cargo test on every PR (#61)
Secret Scan / scan (push) Successful in 6s
The PR check now runs vitest and `cargo test --locked` in a `test` job that runs alongside the platform builds. That job is the merge-time guard for the app-command ACL census.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:17:14 +00:00
jknappandClaude Opus 5.5 8305c96e20 Terminal file viewer/editor + per-window app-command lockdown (#60)
Secret Scan / scan (push) Successful in 8s
Build App / build-macos (push) Successful in 2m53s
Build App / build-windows (push) Successful in 5m15s
Build App / compute-version (push) Successful in 7s
Build App / build-linux (push) Successful in 5m12s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 1m5s
Clicking a file path in Claude's terminal output now opens the file in its own window with a CodeMirror 6 editor. The editor highlights the target line, live-reloads while the file changes, and saves explicitly with hash-based conflict detection. The viewer commands are gated by window label.

Every app command is now ACL-gated per window through a Tauri AppManifest. build.rs checks the handler list against the capability files and fails the build on any mismatch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:05:50 +00:00
jknapp 3537b234d8 Make links in Claude's output clickable (#59)
Secret Scan / scan (push) Successful in 6s
Build App / build-macos (push) Successful in 2m44s
Build App / build-windows (push) Successful in 4m58s
Build App / compute-version (push) Successful in 6s
Build App / build-linux (push) Successful in 5m51s
Build App / sync-to-github (push) Successful in 8s
Build App / create-tag (push) Successful in 9s
Reviewed three times. Rounds 1 and 2 each found a real hole in the gate -- a plain click opened links, then a selection gesture did -- both addressed. The attacker-controlled mouse mode is recorded as a known residual rather than claimed closed.

Still unverified on a real desktop: double-click and drag-select across a link in both tracking states.
2026-09-19 03:20:15 +00:00
shadowdaoandClaude Opus 5 83c9c24951 test: give two synthesised clicks the detail a real click carries
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m43s
Build App (Preview) / build-windows (pull_request) Successful in 4m54s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Build App (Preview) / compute-version (pull_request) Successful in 9s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / build-linux (pull_request) Successful in 7m58s
The previous commit tightened the gate's click-count check from `> 1` to
`!== 1`, which two tests in the wiring block did not survive: they built
`new MouseEvent("click", { button: 0 })` directly rather than through the
`click()` helper, so `detail` defaulted to 0 and the gate refused them.

The gate is right and the tests were wrong -- a mouseup derived from a real
click always carries `detail >= 1`, and 0 is exactly the synthetic-event
shape the tightening was for. Both now pass `detail: 1`.

I pushed the previous commit without noticing this, having read a truncated
test summary that hid the failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:08:36 -07:00
shadowdaoandClaude Opus 5 c6f9c1d43f fix: tighten the click-count check and stop three comments overstating
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 5m0s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / build-linux (pull_request) Successful in 6m6s
Third-round review polish; no behaviour change beyond the first item.

`detail > 1` was justified in a comment by noting a synthesised event
carries `detail` 0 -- which is an argument for letting untrusted synthetic
events through the click-count half of the gate. A mouseup derived from a
real click always carries `detail >= 1`, so the check is now `!== 1`.
Nothing in the container can dispatch a DOM event, so this is hardening
rather than a hole; the comment now says that instead of the reverse.

Three comments claimed more than they hold. The selection check's
paragraph read as though it caught every copy gesture: it sees a drag only
once the drag has spanned a cell, so a press and release inside one
character cell -- or a drag walked back to its start -- still opens the
link. That is the gap the rejected mousedown/mouseup distance check would
have closed, and it is now recorded beside the reason for rejecting it.

`?1002l` was described as taking effect synchronously with the write; it
takes effect when xterm parses it, on its queued write task. And
`modifierPromised` was described as written on every hover, when `hover()`
clears and returns early with no host element -- which leaves it false, the
stricter direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:07:59 -07:00
shadowdaoandClaude Opus 5 593b8168eb fix: a selection is not a request to leave the app
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 4s
Build App (Preview) / build-macos (pull_request) Successful in 2m42s
Build App (Preview) / build-windows (pull_request) Successful in 5m0s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 7s
Build App (Preview) / build-linux (pull_request) Successful in 6m39s
Re-review found the gate did not cover the gesture users actually make.
xterm's `Linkifier._handleMouseUp` has no click-count check, no distance
threshold and no timestamp, so it activates on the mouseup that *ends a
selection* as readily as on a click. Double-clicking a word or dragging
across a few characters inside an OSC 8 link therefore opened the browser.

Worse with a program holding the mouse: the only way to select text there
is Shift/Option+drag, which is byte-identical to the gesture the gate
accepted as a deliberate request to open. A container wrapping each output
row in a link would have harvested every legitimate copy.

`term.hasSelection()` is the load-bearing check: a drag is one press and
one release, so its click count is 1 and `detail` cannot see it. `detail >
1` is belt-and-braces for the case where the selection came out empty, and
for not depending on the selection model being written before the
Linkifier's listener runs -- it is, but the check costs nothing. Drag
distance was rejected rather than forgotten: xterm hands `activate` only
the mouseup, so measuring it means binding our own listener and keeping a
second source of truth about one gesture.

The hover card's promise is now sticky. The hint was computed once at hover
while the gate re-read the mode at mouseup, so a card reading "Shift+click
to open" could be on screen while a bare click opened the link. The gate
now requires the modifier if either the card asked for it or the live mode
does.

The same gate is applied to the WebLinksAddon branch, which had none. That
also closes a real bypass: `OscLinkProvider` drops non-http(s) OSC 8
targets before `linkHandler` sees them, so a `javascript:` target with an
`https://evil.tld` label fell through to WebLinks and opened ungated.

What is not closed, and is now recorded rather than papered over: the mouse
mode is a permission the container grants itself. It can drop tracking
before the pointer arrives and hold it off through the click. The selection
and click-count checks hold either way, so the mass-harvest variant is
gone, but the real fix needs a signal the container cannot write and this
pane does not have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:00:42 -07:00
jknapp d647b56b43 Do not read an unreachable Docker daemon as an absent container (#58)
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 2m49s
Build App / build-windows (push) Successful in 5m3s
Build App / compute-version (push) Successful in 17s
Build App / build-linux (push) Successful in 8m4s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 9s
Closes #56.

Reviewed twice; the second round's findings on the first fix are addressed in f662ed0 and a3840f7.
2026-09-19 02:59:20 +00:00
shadowdaoandClaude Opus 5 a3840f7263 fix: say which check failed, and stop claiming an order we do not use
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m53s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / build-linux (pull_request) Successful in 4m58s
Two accuracy defects from re-review, both the same class as the bug this
branch exists to fix.

`probe_failed` rendered every failure as "This project's container could
not be inspected", but only two of the four readings are about the
container -- the others are the base image and the snapshot. A malformed
base image name in settings therefore pointed the user at the wrong object.
The sentence now names the check rather than the container.

The doc claimed "the first error wins, in call order". It does not: the
checks run container_id, base_image_id, container_running, while the daemon
is called in a different order entirely. The priority is deliberate -- it
puts the reading that stopped the probe first -- so the comment now says
that, instead of describing an order the code does not use.

The test guarding the first point asserted the message does not contain
"Docker", using a synthetic payload. The real bollard error for that case
is "Docker responded with status code 400: invalid reference format", so
the assertion passed only because the payload was invented. It now uses the
real shape and asserts what actually matters: that nothing we add claims
the daemon was unreachable or names the container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:53:52 -07:00
shadowdaoandClaude Opus 5 ac50c38891 fix: gate OSC 8 link activation instead of merely hinting at it
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m42s
Build App (Preview) / build-windows (pull_request) Successful in 5m3s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / build-linux (pull_request) Successful in 7m13s
Review of this branch found its central premise was false. The claim was
that xterm cancels a mousedown before the link layer while a program holds
the mouse, so only a Shift+click could reach a link. None of that holds:
`cancel()` is `if (this.options.cancelEvents || force)` and `cancelEvents`
defaults to false and is never set here, so it does nothing; the mouse
reporting listeners bind to `.xterm` while the Linkifier is constructed on
`screenElement`, a descendant, so the link layer sees the event first
regardless; and `_handleMouseUp` checks neither the modifier nor the
button before calling `activate`.

So a plain click opened the link, and so did a right-click. That is not a
missing convenience. OSC 8 lets the container wrap any clickable TUI widget
-- a menu row, a "1. Yes", a file chip -- in a link to anywhere, and
because the mouse report still reaches the program afterwards the widget
responds too and nothing looks wrong. The hover card was the only
mitigation, and it assumes a user deliberately reaching for a link.

`opensOnClick` is now a real gate: primary button only, and while a program
tracks the mouse the force-selection modifier is required -- the gesture
the user already has for "this click is for the terminal, not the program".
With nothing tracking, a bare click opens, which is what WebLinksAddon
already does for plain-text URLs in the same buffer. The mode is read per
click through a getter rather than captured, and `syncMouseCapture` and the
gate share one expression, because a gate that disagreed with the badge
would be the hole again.

The gate and the hint also share one modifier predicate, and the hint is
conditional on tracking, so it can never name a key that does nothing.

Three more from the same review. The origin span had `flexShrink: 0`, which
beats `overflowWrap` under flexbox, so an attacker-controlled 600-character
origin ran off the pane and hid the registrable domain -- the same spoof as
an ellipsis, without one; it now wraps and the remainder is what gives way.
The card had no `pointerEvents: none`, and `xterm-hover` is inert at this
placement, so a card under the pointer took `mouseleave` from screenElement
and made bottom-row links flicker and refuse to activate at all. And the
design doc comment had come adrift from its function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:45:11 -07:00
shadowdaoandClaude Opus 5 f662ed04ce fix: a reading nobody consults must not destroy the report
Secret Scan / scan (push) Successful in 11s
Build App (Preview) / create-release (pull_request) Successful in 8s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 5m35s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 11s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / build-linux (pull_request) Successful in 7m17s
Review of this branch found the first cut made every probe error fatal,
including one that is usually irrelevant. `snapshot_exists` is consulted
only when there is no container, or when a stopped container coincides with
a busy project -- `pick_probe_source` discards it outright for a running
one. So a daemon hiccup between the four sequential readings turned a full
report into a bare "could not be checked" with Update disabled, in a change
whose whole purpose is handling exactly that hiccup better.

It is now carried as a `Result` to the points that consult it and surfaced
only there. `stopped_probe_policy` carries its own message, because
"try again once it finishes" claims waiting is the only obstacle, which a
failed `image_exists` has not established.

`base_image_id` stays fatal, deliberately: it is the right-hand side of the
comparison, and `image_id` already distinguishes "not pulled locally"
(`Ok(None)`, a legitimate not-stale) from "could not ask". Letting an `Err`
through as `None` would report a project up to date on a reading nobody
got -- #56 one field over.

The message no longer blames the daemon. Three of the four callees can
`Err` from a daemon that answered perfectly: `image_id` maps only 404 to
`Ok(None)`, and the base image name is user-supplied, so a malformed
reference told the user to go fix a daemon that was running fine. That is
the same category of error as #56 itself.

`ContainerState` makes "running is known but no container was found"
unrepresentable rather than merely unreached, so the downstream match has
no impossible arm and the invariant is enforced where it is established.

Finally, the tests covered the new function but not the line the bug was
on: a partial revert to `.unwrap_or(None)` kept them all green. The
readings now travel as a named struct of `Result`s, so that revert is a
compile error -- verified by performing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:38:31 -07:00
shadowdaoandClaude Opus 5 f311ca1990 feat: make links in Claude's output clickable
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m47s
Build App (Preview) / build-windows (pull_request) Failing after 13m30s
Build App (Preview) / prune-previews (pull_request) Skipped
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / build-linux (pull_request) Successful in 8m8s
Claude Code prints links as OSC 8 hyperlinks whose visible text is
hard-wrapped into terminal-width pieces -- urlDetector's header records a
346-character sign-in URL arriving as five emissions, each carrying the
whole URL in its parameter and about 80 characters on screen. WebLinksAddon
regex-matches the painted characters row by row, so against Claude it
matches a fragment or nothing, which is why the URL toast exists.

xterm 5.5 hands over the exact parameter through `linkHandler`, so the
slicing stops mattering. WebLinksAddon stays for plain-text URLs in
ordinary shell output; the two cover different cases and neither replaces
the other. Both now share one failure reporter and one validator.

No new key handling was needed. xterm's mousedown handler is
`if (areMouseEventsActive && !shouldForceSelection(e)) return cancel(e)`,
so holding the force-selection modifier lets the event reach the link
layer while Claude still holds the mouse -- Shift+click, or Option+click on
macOS, which this terminal already enables for text selection.

The hover card is the security half rather than decoration. OSC 8
decouples the label from the target completely: a container can print
`https://claude.ai` and link it anywhere, which is strictly worse than the
userinfo spoofing already guarded against and which invalidated the
justification for opening a click without confirmation ("a deliberate act
on visible text"). Hovering now shows the real origin, in full and never
truncated, because truncating it is the spoof. A target that fails
validation says so and deliberately echoes nothing of itself.

The hint names the modifier for the platform, from xterm's own `isMac`
list, so it cannot tell a Mac user to press a key that does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:23:18 -07:00
shadowdaoandClaude Opus 5 84a5757c74 fix: do not read an unreachable Docker daemon as an absent container (#56)
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / prune-previews (pull_request) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / build-linux (pull_request) Successful in 4m59s
`get_container_staleness` collected four probes through `unwrap_or`, so a
transient daemon fault landed on the same arm as a genuine absence and the
banner said, confidently and wrongly, that the project has no container or
snapshot image to compare against.

The four readings are now taken as `Result`s and funnelled through a pure
`collect_probe_inputs`, following `pick_probe_source` and
`stopped_probe_policy` in the same file, so the rule is unit-testable
without touching Docker. The first error in call order wins and becomes
`probe_error`; the command still returns `Ok`, because the hook's `catch`
sets `staleness` to null and the banner returns early on null -- an `Err`
here would hide the fault instead of reporting it.

One of the issue's premises did not hold. `is_container_running` does not
distinguish absent from unreachable: its body flattens every
`inspect_container` failure to `Ok(false)`, so only a `get_docker` failure
can surface as `Err`. Its `Result` is threaded through anyway, since that
one case is a real daemon-unreachable signal and this layer no longer adds
a second swallow on top, and the remaining gap is documented where the
decision is made rather than patched in `docker/container.rs`, which the
issue puts out of scope and whose doc comment says the swallow is
deliberate. In practice `find_existing_container` runs immediately before
and would already have errored if the daemon were down.

No frontend change: `probeUnavailable` in ContainerMigrationBanner already
routes a set `probe_error` to "Some checks did not complete".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 06:04:41 -07:00
jknapp 73a6e3d8b4 Merge pull request 'Make in-container OAuth logins actually complete' (#57) from fix/auth-callback-and-opener into main
Secret Scan / scan (push) Successful in 5s
Build App / build-macos (push) Successful in 2m44s
Build App / build-windows (push) Successful in 5m55s
Build App / compute-version (push) Successful in 5s
Build App / build-linux (push) Successful in 5m48s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 1m12s
Reviewed-on: #57
2026-09-18 04:32:07 +00:00
shadowdaoandClaude Opus 5 943c83b9e3 fix: stop a stale payload re-enabling a bridge the user turned off
Secret Scan / scan (push) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / build-linux (pull_request) Successful in 8m39s
Review of this branch found that `update_project` restored
`browser_view_enabled` from the store but took `auth_bridge_enabled` from
the IPC payload, on a comment claiming the Config tab edits it through that
save. The comment was wrong. `AuthBridgeRow` is the only writer, it calls
`set_auth_bridge_enabled` out of band precisely so the switch works while a
login is hanging, and it never writes the value back into frontend state --
so a payload's copy of that flag is always a stale snapshot.

The consequence was not cosmetic: turn the bridge off, then close a renamed
terminal tab, and `useTerminal` round-trips the stale `true` and the
reconcile block restarts a bridge whose own UI warns that a bridged port is
unauthenticated and reachable by any local process. Defaulting the flag to
true earlier in this branch made it worse, since the stale value is now
true for every pre-existing project.

Both flags are now restored from the store by `restore_store_owned_fields`,
and the reconcile block is gone rather than corrected: with the value
always restored it could only re-assert what was already true, and every
writer already owns its own side effect -- the setter starts and stops
synchronously, container start arms the bridge, launch reconcile re-arms
it, and the poller re-reads the flag each tick and self-terminates.
Re-adding a start path to the one function that no longer owns the flag is
what caused this.

Turning the browser view off also stopped tearing the session down when the
project record had vanished, because the persist used `?` and returned
early -- the supervisor's own `store.get()` check exists because records do
vanish mid-session. Teardown is now unconditional and the write error still
surfaces afterwards, since the stored flag saying "enabled" means the view
returns on next launch and that is worth reporting.

Finally, the opener no longer falls through to `gio` on any non-zero exit.
xdg-open's 1, 2 and 3 assert no handler ran; 4 also covers a handler that
was launched and then failed, which would have opened the link twice --
two authorize requests for one click in an OAuth flow. Reasoned from
documented exit codes rather than an observed double-open, and the cost is
stated: a genuine code-4 failure no longer reaches gio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:13:57 -07:00
shadowdaoandClaude Opus 5 60188610ee fix: do not let an in-flight open blank a newer prompt, or promise a bridge that is off
Two findings from review of this branch.

Awaiting the open instead of dismissing up front bought a window: on Linux
it is at least OPENER_GRACE, doubled when xdg-open fails and gio is tried.
If the container relays a second URL inside that window, the first open's
resolution blanked the second prompt -- losing a link that exists only in
the container's transcript, which is the failure "dismiss on success only"
was made to prevent. The slot already carried a `seq` for exactly this
reason; dismissal is now conditional on it.

`urlPromptRef` is written eagerly by the two functions that change the slot
rather than synced by an effect. That is load-bearing: an effect-synced
mirror lags state by a commit, and a promise microtask can resolve between
`setUrlPrompt` and React flushing passive effects -- so it answers "did a
newer prompt land?" wrong in precisely the window the guard exists for.
Dropping the functional updater also fixes `promptSeqRef.current += 1`
being mutated inside a state updater React is free to invoke twice.

The guard is a sibling function rather than an optional argument on
`dismissUrlPrompt`, because that function is passed by reference as
UrlToast's `onDismiss` and React would hand it a MouseEvent as its first
argument -- the seq check would fail and the close button would silently
stop working, with the types still assignable.

Separately, the sign-in hint was binary on which button leads, but "host
leads" covers both a live bridge and a fallback where nothing is set up to
catch the callback at all. In the second case the toast promised the bridge
would carry it and the login hung to its timeout. The target is now
three-state, the hint tells the truth in the fallback case and names the
control that fixes it, and the hook starts at `host-fallback` rather than
assuming a bridge it has not confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:12:22 -07:00
shadowdaoandClaude Opus 5 db648230ee chore: regenerate capabilities schema after dropping the opener grant
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m57s
Build App (Preview) / build-windows (pull_request) Successful in 5m54s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / build-linux (pull_request) Successful in 5m38s
Tracked build output; regenerated by the Tauri build from
capabilities/default.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:11:03 -07:00
shadowdaoandClaude Opus 5 5a452e7a2a security: drop opener:allow-open-url now that nothing calls it
default.json carried this grant with an explicit accepted residual risk:
a compromised webview could make the OS open an attacker-chosen http(s)
URL. It was accepted because it could not be narrowed -- WebLinksAddon
opens links Claude printed inside the container, which are arbitrary by
construction, so a host allowlist would have deleted the feature.

Now that every host-browser open routes through `open_url_external`, the
webview has no reason to reach the plugin directly, and the risk closes
rather than stays recorded. The plugin remains a dependency: macOS and
Windows still use it, through `OpenerExt::open_url`, whose desktop
implementation calls `crate::open::open` directly and is not gated by
capabilities at all (tauri-plugin-opener-2.5.3/src/lib.rs:60) -- verified
rather than assumed, since the whole point is that the Rust path keeps
working. What is removed is the webview's ability to reach the opener
without passing the Rust-side validation.

The census note in default.json is rewritten to match, and lib.rs's
grant-list test is updated deliberately, as its own assertion message
demands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:10:45 -07:00
shadowdaoandClaude Opus 5 5a09254538 fix: route every host-browser open through open_url_external
The Rust command existed but nothing called it. All four frontend call
sites still used `openUrl` from `@tauri-apps/plugin-opener`, so the
environment fix was inert and the three dialogs carried the same Linux bug
as the terminal: DockerInstallDialog's docs link, ClaudeAuthModal's sign-in
link and UpdateDialog's release link would all have reported success while
launching nothing.

`openUrlExternal` in tauri-commands.ts is now the single sink. There is no
platform branch: Linux gets the sanitized spawn, macOS and Windows reach
the same plugin as before but from Rust, and every platform picks up the
Rust-side re-validation, which matters because these URLs originate in an
untrusted container.

Comments in urlRelay.ts and urlDetector.ts that named `openUrl` as the sink
they guard are updated to match, and the two test files that mocked
`@tauri-apps/plugin-opener` now mock the command instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:09:57 -07:00
shadowdaoandClaude Opus 5 9297020688 fix: open host URLs with a clean environment (triple-c#34)
On Linux the app ships as a single AppImage, and the AppImage environment
leaks into everything it spawns. linuxdeploy's AppRun, linuxdeploy-plugin-gtk
and our own wayland fallback hook all export LD_LIBRARY_PATH, GTK_PATH,
GIO_MODULE_DIR and friends pointing inside $APPDIR, and main.rs sets
WEBKIT_DISABLE_DMABUF_RENDERER process-wide for the webview. A browser that
is already running shrugs this off, because xdg-open just hands the URL to
the existing process. A cold-launched one inherits the lot and dies before
painting -- with xdg-open still exiting 0, which is why this looked like the
button doing nothing at all.

`url_open` captures a pristine snapshot of the environment in main() before
any mutation runs, then hands children a repaired copy: a saved original is
restored where one exists, otherwise the process-start value is restored
where we changed it, otherwise only the colon-separated entries that live
under $APPDIR are dropped and the user's own are kept. Outside an AppImage
it is a no-op.

The command re-validates the URL in Rust rather than trusting the frontend,
because the URL originates in an untrusted container: http/https only, no
embedded credentials, no control characters or whitespace, length capped,
ASCII asserted before it reaches execvp, and error messages never echo the
input. Spawning is Command with explicit args and never a shell, trying
xdg-open then gio open.

No portal. org.freedesktop.portal.OpenURI would pull in a D-Bus client stack
for one call on the one platform where we ship self-contained, and it only
helps where a portal is running -- the same case where xdg-open already
works once the environment is clean. `gio open` as a second candidate
recovers most of the missing-MIME-association case for free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:08:20 -07:00
shadowdaoandClaude Opus 5 bf8094dbc4 fix: route sign-in links by what can actually catch the callback
`isAnthropicSignInUrl` made the container the default action for every
Anthropic sign-in link, justified by "the host has nothing to catch it
with". That was wrong in both directions. The host does have something --
the auth bridge -- and the container side is not a general browser at all
but Playwright's dashboard, whose packages and chromium are deliberately
not baked into the image. So the default pointed at the one path that is
uninstalled on a fresh project, on every platform, while the path that
works sat behind a switch.

The decision now lives in `useSignInOpenTarget`: a live auth bridge picks
the host, otherwise a container that can actually launch a browser picks
the container, otherwise the host. It resolves at mount rather than when a
URL arrives, so the buttons do not swap under a moving mouse, and it
re-decides on `auth-bridge-changed` so flipping the switch during a
hanging login takes effect. A bridge with port conflicts reads as not
live; an empty `active_ports` does not, since there is nothing to bridge
until the CLI binds its listener and that races the URL.

Both buttons still render either way -- this changes which one leads.
`sanitizeRelayUrl` is byte-for-byte unchanged, so the embedded copy in
web_terminal/terminal.html needs no matching edit.

The host "Open" path also failed silently: `dismissUrlPrompt()` ran before
`openUrl`, so the toast vanished and a rejected promise reached only the
devtools console. Dismissal now happens on success only, leaving "In
container" one click away after a failure, and the error surfaces through
the same toast the container path already used. On Linux this catch will
not fire for the common case -- `xdg-open` routinely exits 0 having done
nothing -- so it complements the AppImage environment fix rather than
replacing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:07:50 -07:00
shadowdaoandClaude Opus 5 90b7e4ccb2 fix: default the auth bridge on, and make the browser-view flag durable
A CLI running `claude login` inside the container binds a random ephemeral
loopback port and hands the provider a redirect pointing at it. The browser
is on the host, so the callback lands on a closed host port and the login
hangs with no diagnostic. The auth bridge is the thing that fixes this --
it mirrors container loopback listeners onto the same host port -- so
having it default to off made a hang the out-of-the-box experience.

`auth_bridge_enabled` now defaults to true through a
`default_auth_bridge_enabled()` serde helper, matching the shape already
used by `use_shared_auth_token`. Because the default is applied at
deserialisation, projects stored before the bridge existed pick it up too;
`migrate_from_value` writes neither flag, so nothing defeats it, and a
regression test pins that.

Separately, `BrowserViewManager.enabled` was in-memory only and the durable
`browser_view_enabled` field on the project record was never implemented.
Rather than sync the two, the cache is removed and the record becomes the
single home for the flag, mirroring how `AuthBridgeManager` already works.
`stop()` deliberately does not clear it, since container teardown and
migration reach that path and neither is the user changing their mind.
Durable does not mean auto-started: a restarted app reports enabled with
the viewer off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:06:09 -07:00
shadowdaoandClaude Opus 5 afe9d5cdb2 docs: correct Linux packaging in BUILDING.md
BUILDING.md listed AppImage, .deb and .rpm as build artifacts, but Linux
ships as AppImage only -- CI passes `--bundles appimage`, and the .deb and
.rpm were dropped because neither could self-update. A bare `npx tauri
build` still emits all three, since tauri.conf.json keeps "targets": "all"
to leave macOS and Windows untouched, so the table now marks which are
actually released rather than pretending the others do not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:01:29 -07:00
jknapp b59c6148ff Merge pull request 'Read a stopped container instead of claiming there is nothing to read' (#55) from fix/staleness-probe-stopped-container into main
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m29s
Build App / build-windows (push) Successful in 5m1s
Build App / compute-version (push) Successful in 4s
Build App / build-linux (push) Successful in 5m10s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 1m36s
2026-09-11 03:54:40 +00:00
shadowdaoandClaude Opus 5 95a78fe9a3 Take the review: cache the stopped probe, and never let it cost an answer
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m58s
Build App (Preview) / build-windows (pull_request) Successful in 5m9s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / build-linux (pull_request) Successful in 4m43s
Six findings, all real. The one that mattered: `getContainerStaleness` is
called from a `useEffect` that fires whenever the container settles, so
merely opening a stopped project's Overview now committed its whole writable
layer — 44 s on a real project, against ~3 s for the snapshot probe it
replaced. Shipping that would have traded one bad banner for a bad page.

A stopped container's writable layer cannot change, so the probe is exactly
cacheable: `STOPPED_MANIFEST_CACHE` keys on the container's `FinishedAt`,
which moves on every stop. Cold 2967 ms, warm 1 ms, measured. A live test
asserts the restart case as well as the hit, because a cache that failed to
invalidate would plan a migration against a filesystem the project no longer
has — verified by breaking the token and watching that assertion fail.

Skipping the probe for projects that are not stale looked like the cheaper
fix and is unsafe: the deltas would be empty while `probeSettled` stayed
true, and the migrate action in the project menu is not gated on the banner,
so the pre-flight would report nothing to copy while the backend was told to
copy nothing. That is the hazard `canMigrate`'s comment already warns about.
Not done, and written down so it is not tried again.

Also from the review:

- A failed commit no longer costs an answer the snapshot could have given.
  Before this feature a stopped project read its snapshot directly, so
  surfacing this error would have made the banner worse than it was — and
  the failure modes are where the fallback earns its keep: a full disk (the
  commit allocates the whole layer, the snapshot probe allocates nothing)
  and a 409 from a concurrent claim.
- The probe no longer commits while the project is claimed. The collision is
  not symmetric: the probe losing is a retryable `probe_error`, but
  `start_project_container` removes the old container with a hard `?`, so a
  remove that raced a commit would fail the user's Start with an opaque
  error. `stopped_probe_policy` reads `project_lock::held` and probes the
  snapshot instead, or defers with a message that says so.
- The cleanup-failure warning claimed the next probe of the same container
  would reclaim the leftover. Unique names made that false the moment they
  landed; it is `reap_probe_images` that collects it.
- The TS binding still called the command read-only, which is how the
  auto-refresh got added in the first place.
- CLAUDE.md still documented the stable `triple-c-probe-{cid}:latest` name
  this PR removed as unsafe.

548 unit tests, 752 frontend tests, 4 live-Docker tests. Clippy unchanged at
44 warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RSaoDLovVV2wmH4H8VVxz
2026-09-10 20:48:10 -07:00
shadowdaoandClaude Opus 5 307ea07409 Read a stopped container instead of claiming there is nothing to read
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m55s
Build App (Preview) / build-windows (pull_request) Successful in 5m53s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / build-linux (pull_request) Successful in 4m56s
A project that was merely stopped reported "This project has no container
or snapshot image yet, so there is nothing to compare against the base
image" — with its container sitting right there — and Update stayed
disabled. Start it and the checks passed, which is the tell: the staleness
probe had only two sources, a *running* container via `docker exec` or the
project's snapshot image.

The snapshot is not a checkpoint. `commit_container_snapshot` runs only
before a container is destroyed (a config-change recreate) or inside a
migration, never on stop, so a project in daily use for a year can have no
snapshot at all — and five of the six projects on the box that reported
this had none. Absence of a snapshot was being read as absence of anything
to inspect.

So probe the stopped container directly: commit its writable layer to a
throwaway image, probe that, drop it. A stopped container now also outranks
the snapshot, for the same reason a running one already did — the snapshot
lags it by everything installed since the last commit. `pick_probe_source`
is the whole decision and is unit-tested; the message it used to emit now
describes only the case it is true of, no container and no snapshot.

Two things found on the way, both documented in CLAUDE.md:

`bollard` never hands back the image id from a commit — its `Commit` model
deserialises "ID" while the daemon sends "Id" — so the probe image has to be
tagged, and a tagged image is dangling-proof and therefore invisible to
`sweep_orphaned_snapshots`, `reap_stale_migration_pins` and
`scrub_secrets_from_snapshots` alike. Without a reaper of its own a crashed
probe would leak a multi-gigabyte image that nothing could ever reclaim, so
`reap_probe_images` runs at startup beside `reap_probe_containers`, age-gated
for the same reason that one is: `reference=` is daemon-wide and a second
instance's live probe matches the glob.

It removes by tag, never by image id: a force removal by id untags an image
everywhere, which is how a first draft of the reaper test deleted an
unrelated `alpine:latest`. Names are unique per call rather than stable per
container, because container ids do not survive a recreate and two
overlapping probes would otherwise fight over one tag.

Verified against the container that reported the bug: 13,365 paths and an
apt delta of cmake, ffmpeg, libobs-dev, qt6-base-dev and nine more — the
migration payload the Update flow could not see. 546 unit tests plus three
live-Docker tests pass; no new clippy warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RSaoDLovVV2wmH4H8VVxz
2026-09-10 19:24:05 -07:00
jknapp 37bbf181c9 Merge pull request 'Give the mouse back, retire the follow controls, update Claude per session' (#54) from feat/mouse-release-retire-follow-update into main
Secret Scan / scan (push) Successful in 6s
Build App / build-macos (push) Successful in 2m53s
Build App / build-windows (push) Successful in 4m54s
Build App / compute-version (push) Successful in 12s
Build App / build-linux (push) Successful in 5m44s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 9s
Build Container / build-container (push) Successful in 17m57s
2026-09-08 23:43:34 +00:00
shadowdaoandClaude Opus 5 5d16b5713d Give BuildKit the host's network, so it can reach the runner's cache
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m45s
Build App (Preview) / build-windows (pull_request) Successful in 4m45s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 14m48s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / build-linux (pull_request) Successful in 7m38s
The multi-arch build needs the `docker-container` driver — the plain `docker`
driver cannot do linux/amd64+linux/arm64 — and that driver runs BuildKit in
its own container on Docker's default bridge. act_runner advertises
ACTIONS_CACHE_URL as an address the *job* container can reach, and nothing
teaches the BuildKit container about it. So the job could reach
192.168.1.126:40649 while the container actually making the cache request
could not.

That is also why no other workflow here hit this: it is the only one using
buildx. The rest make their cache calls from the job container act_runner set
up.

`no route to host` is EHOSTUNREACH — a firewall rejecting, not a missing route
— which is what a default firewalld zone does to traffic from the docker
bridge, and the runner registers under the stock RHEL/Fedora hostname.
Sharing the host's namespace sidesteps it: the cache address becomes local to
BuildKit. No effect on runners where this already worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 15:30:27 -07:00
shadowdaoandClaude Opus 5 c02c02cbfc Never fail a container build because the cache was unreachable
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build Container / build-container (pull_request) Successful in 10m21s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / build-linux (pull_request) Successful in 8m0s
Every layer of both architectures built. The job then died exporting to
act_runner's emulated GitHub Actions cache service, which it could not route
to: `GetCacheEntryDownloadURL ... dial tcp 192.168.1.126:40649: no route to
host`.

On a pull_request `push:` is false, so this job pushes nothing and the cache
is its only output — which means a network problem between the buildx
`docker-container` builder and the runner host threw away a complete,
successful validation of the Dockerfile on linux/amd64 and linux/arm64. A
cache is an optimisation; it must degrade to "slow", never to "red".

Only the exporter needs the flag. The import is already non-fatal — the build
ran all 37 layers after warning it could not read the cache.

This does not fix the routing itself, so builds stay uncached until that is
sorted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 12:48:41 -07:00
shadowdaoandClaude Opus 5 c0e4c87cec Give the mouse back, retire the follow controls, update Claude per session
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / build-linux (pull_request) Successful in 5m8s
Build Container / build-container (pull_request) Failing after 14m49s
Three things the terminal was getting wrong.

**A program that grabs the mouse and dies used to freeze the tab.** A TUI sets
DECSET ?1000/?1002/?1003; if it exits without resetting them, xterm keeps
routing clicks, drags and — under ?1003 — every pointer *move* to the PTY.
Text selection dies and escape bytes flood the prompt. The only exit was
closing the tab. `TerminalView` now reconciles a flag against
`term.modes.mouseTrackingMode` in the `term.write()` callback — the mode only
changes because the container printed a sequence, so one check per write
catches every transition with no polling — and `Ctrl+Shift+X` or a status-bar
button writes the resets back through `term.write`, never `sendInput`: the
reset belongs to xterm's parser, and a still-live TUI told about it would just
re-grab on its next repaint.

The control is in the status bar deliberately. Mouse tracking is the *normal*
state of htop, vim, lazygit and Claude Code, so a badge over the terminal
would be on screen for the whole life of those programs and would swallow
clicks aimed at their own top-right corner. `macOptionClickForcesSelection` is
also on now: xterm's force-select is Shift everywhere except macOS, where it
is Option and is gated behind that option, which defaults to false — so until
now Mac users had no way to select text while a program held the mouse.

**"Following" and "Jump to Current" are gone.** Claude Code draws on the
alternate screen, which has no scrollback, so `viewportY` always equalled
`baseY` and neither control could do anything. They did still work in bash
tabs; xterm's native follow covers that, and the per-write `scrollToBottom()`
went with them because it fought exactly that. What remains, on activate and
after a refit, now samples `viewportY >= baseY` *before* the fit, so opening
the Notes dock no longer yanks a reader to the tail.

**`claude update` runs before every Claude session, not just at container
start.** Containers here stop/start and often just keep running, so a
long-lived one never re-checked. Both copies take the same flock: the
entrypoint prints "container ready" only after its own update finishes, so
opening a tab immediately would otherwise run two updaters against the same
~/.claude/bin, with `|| echo` hiding a half-written install one line before
`exec claude` ran it.

This turns the non-Bedrock path from a bare argv into a `bash -c` wrapper, so
flags and session names are shell-interpolated now and must go through
`shell_quote_arg`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n
2026-09-08 11:02:33 -07:00
369 changed files with 107698 additions and 2826 deletions

No files matched your search

+155 -10
View File
@@ -5,9 +5,13 @@ name: Build App (Preview)
# sync.
#
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
# a push that breaks any of them fails here. build-app.yml used to do that job
# in parallel and publish nothing, which meant six OS builds per push and one
# unreachable set of bundles; it is now releases-only.
# a push that breaks any of them fails here. Its `test` job runs vitest and
# `cargo test` too, so a push that breaks either suite fails here as well.
# Previews are not code-signed (releases are, in build-app.yml): see the
# comment on the Windows job's "Build Tauri app" step.
# build-app.yml used to do the build-check job in parallel and publish nothing,
# which meant six OS builds per push and one unreachable set of bundles; it is
# now releases-only.
#
# The cost of the swap, stated plainly: one prerelease per PR commit that
# touches `app/**` — so the workflow prunes its own, keeping the newest
@@ -57,6 +61,19 @@ name: Build App (Preview)
# for real releases. See triple-c#32.)
env:
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
# any apt-get install stops at an interactive "Daemons using outdated
# 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`.
#
# 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 }}
# How many preview releases survive a run, newest first — including the one
@@ -73,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:
@@ -221,6 +241,116 @@ jobs:
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
echo "Release ${TAG} is id ${RELEASE_ID}"
# The test suites. Before this job CI ran neither: every check below lived on
# a developer's machine. The one that matters most is the app-command ACL
# census — `cargo test` is what re-checks the committed capability files and
# `gen/schemas/acl-manifests.json` against `generate_handler!`, and vitest's
# `capabilities.test.ts` is what keeps each window's code to the wrappers its
# capability grants. A command left ungranted builds fine and only fails at
# runtime ("not allowed by ACL"), so these tests are the merge-time guard.
#
# Independent of the release: no `needs`, so it runs alongside the three
# platform builds rather than in front of them, and a red test fails the PR
# check without holding up a preview someone may want to try anyway.
#
# Setup mirrors build-linux on purpose — the same Node, the same apt set
# (`cargo test` compiles the whole Tauri crate, so it needs WebKitGTK like
# a real build) and `npm ci` from the lockfile for the reasons given there.
test:
runs-on: ubuntu-latest
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
OLD_NODE_DIR=$(dirname "$(which node)")
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
hash -r
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
hash -r
fi
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
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 \
gstreamer1.0-plugins-good \
gstreamer1.0-pipewire \
gstreamer1.0-pulseaudio \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
libssl-dev \
libxdo-dev \
pkg-config \
build-essential \
curl
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: npm ci
# `npm run build` is `tsc && vite build`: the type check, and the
# `dist/` that `tauri::generate_context!` needs to exist before the Rust
# crate — and so `cargo test` — will compile at all.
- name: Type-check and build the frontend
working-directory: ./app
run: npm run build
- name: Frontend tests (vitest)
working-directory: ./app
run: npx vitest run
# `--locked`: test against the committed Cargo.lock, never a re-resolved
# one, for the same reason the frontend uses `npm ci`.
- name: Backend tests (cargo test)
working-directory: ./app/src-tauri
run: |
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]
@@ -244,7 +374,7 @@ jobs:
fi
if [ "$NEED_INSTALL" = true ]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo 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
@@ -267,10 +397,14 @@ jobs:
- name: Install system dependencies
run: |
sudo apt-get update
sudo 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 \
gstreamer1.0-plugins-good \
gstreamer1.0-pipewire \
gstreamer1.0-pulseaudio \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
@@ -555,7 +689,7 @@ jobs:
where node >nul 2>&1 && (
node --version
) || (
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.23.4/node-v22.23.4-x64.msi"
msiexec /i node-install.msi /quiet /norestart
del node-install.msi
)
@@ -571,7 +705,10 @@ jobs:
- name: Install Tauri CLI via cargo
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo install tauri-cli --version "^2"
rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which
rem the Linux and macOS jobs run, and kept identical to build-app.yml so
rem a preview is built by the same bundler as the release it previews.
cargo install tauri-cli --version "=2.11.0" --locked
- name: Fix npm platform detection
run: |
@@ -594,13 +731,21 @@ jobs:
- name: Build Tauri app
working-directory: ./app
# Previews are not code-signed: signing is metered, previews are built
# on every PR push, and a PR's workflow runs the PR's own code - so the
# signing secrets stay out of this workflow entirely. Releases are
# signed in build-app.yml.
#
# beforeBuildCommand is blanked through --config because the frontend
# was built in the step above. Not TAURI_CONFIG: the v2 CLI never
# reads that variable, and the inline one this step used to set was a
# no-op.
env:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
# See the matching comment on the Linux job's "Build Tauri app" step.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build
cargo tauri build --config "{\"build\":{\"beforeBuildCommand\":\"\"}}"
- name: Collect artifacts
run: |
+128 -37
View File
@@ -1,21 +1,37 @@
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"
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
# any apt-get install stops at an interactive "Daemons using outdated
# 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`.
#
# 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 }}
@@ -117,7 +133,7 @@ jobs:
fi
if [ "$NEED_INSTALL" = true ]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo 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)"
@@ -139,10 +155,14 @@ jobs:
- name: Install system dependencies
run: |
sudo apt-get update
sudo 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 \
gstreamer1.0-plugins-good \
gstreamer1.0-pipewire \
gstreamer1.0-pulseaudio \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
@@ -244,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: |
@@ -270,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
;;
*)
@@ -322,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 }}
@@ -411,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: |
@@ -437,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
;;
*)
@@ -595,7 +615,7 @@ jobs:
where node >nul 2>&1 && (
node --version
) || (
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.23.4/node-v22.23.4-x64.msi"
msiexec /i node-install.msi /quiet /norestart
del node-install.msi
)
@@ -611,7 +631,11 @@ jobs:
- name: Install Tauri CLI via cargo
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo install tauri-cli --version "^2"
rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which
rem the Linux and macOS jobs run: the Windows code-signing path (sign
rem command, NSIS uninstaller signing) was verified against it, and "^2"
rem would change it underneath the pipeline on any Tauri release.
cargo install tauri-cli --version "=2.11.0" --locked
- name: Fix npm platform detection
run: |
@@ -632,10 +656,31 @@ jobs:
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
npm run build
# Releases are signed with Azure Artifact Signing (scripts/windows-*.ps1).
# The setup fetches the signing client and a job-local .NET runtime, and
# writes the Tauri config holding the sign command, which "Build Tauri
# app" passes with --config. A missing secret fails here, before the build.
- name: Prepare code signing
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }}
ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }}
ARTIFACT_SIGNING_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_PROFILE_NAME }}
run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\windows-signing-setup.ps1
- name: Build Tauri app
working-directory: ./app
# The sign command comes in through --config, from the file "Prepare
# code signing" wrote. Not TAURI_CONFIG: the v2 CLI never reads that
# variable (the inline one this step used to set was a no-op), and
# "Verify signatures" is what caught it.
env:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
# Read by the signing dlib itself, never passed on a command line.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
rem Every Tauri bundler it downloads - candle.exe, light.exe and
@@ -650,7 +695,20 @@ jobs:
rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the
rem System32 originals, which makes the redirected view resolve. A
rem runner running as a normal user needs no such patch.
cargo tauri build --bundles msi,nsis
cargo tauri build --bundles msi,nsis --config "%TRIPLE_C_TAURI_SIGN_CONFIG%"
- name: Verify signatures
run: >-
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass
-File scripts\windows-verify-signatures.ps1
app\src-tauri\target\release\bundle\msi\*.msi
app\src-tauri\target\release\bundle\nsis\*.exe
# Tauri reports a failed sign command as just "failed to run powershell";
# windows-sign.ps1 keeps its own transcript, signtool /debug included.
- name: Show signing output
if: failure()
run: if exist .code-signing\sign-output.log type .code-signing\sign-output.log
- name: Collect artifacts
run: |
@@ -661,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 }}
@@ -685,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 `
@@ -722,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
@@ -732,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
+75 -7
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,11 +114,14 @@ 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
# only warn, but an export failure fails the build unless told not
# to -- the same setting build.yml already carries.
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max,ignore-error=true
+120 -7
View File
@@ -11,6 +11,17 @@ on:
paths:
- "container/**"
- ".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).
# 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
@@ -22,12 +33,92 @@ 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
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# Put BuildKit in the host's network namespace so it can reach
# act_runner's cache service.
#
# The `docker-container` driver — which the multi-arch build below
# requires, since the plain `docker` driver cannot do
# linux/amd64+linux/arm64 — runs BuildKit in its *own* container on
# Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as
# an address the *job* container can reach, and nothing teaches the
# BuildKit container about it: the job could reach
# 192.168.1.126:40649 while the container actually making the request
# could not, and the build died with `no route to host`.
#
# `no route to host` is EHOSTUNREACH — a firewall rejecting, not a
# missing route (a wrong address times out instead) — which is what a
# default firewalld zone does to traffic arriving from the docker
# bridge. Sharing the host's namespace sidesteps the question
# entirely: the cache address becomes local to BuildKit.
#
# No effect on runners where this already worked.
driver-opts: network=host
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
@@ -43,17 +134,39 @@ jobs:
username: shadowdao
password: ${{ secrets.GH_PAT }}
# Changes once a week, which invalidates the cached `apt-get upgrade`
# layer (container/Dockerfile) so later security fixes are picked up.
- name: Compute the apt refresh stamp
id: apt
run: echo "stamp=$(date -u +%G-W%V)" >> $GITHUB_OUTPUT
- name: Build and push container image
uses: docker/build-push-action@v5
with:
context: ./container
file: ./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-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
# 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: ${{ 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`
# builder `setup-buildx-action` creates could not route to it —
# every layer of both arches built, then the job died on
# `GetCacheEntryDownloadURL: no route to host` while exporting.
#
# On a pull_request `push:` above is false, so this job pushes
# nothing and the cache is its only output: failing it discarded a
# complete, successful validation of the Dockerfile for both
# architectures. A cache is an optimisation and must degrade to
# "slow", never to "red".
#
# The import is already non-fatal — the build ran all 37 layers after
# warning that it could not read the cache — so only the exporter
# needs the flag.
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max,ignore-error=true
+14
View File
@@ -1,6 +1,10 @@
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/
Screenshot*.png
code-review.md
@@ -13,3 +17,13 @@ screenshot_for_fix/
# Package files pulled in by ad-hoc verification runs.
*.deb
# Windows CI code signing (scripts/windows-signing-setup.ps1)
.code-signing/
# OpenTofu working state (aws/terraform/*): providers, local state, locks.
# The modules' .terraform.lock.hcl files are committed.
.terraform/
*.tfstate
*.tfstate.*
.terraform.tfstate.lock.info
+21 -5
View File
@@ -71,13 +71,29 @@ npm ci
npx tauri build
```
Linux ships as **AppImage only**. To match what CI produces, pass the bundle
explicitly:
```bash
npx tauri build --bundles appimage
```
The `.deb` and `.rpm` bundles were dropped — two more artifacts to build and
publish for an audience the AppImage already serves, and neither could
self-update. A bare `npx tauri build` still emits them, because
`tauri.conf.json` keeps `"targets": "all"` so that macOS and Windows are
untouched; they are not released and not tested.
Build artifacts are located in `app/src-tauri/target/release/bundle/`:
| Format | Path |
|------------|-------------------------------|
| AppImage | `appimage/*.AppImage` |
| Debian pkg | `deb/*.deb` |
| RPM pkg | `rpm/*.rpm` |
| Format | Path | Released |
|------------|-------------------------------|----------|
| AppImage | `appimage/*.AppImage` | yes |
| Debian pkg | `deb/*.deb` | no |
| RPM pkg | `rpm/*.rpm` | no |
`scripts/finalize-appimage.sh` post-processes the AppImage; see the Packaging
section of `CLAUDE.md` for why both of its steps are load-bearing.
## macOS
+436 -9
View File
@@ -73,6 +73,26 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
- **Copy paths** (`copyCleanup.ts`, `osc52.ts`). OSC 52 base64 is UTF-8 *bytes* — never hand
`atob()`'s output to the clipboard (that was the `’` mojibake); OSC 52 text then gets only
`sanitizeInvisibles`. Ctrl+Shift+C / "Copy trimmed" run `cleanSelection`, which also undoes
Claude Code's hard wraps (it wraps its own output and moves the cursor, so rows are never
`isWrapped`) using `term.cols` and the selection's start column. Ctrl+Shift+Alt+C stays raw.
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` →
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
runs with `allowNonHttpProtocols` on and dispatches `file:` to the viewer, so every other scheme
must be refused *there*. `viewer.html` must never carry an inline `<style>` — Tauri would add a
style nonce and CodeMirror's injected styles would stop applying. A missing or broken
`viewer.html` Vite entry is not caught by Tauri at build time — both Vite dev and Tauri's asset
lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and
all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input`
in `file_viewer/mod.rs` is the only thing pinning this.
**Claude Code wraps its own output, so xterm's `isWrapped` misses most wrapped paths.** A word
longer than the line (a `/tmp/claude-<uid>/…` scratchpad path) is broken mid-word and the rest
drawn on the next row with cursor moves. `lib/xtermLineJoin.ts` bridges that when given `cols`
(row runs to the edge, next row continues with path characters, and the two runs could not
have fit on one line — the last test is what keeps prose from being glued together).
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
and the editors reused by Project Home
@@ -161,6 +181,20 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
`auth_token_commands.rs`.
- **`file_viewer/`** — one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`,
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`
(`~/p` is `/home/claude/p`; a relative miss gets a second exec at `/home/claude/<p>`, `/tmp/<p>`),
polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container
user (spec §5 says why the archive API never writes to the target directory). Commands take
`window: tauri::Window`, gate on the label and act on the caller's own registry entry — no
viewer command accepts a path. Which window may *call* each command is the ACL's job: the
`file-viewer-*` capability grants exactly the five `viewer_*` commands (see `build.rs`).
- **`build.rs` + `src/command_census.rs`** — the build declares a Tauri `AppManifest` from the
`generate_handler!` list and refuses to build unless every command has exactly one bare
`allow-*` grant in the capability file its name says it belongs to. The parser and rules are
in `command_census.rs`, compiled into both the build script and the test build, so they are
unit-tested; `the_generated_app_manifest_matches_the_handler_list` reads back what tauri
embedded. Design: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
@@ -230,8 +264,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`docker/`** — Docker API layer using bollard:
- `client.rs` — Singleton Docker connection via `OnceLock`
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
- `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an
attached exec is opened; terminal sessions and the auth bridge both go through it.
- `exec.rs` — Docker's implementation of everything `host::ContainerTarget` does inside a
container. `create_attached_exec_spec()` is the **single** place an attached exec is opened.
- `image.rs` — Image build/pull with progress streaming
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
@@ -248,6 +282,101 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
users have migrated.
- **`host/`** — Where a project's container runs, and the only way into it. `ContainerTarget`
(`Docker`, and `Agent` — reached through the in-container `triple-c-agent` by `agent.rs`, per
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`; AWS planned —
`docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`)
wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`.
`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.
`aws_host.rs` is `ProjectHost::Aws` (an ECS Fargate task per project; a running task is
attached to with the token read back from its secret, never rotated; a task a failed start ran
is stopped again) and `tunnels.rs` the registry of open tunnels by project — an AWS project's
`ContainerTarget::for_project` is its tunnel's endpoint, its stored `container_id` is that
endpoint's URL, and the AWS death watch confirms exits through the registry. Shutdown closes
every tunnel (`for_project` is `None` while none is open; `for_project_waiting` covers the
background re-attach after an app restart). AWS SDK calls stay in `aws/` (`runtime.rs`,
`tasks.rs`, `workspace.rs`).
`agent.rs` reuses `docker/exec.rs`'s `OneshotOutput`, host-file reader and uid probe so the two
variants cannot drift; an `Agent` target's token never appears in `Debug` or error text.
**Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or
`get_docker()`** — `host::tests::no_container_interior_bollard_outside_the_host_layer` fails
the build on it. `lifecycle.rs`'s `ProjectHost` owns create/start/stop/find/remove and
discarding a project's persistent state; the project commands keep only host-neutral steps
(validation, status, sessions, auth bridge, syncs). `watch.rs` gives `container_watch` its
death reports and positive-exit check. `no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer`
fails on lifecycle calls or `ContainerTarget::docker(id)` in non-test code outside `host/` and
`docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session
(`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception
(`DOCKER_ONLY`); shrink it, never grow it.
- **`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 tier policies — both tiers span several
files, `policy_files` lists them — 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 (`policy_eval.rs`, test-only; `tofu_modules/tests.rs` also asks it whether the admin
tier allows every call `MODULE_CALLS` lists for the Terraform modules), 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"). Its `environment` tests apply the real modules as a role
holding only the admin files (`cargo build` first: the runner they start is the built app
binary), in a throwaway state bucket.
**A Fargate workspace's resources** (`workspace.rs`, `tasks.rs`, `runtime.rs`). **No project
data in a task definition (P1):** every workspace user has `ecs:Describe*` on `*`, so
`environment`, tags, labels and `overrides` are public to the account. The task definition carries
`TRIPLEC_HOST=aws`, `COLORTERM`, the three ownership tags, a fingerprint docker label (a hash,
`FINGERPRINT_LABEL`) and `valueFrom` references into the project's **one secret**
(`triple-c/<env>/<owner_key>/<project-id>`, JSON, 64 KB cap: files, repos, instructions, settings,
custom env, the agent token). The token rotates each start; a running task is attached to by reading
it back (the task role has no Secrets Manager access). **Anyone may register a revision into any
`triple-c-*` family** (IAM can't scope it), so a revision or task is reused only when it carries the
caller's own owner tag + this project's tag (+ the fingerprint), and `RunTask` always names the exact
`family:revision` ARN, never "latest". Names come only from `tofu_modules.rs`, built from `owner_key`
(`aws:userid` contains `:`). Workspace users can't deregister or delete task definitions
(`DeregisterTaskDefinition` is `*`-only in IAM): removal leaves revisions ACTIVE for an admin
(`aws/README.md`). Stop: the stop guard (`commands/stop_guard.rs`) dry-runs `triple-c-wip-push`
through the agent, closes the project's terminals and auth bridge (so nothing is written after the
push's snapshot), may push first (under an in-container `timeout`, since the agent does not kill an
exec the client gave up on), then writes `pushed` or `discard` to `/run/triple-c/stop-reason`
(root-owned; the entrypoint's TERM hook reads it, and the idle watcher writes `idle`) so the hook
doesn't push twice; the marker is cleared if StopTask fails. Claude Code's sandbox is forced off in
the generated settings (bubblewrap can't create namespaces on Fargate, verified live).
**Environments are OpenTofu modules** (`aws/terraform/{environment,user-onboard,permissions-boundary}`;
spec D2–D10). `tofu_modules.rs` embeds them and is the **only** place the shared names are built
(environment, `/triple-c/environments/<env>` record, owner key — `sha256("<region>:<aws:userid>")`,
12 hex, regional because role names are account-global — per-user roles, state bucket
`triple-c-tfstate-<account>-<region>`, state keys); its tests pin the HCL to those names, the
versions and tags, and `MODULE_CALLS` (every provider call) to the admin policy.
`tofu.rs` downloads the pinned OpenTofu (SHA-256 checked **before** extraction), writes a work
folder (`materialize`), builds tofu's environment from scratch (`child_env`: no inherited
`AWS_*`/`TF_*`, keys only in the environment) and runs `init` + `apply|destroy` **detached
(D10)**: the app binary re-executed with `--tofu-runner` (`main.rs` checks that first), in its
own session, logging to `apply.log`; liveness is the runner's `apply.lock`, never a PID; closing
the app never stops a run and the next start re-follows the log. `environments.rs` is detection
(records + state objects + locks; records are untrusted and capped; an empty state, which
`tofu destroy` leaves behind, is no deployment) and `ensure_state_bucket`;
`commands/environment_commands.rs` sequences ensure_tofu → ensure_state_bucket → materialize →
start → follow. **D2: strict tenancy gives each user their own EFS file system** (its policy
admits only their task role) from their onboarding module; the environment keeps only the shared
pieces. The app destroys only incomplete deployments (D6); a working environment is removed with
`tofu destroy` from an admin bundle (D5).
- **`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
@@ -260,6 +389,29 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
libraries a browser links against (see below) and the VPN tooling the `vpn_support_enabled`
toggle grants capability for (`iproute2`, `wireguard-tools`, `iptables`)
- **`TRIPLEC_HOST=aws` mode** (`entrypoint.sh`, the `aws_*` blocks; inert without it). There are no
bind mounts: `TRIPLEC_FILES` (JSON of base64 files) is decoded into `/tmp/.host-ssh` / `/tmp/.host-ca`
so the SSH merge and CA install run unchanged, and `TRIPLEC_REPOS` is kept in an unexported shell
variable; **both are unset before the agent-token block snapshots the environment** (that snapshot is
every terminal's env and the files hold private keys). Repos clone in the background after the agent
starts (terminals are usable before they arrive). Hybrid storage mounts EFS at `/mnt/triple-c-home`
and symlinks `~/.claude`, `~/.claude.json`, `~/.ssh` into it (mounting over `/home/claude` would hide
the image's tooling); root never follows those links outside the mount (`aws_hybrid_ssh_dir`). On
TERM the hook pushes unsaved work to a `triple-c/wip/<stamp>` branch via a temporary index (the
user's index is untouched; needs the task definition's `stopTimeout` raised, 120 s) unless the stop
marker says the app handled it. The idle watcher runs as root under a supervisor and signals the
entrypoint's own PID (not PID 1, which is docker-init), so idle and ECS stops share one trap.
Idle = the agent's `last_activity_unix` older than N minutes (open-but-silent attaches/tunnels
don't count; requests with `X-Triple-C-Background: 1` — the auth bridge's and the file viewer's polls — never do),
no `claude`, no scheduler job, no running clone, and no unsaved git work: with the WIP push on the
watcher pushes *before* signalling and stays up if that fails; with it off, dirty repos keep the
workspace up; activity during the watcher's push sends it back to watching. A repo the background
clone is still writing (`/run/triple-c-clone/repos` last says `cloning`, no `done`) is skipped by
`triple-c-wip-push` (dry run: `"cloning": true`). The stop trap is set before any background work
starts. An idle stop exits 75 (`aws::tasks::IDLE_EXIT_CODE`) so the death watch can tell it
from a crash (`classify_stop` → the `ContainerExited` toast).
**The `TRIPLEC_` env prefix is reserved** on both hosts (`RESERVED_ENV_PREFIXES`; `workspace.rs`
drops custom variables in it): the entrypoint's settings live there.
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
@@ -279,8 +431,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
approach converges on the worst state: a 400 MB browser present with its libraries gone.
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then stays resident **as root** waiting on a root-owned
`triple-c-keepalive` sleep (respawned if killed; SIGTERM trap so `docker stop` is immediate).
**Never hand the container's lifetime to a process the `claude` user owns** — it used to end in
`exec su … claude -c "exec sleep infinity"`, and any `pkill sleep` / `kill -9 -1` a Claude
session ran killed the whole container. `container_watch.rs` (one Docker `die`-event
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
@@ -413,6 +584,26 @@ container is created once by a very long function where a dropped capability is
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
so a project already at `true` gets no recreation at all on upgrade.
### Keeping Claude Code current
`claude update` runs in **two** places, and both are needed:
- `container/entrypoint.sh` runs it once per container start, before any session exists.
- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the
command every Claude session launches with, because containers use a stop/start model and a
long-lived one would otherwise never re-check.
Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than
failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is
not tidiness: the entrypoint prints "container ready" only after its own update finishes, so
starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two
updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a
friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success,
because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]`
argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell
string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are
deliberately untouched.
### Container Lifecycle
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
@@ -436,6 +627,63 @@ security update. Migration is the non-destructive way out; Reset is the destruct
bump: churn on the old base, and it would consume the "you should migrate" signal without
migrating. `get_container_staleness` surfaces it; `migrate_project_to_base` acts on it.
- **A missing lineage label means "unknown, probe instead", never "stale".**
- **The snapshot image is not a checkpoint — never read its absence as "nothing to inspect".**
`commit_container_snapshot` runs only before a container is destroyed (a config-change recreate)
or inside a migration. **Never on stop.** So a project in daily use for a year can legitimately
have no `triple-c-snapshot-{id}:latest` at all, and one that has is stale by everything installed
since. `pick_probe_source` therefore reads a *stopped* container directly — commit its writable
layer to a unique `triple-c-probe-*` image, probe that, drop it — and ranks it **above** the snapshot,
for the same reason a running container already outranked it. Assuming a snapshot existed is what
made a stopped, never-recreated project report "no container or snapshot image yet" with its
container sitting right there, and left Update disabled on the projects furthest behind.
- **`bollard` never gives you the image id back from a commit.** Its `Commit` response model
deserialises `"ID"`; the daemon sends `"Id"`, so `commit_container` returns `id: None` every time
(verified: bollard 0.18.1, Engine 29.6). Neither long-standing commit site notices because both
discard the response — but it means any commit you need a *reference* to has to be **tagged**.
- **A tagged leftover is the one orphan no sweep can reach, so the probe image has its own reaper.**
`sweep_orphaned_snapshots` collects `dangling` + `triple-c.managed=true`; `reap_stale_migration_pins`
and `scrub_secrets_from_snapshots` both filter `triple-c-snapshot-*`. A `triple-c-probe-*` image is
tagged and so matches none of them, which would make a crashed probe a permanent multi-gigabyte
leak with no UI to find it. `reap_probe_images` runs at startup beside `reap_probe_containers` and
is **load-bearing, not tidying** — it is also what makes the probe image's unscrubbed writable
layer acceptable. Two rules it earned the hard way:
- **Age-gate it** (`PROBE_REAP_MIN_AGE_SECS`, same as the container reaper). `reference=` is
daemon-wide, so a second copy of the app has live probe images matching the glob.
- **Remove by tag, never by image id.** A `force` removal by id untags an image *everywhere*; a
fixture that tagged `alpine:latest` into this namespace deleted the user's alpine that way.
- **Probe image names are unique per call, and must stay that way.** A stable per-container name was
tried: container ids do not survive a recreate, so most leftovers were stranded permanently, and
two concurrent probes fought over one tag — whichever finished first force-removed the image the
other was still reading, reporting a bogus `probe_error` on a healthy project. `get_container_staleness`
takes no `project_lock` claim (the migration banner needs it to answer *during* a migration), so
uniqueness is what makes overlapping probes safe.
- **The stopped-container probe is cached per stop, and that is not an optimisation you may drop.**
`getContainerStaleness` is called from a `useEffect` that fires whenever the container settles, so
merely opening a stopped project's Overview probes it. Uncached that is a `docker commit` of the
whole writable layer per visit — measured at 44 s on a real project, against ~3 s for the snapshot
probe it replaced. `STOPPED_MANIFEST_CACHE` is keyed on the container's `FinishedAt`, which is
exact rather than merely plausible: nothing can write to a stopped container's writable layer, and
`FinishedAt` moves on every stop. A live test asserts the restart case, because a cache that
failed to invalidate would plan a migration against a filesystem the project no longer has.
- **Do not "skip the probe when the project is not stale" to save that cost.** It was tried. The
deltas would be empty while `probeSettled` (`!probing && staleness && !probe_error`) stayed *true*,
which leaves the migrate action in the project menu enabled — that action is not gated on the
banner — so the pre-flight would report nothing to copy while the backend was told to copy
nothing. That is the exact hazard `ProjectHome.tsx`'s `canMigrate` comment already warns about.
- **A failed stopped-container probe falls back to the snapshot whenever one exists.** Before this
feature a stopped project read its snapshot directly, so surfacing a commit failure where the
snapshot could have answered would make the banner *worse* than it was — and the failure modes are
exactly the ones where the fallback earns its keep: a full disk (the commit allocates the whole
writable layer; the snapshot probe allocates nothing) and a 409 from a concurrent claim.
- **`get_container_staleness` never commits while the project is claimed.** It takes no
`project_lock` claim itself, deliberately — the banner has to answer *during* a migration — so it
reads `project_lock::held` instead and probes the snapshot rather than the container. The
collision is not symmetric: the probe losing is a retryable `probe_error`, but
`start_project_container` removes the old container with a hard `?`, so a remove that raced a
commit would fail the user's Start with an opaque error.
- **An image's `Created` is the image's own, not its tag's.** Tagging an existing image gives you
that image's age; BuildKit stamps `docker build` output with a fixed epoch. Only `docker commit`
stamps *now* — which is what real probe images do, and what any fixture for them must do.
- **`:latest` keeps pointing at the old lineage until the final commit.** That is what makes every
crash before that point self-heal — `start_project_container` just recreates from the old
snapshot. After the container swap, the new container's `triple-c.migration-state=in-progress`
@@ -468,6 +716,24 @@ security update. Migration is the non-destructive way out; Reset is the destruct
the last commit. Migration diffs two filesystem manifests instead, filtered through dpkg
ownership and presence-in-the-new-base. Measured on a real project, that turns 8,677 raw path
differences into 2 genuinely user-authored ones.
- **`/workspace` is reported two levels deep, never file by file** (`SUMMARISED_ROOTS`). One
`find -xdev` pass is piped through `awk`, which turns each depth-2 directory into one `S` entry
sized as the sum of the files beneath it (the same number the per-file sum gave the disk gate),
or a plain `d` if it holds no file at any depth (so empty scaffolds stay empty to the filter). A
user's worktrees and `node_modules` made a flat walk 8.6 MB — past the exec cap, so every
staleness check failed on start and stop — and pruning `node_modules`/`.git` by name only
reached 6.3 MB; now the whole manifest is ~1 MB. Summaries lose nothing: below `/workspace/<x>`
every per-path filter is vacuous, and `tar` copies a directory recursively. Three rules:
- **A failed `awk` is an error, never an empty `/workspace`.** The pipeline prints
`###SUMMARISED-OK` only on success and `check_manifest_complete` refuses a manifest without
it — otherwise the swap would destroy what the walk failed to see.
- **A summary is exempt from presence-in-the-base**, because it hides which paths are inside;
`--skip-old-files` on restore is the per-file answer. That rests on the base shipping nothing
under `/workspace` — pinned by the ignored live test
`the_base_ships_nothing_under_a_summarised_root`, and logged as a warning if a base breaks it.
- **Only `/workspace`.** The other roots stay per file: dpkg ownership and presence-in-the-base
are exactly what make their delta honest (`/usr/local/lib/node_modules`, base content in
`/opt`), and they are under 1 MB together.
### Authentication
@@ -501,9 +767,28 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`,
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not**
need an entry there — adding one is not required and none exists for any app command.
- **A new command needs three things:** `#[tauri::command]`, a `generate_handler!` entry in
`lib.rs`, and a bare `allow-<name-with-dashes>` entry in the one capability file for the
window that calls it — `viewer_*` commands in `capabilities/file-viewer.json`, everything else
in `capabilities/default.json`. `build.rs` declares a Tauri `AppManifest` from the handler list
(without one, tauri 2.11 does not apply the ACL to app commands at all) and fails `cargo
check`/`tauri build` on a missing, misspelled, duplicated or misfiled grant, a `deny-*`, or a
hand-written file under `permissions/`. `src/test/capabilities.test.ts` fails if code that runs
in a window imports a `tauri-commands.ts` wrapper that window is not granted. Only `_` becomes
`-` in the identifier; `permissions/autogenerated/` is generated and ignored, and
`gen/schemas/*.json` is regenerated by every build and committed.
- **A new window needs its own top-level `capabilities/*.json`; never `webviews`/`remote`;
never inline.** `build.rs` only vouches for what `src/command_census.rs` reads — a top-level
`capabilities/*.json` file with a `windows` list — so it refuses to build on anything tauri
would load that the census can't check: a capability under a subdirectory or written as
`.toml`/`.json5`, a `webviews` or `remote` key in a capability file (either widens grants past
what `windows` says), `app.security.capabilities` declared inline in `tauri.conf.json`/any
`tauri.<platform>.conf.json`/`TAURI_CONFIG`, or a tauri config in a format it can't parse
(JSON5, TOML). OS/editor junk (`.DS_Store`, `Thumbs.db`, swap files) is recognised and skipped
rather than refused. Each failure names the check that failed, not just "capabilities do not
match generate_handler!". **Known limit:** adding a new `tauri.<platform>.conf.json` to a tree
that has already been built once only takes effect on a clean build or in CI — cargo's
incremental build has no reason to notice a file that did not exist on the previous build.
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
- **Adding project state that changes the container?** `container_needs_recreation()` is entirely
**label-based** — it does not diff the container's env. If a new setting affects the container's
@@ -524,6 +809,35 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
`models/project.rs` for anything that should default to true.
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
- A new local window needs its own capability file (`capabilities/file-viewer.json` is the
model), and `lib.rs`'s `on_window_event` stays guarded on `label() == "main"`.
### Marketplace
- Code: models in `models/marketplace.rs`; host-side logic in `src/marketplace/` (`git.rs` gix
cache + pins, `catalog.rs` repo format, `auth.rs` credentials, `gh_login.rs`, `payload.rs`,
`sync.rs`); commands in `commands/marketplace_commands.rs`; UI in `components/marketplace/` and
`projects/home/config/MarketplaceSection.tsx`. Spec:
`docs/superpowers/specs/2026-09-27-marketplace-design.md`.
- **Tokens never enter containers.** Marketplaces are fetched on the host into
`<data_dir>/triple-c/marketplaces/<id>.git`; containers only ever receive a tar of pinned
files. Do not add a code path that passes a marketplace credential into an exec, env var, label
or file in a container.
- **Sync model:** after every container start (next to `sync_bedrock_credentials`) and on "Apply
now", the host builds the project's effective set (`global − disabled ∪ project`), then uploads
`payload.tar` **and the app-embedded script `src/marketplace/sync.sh`** (`include_str!`, not a
file in `container/`) to `~/.claude/triple-c/marketplace/incoming/` and runs it as `claude`,
once the entrypoint has finished (`READY_PROBE`: a root `triple-c-keepalive infinity` process
on current images, `su -s /bin/bash claude -c exec sleep infinity` on older ones — keep both).
The script is re-uploaded on every sync rather than baked into the image, so every
existing project always gets the version that matches the running app — `container/` is never
touched for this feature. The script only removes files and hook entries it recorded in
`~/.claude/triple-c/marketplace/state.json`; it must never overwrite or delete user-created
agents/skills/commands or user hooks. A sync failure must not fail the container start.
- Installs are **pinned** to a commit; nothing updates without the user accepting a diff. Pinned
commits are kept alive by `refs/triple-c/pins/*` in the cache.
- Marketplace changes need no container labels or recreation — they are applied by the sync, not
at create time.
## Secrets
@@ -556,9 +870,9 @@ nobody had reason to open. Fixtures are never live values; there is no case wher
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
deliberately out of scope — this is not a project backup.
live in the OS keychain instead: the shared Claude Code OAuth login, the model gateway's two keys,
and every marketplace account's token. Per-project settings, per-project secrets, and anything in
a project's Docker volumes are deliberately out of scope — this is not a project backup.
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
@@ -575,6 +889,27 @@ deliberately out of scope — this is not a project backup.
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
overwrite what the file actually has" treatment as the other three secrets
(`ExportedSecrets::marketplace_account_tokens`, keyed by account id). Marketplaces and install
lists themselves are ordinary `AppSettings` fields and travel with the settings replace, but are
**validated** on import the same way the add-marketplace/install commands validate them
(`validate_imported_marketplace_state`) — an import is untrusted input, not a trusted restore.
The preview warns whenever the import carries one or more **global hook installs or global
plugin installs**, in addition to the base-URL and custom-image warnings above: a hook runs
commands in every project container, and a plugin can carry its own hooks, MCP/LSP servers and
commands into one. In the Marketplace tab both kinds have a confirm step before they install
(`HookConfirmModal` lists a hook's commands, `PluginConfirmModal` lists everything a plugin
brings that runs); an import installs them without either, so the preview warning is the only
place that confirmation happens for an import.
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
@@ -679,6 +1014,25 @@ deliberately out of scope — this is not a project backup.
## 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
@@ -715,6 +1069,72 @@ verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMCl
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
### Windows code signing
Windows **releases** (`build-app.yml`) are signed with **Azure Artifact Signing**: the app binary,
the MSI, the NSIS installer and its uninstaller. Three scripts do it, and "Verify signatures"
fails the job if any of them is unsigned or untimestamped, so an unsigned installer cannot ship
quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml` must not
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. 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,
**each pinned by version and hash**, writes the dlib's `metadata.json`, and writes a Tauri
config file with `bundle.windows.signCommand` that the build passes as
`cargo tauri build --config`. Nothing is installed on the build VM. To bump
a pin, take the hash from nuget.org / the .NET `releases.json`, never from your own download.
- `scripts/windows-sign.ps1` is the sign command: `signtool sign /dlib` with SHA-256 and the
Microsoft timestamp server, retried. Credentials never reach a command line — the dlib reads
`AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` from the environment.
**It signs only an allowlist of what ships**: 5 signatures per release (the app binary twice,
because Tauri re-patches it between the MSI and NSIS bundles; the MSI; the NSIS installer;
and its uninstaller). Tauri also presents build-time tools, the WiX extension DLLs and NSIS
plugins, and signing those would more than double the metered count for no user-visible
benefit. If the app ever ships resource DLLs or sidecars, extend the
allowlist, or they will go out unsigned. Tauri reports a failed sign command only as
"failed to run powershell", so the script keeps a transcript (`.code-signing/sign-output.log`,
`signtool /debug` included), and the job prints it on failure.
- `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp on the
installers, and on the binaries **inside** the MSI (an administrative `msiexec /a` extract).
It deliberately does not check `target\release\triple-c.exe`: Tauri patches that file again
after packaging, so the loose copy is unsigned by design and is not what ships. For the NSIS
installer, which cannot be unpacked that way, it requires the signing log to show the app
binary and the uninstaller were signed.
Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`,
`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the
two Windows steps of `build-app.yml` that need them ("Prepare code signing" and "Build Tauri
app"), never by the preview workflow, never echoed, and never on a command line. The repo is
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), 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:
- **`metadata.json` excludes every credential but `EnvironmentCredential`.** The dlib uses
`DefaultAzureCredential`, whose chain ends in `InteractiveBrowserCredential`; the runners run as
SYSTEM, where that waits forever for a browser.
- **The sign command goes in through `--config`, never `TAURI_CONFIG`.** The v2 CLI does not read
that variable — it only sets it, for tauri-build — so a config put there is dropped without an
error. The Windows jobs set an inline `TAURI_CONFIG` for years and it never applied;
"Verify signatures" is what exposed it, and it is what would catch a regression.
- **The signing files and the job's `%TEMP%` live in the workspace.** The NSIS uninstaller is
written to `%TEMP%` and signed from inside 32-bit `makensis`, under 32-bit PowerShell; WOW64
redirects SYSTEM's own `%TEMP%` (under System32) for those processes but not for the x64
signtool, so they would disagree about where the file is. The workspace is under
`systemprofile\.cache`, which the VM junctions so both views resolve. makensis also ignores
the sign command's exit code for the uninstaller, so `windows-sign.ps1` logs every file it
signs and the verify step requires a logged signature under that temp directory.
- **The build VM is `WindowsBuilder` (VM 110 on the Proxmox host `pve4`)**, carrying both the
`winvm-builder` and `virtual-builder` runners in host mode. It has the Windows SDK's
`signtool` (10.0.26100) but no .NET — hence the job-local runtime.
## Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
@@ -722,3 +1142,10 @@ Frontend tests use Vitest with jsdom environment and React Testing Library. Setu
cd app
npx vitest run src/path/to/test.test.ts
```
CI runs both suites on every PR: the `test` job in `.gitea/workflows/build-app-preview.yml` does
`npm run build`, `npx vitest run` and `cargo test --locked`, in parallel with the platform builds.
It is the only place `cargo test` runs on merge, which matters most for the app-command ACL
census — an ungranted command compiles and only fails at runtime. `build-app.yml` (releases
from `main`) deliberately does not repeat it. The runner is root, so the few Rust tests that
exercise file permissions skip themselves there.
+66 -9
View File
@@ -15,6 +15,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
- [Permission Modes](#permission-modes)
- [Project Configuration](#project-configuration)
- [Shared Claude Authentication](#shared-claude-authentication)
- [Marketplace](#marketplace)
- [Opening URLs in Your Browser (URL Relay)](#opening-urls-in-your-browser-url-relay)
- [Browser Logins Inside the Container (Auth Bridge)](#browser-logins-inside-the-container-auth-bridge)
- [AWS Bedrock Configuration](#aws-bedrock-configuration)
@@ -243,7 +244,7 @@ Anthropic-backend project uses that token without its own login. See
│ │ │ │ │
│ │ └──────────────────────────────────────────────────┘ │
├─────────────┴────────────────────────────────────────────────────────┤
│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓ │
│ 2 project(s) · 1 running · 2 terminal(s) Notes │
└──────────────────────────────────────────────────────────────────────┘
```
@@ -268,8 +269,8 @@ Anthropic-backend project uses that token without its own login. See
- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs
open you get a welcome screen with Docker/image/project readiness checks.
- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the
**Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when
speech-to-text is enabled.
**🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the
**Notes** toggle; and the microphone button when speech-to-text is enabled.
---
@@ -469,6 +470,7 @@ replaces the old Full Permissions on/off switch.
| **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` |
| **Default** | Asks before each tool call | *(nothing — Claude Code's own default)* |
| **Accept Edits** | Auto-approves file edits; other tools still prompt | `--permission-mode acceptEdits` |
| **Auto** | A safety classifier approves routine actions and blocks risky ones, without prompting | `--permission-mode auto` |
| **Bypass** | Auto-approves every tool call | `--dangerously-skip-permissions` |
New projects start in **Default**. Projects created before permission modes existed keep behaving
@@ -480,12 +482,19 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that
> has Docker socket access or reaches services on your network. The Overview tab tells you whether
> the in-container sandbox is also on.
**Auto** sits between Accept Edits and Bypass: Claude Code's own classifier reviews each action,
lets routine work through and blocks things that look risky (such as destructive or
exfiltrating commands) — no prompts either way. Whether it is available depends on your Claude
Code account, model and backend (local and OpenAI-compatible backends usually won't
qualify). When it isn't available, Claude Code quietly starts in its normal prompting mode
instead.
### When a change takes effect
- **Terminals** — the mode is applied when a terminal is opened, so it affects terminals you open
from then on. A Claude session that is already running keeps the permissions it started with;
close the tab and open a new terminal to change it. The badge on each terminal tab shows the mode
that terminal was launched with (`plan`, `ask`, `edits`, `bypass`).
that terminal was launched with (`plan`, `ask`, `edits`, `auto`, `bypass`).
- **Resumed sessions** — a session resumed from the **Sessions** tab uses the project's current
mode.
- **Scheduled tasks** — these now honour the permission mode too (they previously always ran with
@@ -494,8 +503,10 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that
mode change to reach the scheduler.
> Scheduled tasks run headless (`claude -p`) and cannot answer a permission prompt. In any mode
> other than **Bypass**, a task may simply stop early when Claude Code asks for approval. Its run
> log records which mode it used.
> other than **Auto** or **Bypass**, a task may simply stop early when Claude Code asks for
> approval. In **Auto**, actions the classifier blocks are denied and the run carries on without
> them — but if Auto isn't available for the project's model or backend, Claude Code falls back
> to prompting and the task can stall the same way. Its run log records which mode it used.
---
@@ -763,6 +774,28 @@ is next started, at which point the same recreation clears the variable.
---
## Marketplace
The marketplace installs Claude Code **agents, skills, commands, hooks and plugins** from git repositories into your containers.
1. **Settings → Marketplace → Open Marketplace** opens the Marketplace tab.
2. **Add a marketplace**: on the Browse tab choose *Add marketplace* and enter an HTTPS clone URL, for example `https://github.com/shadowdao/triple-c-marketplace.git`. For a private repository, pick an account (see below). Triple-C checks it can read the repository before saving.
3. **Install**: select an item to see what it contains. Turn on **All projects** to install it everywhere (including projects you add later), or tick individual projects. A project can opt out of an "All projects" item by unticking it, or from **Project → Config → Marketplace**.
4. **Hooks** run shell commands, so Triple-C shows every command before installing one.
5. **When it applies**: on the container's next start, or straight away for running containers with **Installed → Apply now**. New Claude sessions pick it up; sessions already open keep what they loaded.
**Updates.** Every install is pinned to the commit it came from. When an item changes in its repository, the Installed tab shows *Update available*. Review the diff and accept to move the pin.
**Accounts (private repositories).** On the Accounts tab:
- *GitHub via gh* — if the GitHub CLI is installed and logged in on this computer, Triple-C uses it. If not, it runs `gh auth login` inside a running project's container and keeps only the resulting token in your OS keychain.
- *Access token* — any host (GitHub, Gitea, GitLab). The token is stored in your OS keychain.
Credentials never enter containers. If a private repository in a GitHub organisation cannot be read, the error explains the usual causes: the org has not approved the GitHub CLI, the token is not authorised for the org's SSO, or a fine-grained token belongs to a different owner.
**If an item is skipped**: Triple-C never overwrites an agent, skill or command file you created yourself. If one has the same name as a marketplace item, the sync skips it and the project's Config → Marketplace section says so.
---
## Opening URLs in Your Browser (URL Relay)
There is no browser inside the container and no screen to put one on. Any tool that tries to open
@@ -1224,9 +1257,31 @@ Programs inside the container can copy text to your host clipboard. When a conta
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
### Jump to Current
### Scrolling
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
Scrolling is the terminal's own: scroll up to read back and it holds position, scroll to the
bottom and it follows new output again. There is no follow toggle — an earlier **Following /
Paused** control and a **Jump to Current** button were retired once they stopped doing anything
useful, because Claude Code draws its interface on the alternate screen, which has no scrollback
for them to act on.
### When the mouse stops working
Some programs ask the terminal for the mouse, so that clicks and drags go to the program instead
of selecting text. If one of them exits without handing the mouse back, the terminal looks stuck:
you cannot select text, and stray characters can appear as you move the pointer.
A **🖱 Mouse captured — release** button appears in the status bar whenever a program holds the
mouse. Click it, or press **Ctrl+Shift+X**, to take the mouse back. Nothing is sent into the
container — only the terminal's own state is reset.
Note that holding the mouse is normal for programs like `htop`, `vim` and Claude Code itself, so
the button is showing most of the time you are in one. It is there for when a program exits
without handing the mouse back and the terminal is left stuck; releasing while a program is still
running just takes the mouse away from that program.
To select text *without* taking the mouse back, hold **Shift** while dragging — or **Option** on
macOS.
### Files
@@ -1327,7 +1382,9 @@ Scheduled runs use the project's [permission mode](#permission-modes) — they n
with `--dangerously-skip-permissions`. Because the mode travels into the container as an
environment variable, **stop and start the project** after changing it for the scheduler to see the
change. Remember that a headless run cannot answer a permission prompt, so in any mode other than
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
**Auto** or **Bypass** a task may stop early when Claude Code asks for approval (in Auto, blocked
actions are denied instead, unless Auto is unavailable and Claude Code falls back to
prompting); the run log records the mode
that was used.
### Creating Tasks
+4 -3
View File
@@ -114,7 +114,7 @@ progress modal.
## Permission Modes
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Five states,
mapped to CLI flags by `PermissionMode::cli_args()`:
| Mode | Serialized | CLI args passed to `claude` |
@@ -122,6 +122,7 @@ mapped to CLI flags by `PermissionMode::cli_args()`:
| **Plan** | `plan` | `--permission-mode plan` |
| **Default** | `default` | *(none)* |
| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` |
| **Auto** | `auto` | `--permission-mode auto` |
| **Bypass** | `bypass` | `--dangerously-skip-permissions` |
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to
@@ -528,10 +529,10 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Auto / Bypass segmented control |
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
+1 -1
View File
@@ -58,7 +58,7 @@ choice it never asked about.
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
file drag-drop, STT), the web terminal, and workspace backup.
---
+12 -8
View File
@@ -62,10 +62,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
- **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
- **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom.
- **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`** — the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working.
- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do.
- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on.
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store.
### bollard (Docker API)
@@ -134,7 +137,7 @@ Implementation gotchas for the terminal view and its global controls (merged in
│ │
│ User: claude (UID/GID remapped to match host) │
│ Entrypoint: UID/GID remap → SSH setup → git config → │
│ docker socket perms → sleep infinity │
│ docker socket perms → root keepalive wait │
└──────────────────────────────────────────────────────────┘
```
@@ -161,7 +164,7 @@ Terminal resize follows the same pattern: `ResizeObserver` detects container siz
Containers follow a **stop/start** model, not create/destroy:
1. **First start**: A new container is created with bind mounts, named volumes, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, rebuilds the scheduler crontab, then runs `sleep infinity` to keep the container alive.
1. **First start**: A new container is created with bind mounts, named volumes, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, rebuilds the scheduler crontab, then stays resident as root waiting on a root-owned `triple-c-keepalive` sleep — never a process the `claude` user can kill, since any `pkill sleep` a Claude session runs would otherwise stop the container.
2. **Terminal open**: `docker exec` launches `claude` with a PTY in the running container, with the permission-mode flags from `PermissionMode::cli_args()` (or `bash -l` for a shell session).
3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive.
4. **Restart**: `docker start` resumes the existing container — unless `container_needs_recreation()` finds a `triple-c.*` label that no longer matches the project's settings, in which case the container is committed to a snapshot image (`triple-c-snapshot-{projectId}:latest`), removed, and recreated from that snapshot. Installed tools survive; the named volumes are untouched.
@@ -183,7 +186,7 @@ host keychain secrets.
### Permission Modes
`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions`
`PermissionMode` (`models/project.rs`) is a five-state enum replacing the earlier `full_permissions`
boolean. It reaches Claude Code by two different routes:
| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler |
@@ -191,6 +194,7 @@ boolean. It reaches Claude Code by two different routes:
| `Plan` | `--permission-mode plan` | `plan` |
| `Default` | *(no flag)* | `default` |
| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` |
| `Auto` | `--permission-mode auto` | `auto` |
| `Bypass` | `--dangerously-skip-permissions` | `bypass` |
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
@@ -344,7 +348,7 @@ The entrypoint solves this by:
3. Relocating any existing system user/group that conflicts with the target UID/GID.
4. Fixing ownership of `/home/claude` after the change.
This runs as root in the entrypoint, then the final `exec su -s /bin/bash claude -c "exec sleep infinity"` drops to the remapped user.
This runs as root in the entrypoint. The entrypoint itself then stays running as root to keep the container alive; sessions reach the remapped `claude` user through `docker exec --user`. (Older images ended in `exec su -s /bin/bash claude -c "exec sleep infinity"`, which let any `claude` process stop the container by killing that sleep.)
### SSH Key Handling
@@ -471,7 +475,7 @@ triple-c/
│ │ ├── ProjectRow.tsx # Select-only sidebar row
│ │ ├── ProjectList.tsx # Sidebar project list
│ │ ├── AddProjectDialog.tsx # New-project dialog
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Auto/Bypass
│ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
│ │ # ClaudeInstructions, ClaudeCodeSettings —
+555 -190
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -13,6 +13,21 @@
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.4",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
+2351 -105
View File
File diff suppressed because it is too large. Load diff
+61
View File
@@ -21,17 +21,28 @@ bollard = "0.18"
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
bytes = "1"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
dirs = "6"
log = "0.4"
fern = { version = "0.7", features = ["date-based"] }
tar = "0.4"
# Unpacks the pinned OpenTofu release archive (aws/tofu.rs); every platform's
# archive is a .tar.gz. The version already in the tree.
flate2 = "1"
include_dir = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
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"
@@ -39,14 +50,64 @@ local-ip-address = "0.6"
argon2 = "0.5"
aes-gcm = "0.10"
zeroize = "1"
# WHATWG URL parsing for `url_open`'s re-validation of URLs arriving from the
# container. Already in the tree transitively (reqwest), and the point of
# using it rather than hand-rolling is parity with the frontend's `new URL()`.
url = "2"
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-secretsmanager = { version = "1.128.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
aws-sdk-efs = { version = "1.125.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
aws-sdk-cloudwatchlogs = { version = "1.165.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"] }
aws-sdk-s3 = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] }
# aws/tofu.rs: `setsid` puts the detached OpenTofu runner in its own session,
# and Cancel sends SIGINT to the runner process alone (never a group), which
# forwards it to `tofu`. Liveness is the runner's `apply.lock`, not a PID.
# Already in the tree through tokio and friends.
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# Linux only: WebKitGTK refuses getUserMedia until the embedder enables media
# streams and answers the permission request (see src/linux_media.rs). Same
# version tauri/wry already resolve, so this adds no second copy.
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0.2"
[dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
# 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"] }
# Only `aws::tofu_modules` tests: they parse the shipped OpenTofu modules.
hcl-rs = "0.20"
[build-dependencies]
tauri-build = { version = "2", features = [] }
# build.rs reads capabilities/*.json to cross-check them against generate_handler!.
serde_json = "1"
[features]
default = ["custom-protocol"]
+207 -2
View File
@@ -1,3 +1,208 @@
fn main() {
tauri_build::build()
//! Declares the Tauri `AppManifest`, so every app command is ACL-gated per window, and refuses
//! to build unless every registered command is granted in exactly one capability file — the
//! file whose `windows` the command's name says it belongs to. Without an app manifest, tauri
//! 2.11 skips the ACL for app commands entirely (`webview/mod.rs:1794`), so any local window
//! could call any command.
//!
//! Because the census can only vouch for what it reads, the build also stops on any capability
//! tauri would load that the census does not: anything in `capabilities/` other than a
//! top-level `*.json`, a `webviews`/`remote` key, `app.security.capabilities` in a tauri config
//! or `TAURI_CONFIG`, and any hand-written file under `permissions/`.
//!
//! The parser and the rules live in `src/command_census.rs`, which `cargo test` also compiles,
//! so they have unit tests. Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md`.
#[path = "src/command_census.rs"]
mod command_census;
use std::path::Path;
/// Stops the build. `what` names the check that failed, so a malformed capability file, a
/// stray entry or a hand-written permission does not read as a grant/handler mismatch.
fn fail(what: &str, problems: &[String], hint: &str) -> ! {
eprintln!();
eprintln!(
"{what} ({} problem{}):",
problems.len(),
if problems.len() == 1 { "" } else { "s" }
);
for p in problems {
eprintln!(" - {p}");
}
eprintln!();
eprintln!("{hint}");
eprintln!();
std::process::exit(1);
}
const LAYOUT_HINT: &str = "Every capability is a top-level capabilities/*.json file with a \
`windows` list and no `webviews` or `remote`, and no capability is declared anywhere else \
(tauri.conf.json, TAURI_CONFIG, subdirectories, .toml/.json5). The census in \
src/command_census.rs can only vouch for what it reads.";
fn file_name(path: &Path) -> String {
path.file_name()
.expect("a directory entry has a file name")
.to_string_lossy()
.into_owned()
}
fn main() {
// tauri-build already emits rerun-if-changed for `capabilities`, `permissions` and the
// tauri config files, and rerun-if-env-changed for TAURI_CONFIG.
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=src/command_census.rs");
let lib_rs = std::fs::read_to_string("src/lib.rs")
.expect("build.rs runs with CWD = src-tauri, so src/lib.rs must be readable");
let Some(commands) = command_census::registered_commands(&lib_rs) else {
fail(
"missing generate_handler! block",
&["src/lib.rs has no `generate_handler![ … ])` block to derive the AppManifest from"
.to_string()],
"build.rs derives the AppManifest from that block; see src/command_census.rs.",
);
};
check_tauri_config();
let files = read_capabilities();
let problems = command_census::check(&commands, &files);
if !problems.is_empty() {
fail(
"capabilities do not match generate_handler!",
&problems,
"Every app command needs exactly one bare `allow-<command-with-dashes>` grant: \
`viewer_*` commands in capabilities/file-viewer.json, everything else in \
capabilities/default.json. See src/command_census.rs.",
);
}
prune_permissions(&commands);
// `AppManifest::commands` takes `&'static [&'static str]` and the struct is `Copy`, so
// there is no owned form; leaking is fine in a process that exits right after.
let leaked: Vec<&'static str> = commands
.into_iter()
.map(|c| &*Box::leak(c.into_boxed_str()))
.collect();
let leaked: &'static [&'static str] = Box::leak(leaked.into_boxed_slice());
let attributes = tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(leaked));
if let Err(error) = tauri_build::try_build(attributes) {
// Same shape as `tauri_build::build()`: message on stdout, then exit 1.
println!("{error:#}");
std::process::exit(1);
}
}
/// tauri-build writes `permissions/autogenerated/<command>.toml` for every manifest command
/// and never deletes one, so a command removed from `lib.rs` would leave a permission a
/// capability could still reference (and the build would pass). Delete only the stale files:
/// tauri-build also emits `rerun-if-changed=permissions`, so regenerating everything would
/// touch every mtime and re-run this script — and recompile the crate — on every cargo
/// invocation. Anything else under `permissions/` is a hand-written grant the census cannot
/// see, so it is refused — except OS/editor junk (`.DS_Store`, swap files), which tauri never
/// loads and which is skipped (see `command_census::is_os_junk`).
fn prune_permissions(commands: &[String]) {
let root = Path::new("permissions");
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries {
let path = entry.expect("readable entry in permissions/").path();
if path.is_file() && command_census::is_os_junk(&file_name(&path)) {
// .DS_Store and friends: tauri never loads them, so they cannot grant anything.
continue;
}
if path.file_name().is_some_and(|n| n == "autogenerated") && path.is_dir() {
for file in std::fs::read_dir(&path).expect("readable permissions/autogenerated") {
let file = file.expect("readable entry").path();
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let live = file.extension().is_some_and(|e| e == "toml")
&& commands.iter().any(|c| c == stem);
if !live {
std::fs::remove_file(&file)
.unwrap_or_else(|e| panic!("cannot delete stale {}: {e}", file.display()));
}
}
} else {
fail(
"hand-written permission",
&[format!(
"{} is not generated by build.rs; hand-written permissions are not allowed \
(every grant is a bare allow-* string in a capability file)",
path.display()
)],
"permissions/ holds only build.rs's autogenerated/ directory. Delete the entry; \
an app command is granted by listing allow-<command> in a capability file.",
);
}
}
}
/// Every capability tauri will load, read the way the census reads it — or the build stops.
/// tauri-build loads `capabilities/**/*.{json,toml,json5}`; the census reads only top-level
/// `*.json`, so anything else tauri could load is refused rather than granted unchecked.
fn read_capabilities() -> Vec<command_census::CapabilityFile> {
let mut files = Vec::new();
let mut stray = Vec::new();
let mut invalid = Vec::new();
for entry in std::fs::read_dir("capabilities").expect("capabilities/ must exist") {
let path = entry.expect("readable entry in capabilities/").path();
let name = file_name(&path);
let is_file = path.is_file();
if is_file && command_census::is_os_junk(&name) {
continue;
}
if let Some(problem) = command_census::stray_capability_entry(&name, is_file) {
stray.push(problem);
continue;
}
let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
match command_census::capability_file(&name, &json) {
Ok(file) => files.push(file),
Err(problem) => invalid.push(problem),
}
}
stray.sort();
if !stray.is_empty() {
fail("stray entry in capabilities/", &stray, LAYOUT_HINT);
}
invalid.sort();
if !invalid.is_empty() {
fail("invalid capability file", &invalid, LAYOUT_HINT);
}
files.sort_by(|a, b| a.name.cmp(&b.name));
files
}
/// tauri also takes capabilities inline from `app.security.capabilities` in any of its config
/// files, or from the `TAURI_CONFIG` JSON that tauri-build merges over them. The census cannot
/// see those, so they are refused; so is a config in a format it cannot read (JSON5, TOML).
fn check_tauri_config() {
let mut problems = Vec::new();
for entry in std::fs::read_dir(".").expect("readable src-tauri/") {
let path = entry.expect("readable entry in src-tauri/").path();
let name = file_name(&path);
match command_census::tauri_config_file(&name) {
None => {}
Some(false) => problems.push(format!(
"{name}: the census reads JSON tauri configs only; a JSON5/TOML config could \
declare capabilities it cannot see"
)),
Some(true) => {
let json =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
problems.extend(command_census::tauri_config_problem(&name, &json));
}
}
}
if let Ok(json) = std::env::var("TAURI_CONFIG") {
problems.extend(command_census::tauri_config_problem("TAURI_CONFIG", &json));
}
problems.sort();
if !problems.is_empty() {
fail("capabilities declared outside capabilities/", &problems, LAYOUT_HINT);
}
}
+155 -5
View File
@@ -1,6 +1,6 @@
{
"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 — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `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` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (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 field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` anLine truncated
"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 field and Backup. Two commands stillLine truncated
"windows": ["main"],
"permissions": [
"core:event:allow-listen",
@@ -8,9 +8,159 @@
"core:webview:allow-internal-toggle-devtools",
"dialog:allow-open",
"dialog:allow-save",
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "http://*" }, { "url": "https://*" }]
}
"allow-check-docker",
"allow-check-image-exists",
"allow-build-image",
"allow-get-container-info",
"allow-list-projects",
"allow-add-project",
"allow-remove-project",
"allow-update-project",
"allow-start-project-container",
"allow-stop-project-container",
"allow-check-unsaved-work",
"allow-aws-task-log",
"allow-remove-aws-resources",
"allow-rebuild-project-container",
"allow-reconcile-project-statuses",
"allow-reconcile-aws-projects",
"allow-list-notes",
"allow-save-note",
"allow-delete-note",
"allow-get-container-staleness",
"allow-migrate-project-to-base",
"allow-confirm-migration",
"allow-rollback-migration",
"allow-get-migration-state",
"allow-set-auth-bridge-enabled",
"allow-get-auth-bridge-status",
"allow-set-browser-view-enabled",
"allow-get-browser-view-status",
"allow-check-browser-view-support",
"allow-install-browser-view-support",
"allow-install-browser-view-browser",
"allow-open-browser-view-popout",
"allow-close-browser-view-popout",
"allow-get-browser-view-popout-state",
"allow-set-browser-view-popout-always-on-top",
"allow-open-page-in-container-browser",
"allow-set-container-page-viewport",
"allow-get-container-page-state",
"allow-close-container-page",
"allow-set-browser-view-match-window",
"allow-get-browser-view-match-window",
"allow-acquire-claude-token",
"allow-submit-claude-token-code",
"allow-cancel-claude-token",
"allow-has-claude-token",
"allow-clear-claude-token",
"allow-sweep-claude-token-snapshots",
"allow-get-settings",
"allow-update-settings",
"allow-pull-image",
"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-aws-environments",
"allow-deploy-aws-environment",
"allow-update-aws-environment",
"allow-repair-aws-environment",
"allow-destroy-incomplete-aws-environment",
"allow-unlock-aws-state",
"allow-follow-aws-deployment",
"allow-cancel-aws-deployment",
"allow-push-aws-saved-state",
"allow-onboard-aws-user",
"allow-deploy-aws-permissions-boundary",
"allow-save-aws-admin-bundle",
"allow-detect-host-timezone",
"allow-export-settings",
"allow-preview-settings-import",
"allow-apply-settings-import",
"allow-open-terminal-session",
"allow-terminal-input",
"allow-terminal-resize",
"allow-close-terminal-session",
"allow-paste-image-to-terminal",
"allow-upload-host-file-to-terminal",
"allow-start-audio-bridge",
"allow-send-audio-data",
"allow-stop-audio-bridge",
"allow-list-container-files",
"allow-download-container-backup",
"allow-download-container-file",
"allow-upload-files-to-container",
"allow-read-container-file",
"allow-rename-container-path",
"allow-create-container-directory",
"allow-open-file-viewer",
"allow-aws-sso-refresh",
"allow-get-app-version",
"allow-check-for-updates",
"allow-check-image-update",
"allow-get-help-content",
"allow-open-url-external",
"allow-detect-install-options",
"allow-run-docker-install",
"allow-start-web-terminal",
"allow-stop-web-terminal",
"allow-get-web-terminal-status",
"allow-regenerate-web-terminal-token",
"allow-get-stt-status",
"allow-start-stt",
"allow-stop-stt",
"allow-build-stt-image",
"allow-pull-stt-image",
"allow-transcribe-audio",
"allow-get-gateway-status",
"allow-start-gateway",
"allow-stop-gateway",
"allow-check-gateway-health",
"allow-build-gateway-image",
"allow-pull-gateway-image",
"allow-set-gateway-api-key",
"allow-clear-gateway-api-key",
"allow-get-gateway-auth-token",
"allow-regenerate-gateway-auth-token",
"allow-list-claude-sessions",
"allow-list-container-capabilities",
"allow-list-scheduled-tasks",
"allow-add-scheduled-task",
"allow-update-scheduled-task",
"allow-get-scheduled-task-log",
"allow-set-scheduled-task-enabled",
"allow-run-scheduled-task-now",
"allow-remove-scheduled-task",
"allow-get-scheduler-notifications",
"allow-clear-scheduler-notifications",
"allow-list-marketplace-snapshots",
"allow-refresh-marketplaces",
"allow-add-marketplace",
"allow-update-marketplace",
"allow-remove-marketplace",
"allow-install-marketplace-item",
"allow-uninstall-marketplace-item",
"allow-set-global-item-disabled",
"allow-forget-marketplace-installs",
"allow-list-marketplace-updates",
"allow-marketplace-item-diff",
"allow-update-marketplace-item",
"allow-apply-marketplace-now",
"allow-get-marketplace-sync-report",
"allow-add-marketplace-token-account",
"allow-add-marketplace-gh-host-account",
"allow-start-marketplace-gh-container-login",
"allow-cancel-marketplace-gh-login",
"allow-test-marketplace-account",
"allow-remove-marketplace-account",
"allow-marketplace-gh-host-available"
]
}
@@ -0,0 +1,16 @@
{
"identifier": "file-viewer",
"description": "The terminal file viewer windows (`file-viewer-<n>`, opened by `open_file_viewer` on the app's own `viewer.html`). Same rules as `default.json`, including the layout checks: this file itself must stay a top-level `capabilities/*.json` with no `webviews` or `remote` key, or `build.rs` refuses the build rather than grant something the census cannot see. The five bare `allow-viewer-*` grants are the only app commands a viewer window can invoke: `build.rs` declares the AppManifest that makes tauri enforce that, and refuses any other bare grant in this file. The label gate inside `commands/file_viewer_commands.rs` is still what stops window A acting on window B's registry entry, because the ACL only decides which window may call. The rest of this file is the plugin-command surface a compromised viewer webview could reach, and it is the smallest one that lets the window work. `core:event:allow-listen`/`allow-unlisten` are for `file-viewer-goto` (Rust → this window; the viewer subscribes through `getCurrentWindow().listen`, because a bare `listen()` in *any* window receives an `emit_to`). `core:window:allow-destroy` is not optional: `getCurrentWindow().onCloseRequested` in @tauri-apps/api 2.11 makes Rust `prevent_close()` whenever a JS listener exists and then calls `destroy()` itself, so without this grant the window's X button does nothing once the unsaved-changes guard is installed. `allow-close` is deliberately absent — nothing calls it, and `destroy` is the only exit. No `set-title`/`set-focus`/`unminimize`: those are done from Rust when a second click targets an already-open file. `core:webview:allow-internal-toggle-devtools` is the same dev-only convenience `default.json` carries.",
"windows": ["file-viewer-*"],
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:window:allow-destroy",
"core:webview:allow-internal-toggle-devtools",
"allow-viewer-get-state",
"allow-viewer-choose-file",
"allow-viewer-read-file",
"allow-viewer-poll-file",
"allow-viewer-write-file"
]
}
+1 -1
View File
@@ -1 +1 @@
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-reLine 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-aws-task-log":{"identifier":"allow-aws-task-log","description":"Enables the aws_task_log command without any pre-configured scope.","commands":{"allow":["aws_task_log"],"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-aws-deployment":{"identifier":"allow-cancel-aws-deployment","description":"Enables the cancel_aws_deployment command without any pre-configured scope.","commands":{"allow":["cancel_aws_deployment"],"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":Line 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 — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `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` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (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 field and Backup. Two commands still take a host path over IPC as a string — the terminal drop and `download_container_backup` — and for those `validate_host_path` is the boundary rather than defence in depth. This file is the reviewed threat model of record, so keep this census accurate: a stale reference here is worse than none. Note that dragging files *into* the app is Line 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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+21 -13
View File
@@ -32,7 +32,7 @@
//! |---|---|
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
//! | Container stopped/died another way | poller's own `is_running` check → loop exits |
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
@@ -55,8 +55,7 @@ use tauri::{AppHandle, Emitter};
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use crate::docker::container::is_container_running;
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
use crate::host::{ContainerTarget, PROC_NET_OUTPUT_LIMIT};
use crate::storage::projects_store::ProjectsStore;
use proc_net::PortFamily;
@@ -178,7 +177,7 @@ impl AuthBridgeManager {
pub async fn start(
&self,
project_id: String,
container_id: String,
container: ContainerTarget,
app: AppHandle,
store: Arc<ProjectsStore>,
) {
@@ -200,12 +199,12 @@ impl AuthBridgeManager {
log::info!(
"Auth bridge: starting for project {} (container {})",
project_id,
&container_id[..container_id.len().min(12)]
&container.id()[..container.id().len().min(12)]
);
let poller = tokio::spawn(poll_loop(
project_id.clone(),
container_id,
container,
epoch,
app,
store,
@@ -276,7 +275,7 @@ impl AuthBridgeManager {
#[allow(clippy::too_many_arguments)]
async fn poll_loop(
project_id: String,
container_id: String,
container: ContainerTarget,
epoch: u64,
app: AppHandle,
store: Arc<ProjectsStore>,
@@ -301,7 +300,7 @@ async fn poll_loop(
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
break;
}
if !is_container_running(&container_id).await.unwrap_or(false) {
if !container.is_running().await.unwrap_or(false) {
log::info!(
"Auth bridge: container for project {} is no longer running — tearing down",
project_id
@@ -327,7 +326,10 @@ async fn poll_loop(
// bridge or stopping the container doesn't wait out an in-flight poll.
let discovery = tokio::select! {
_ = cancel.changed() => break,
res = exec_oneshot_limited(&container_id, cmd, PROC_NET_OUTPUT_LIMIT) => res,
// Background: on AWS this poll must not keep an idle workspace up.
res = container.exec_oneshot_background("claude", cmd, PROC_NET_OUTPUT_LIMIT) => {
res.map(|(out, _)| out)
}
};
match discovery {
@@ -338,7 +340,7 @@ async fn poll_loop(
// gateway/STT/web-terminal ports can be re-pointed while the
// bridge is running, and a stale reservation set is a hole.
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
if reconcile(&container_id, &discovered, &skip, &state).await {
if reconcile(&container, &discovered, &skip, &state).await {
emit_status(&app, &project_id, &state, true).await;
}
}
@@ -528,7 +530,7 @@ const MAX_CONFLICTS: usize = 32;
/// Bring the set of host listeners in line with what the container is currently
/// listening on. Returns whether anything the UI cares about changed.
async fn reconcile(
container_id: &str,
container: &ContainerTarget,
discovered: &BTreeMap<u16, PortFamily>,
skip: &HashSet<u16>,
state: &Arc<Mutex<BridgeState>>,
@@ -581,7 +583,7 @@ async fn reconcile(
);
continue;
}
match PortForward::bind(container_id.to_string(), port, family).await {
match PortForward::bind(container.clone(), port, family).await {
Ok(forward) => {
if st.conflicts.remove(&port).is_some() {
log::info!("Auth bridge: host port {} became available", port);
@@ -831,7 +833,13 @@ mod tests {
(45000u16..45200).map(|p| (p, PortFamily::V4)).collect();
let state = Arc::new(Mutex::new(BridgeState::default()));
reconcile("no-such-container", &discovered, &HashSet::new(), &state).await;
reconcile(
&ContainerTarget::docker("no-such-container"),
&discovered,
&HashSet::new(),
&state,
)
.await;
let mut st = state.lock().await;
assert!(
+18 -15
View File
@@ -11,7 +11,7 @@
//! and stdout wired to the TCP socket. `socat` ships in the container image.
//!
//! The exec plumbing itself is *not* reimplemented here: it comes from
//! [`crate::docker::exec::create_attached_exec`], the same helper the
//! [`crate::host::ContainerTarget::attach_exec`], the same helper the
//! interactive terminal sessions are built on.
//!
//! ## What the host listener is, and is not
@@ -56,14 +56,12 @@
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::Duration;
use bollard::container::LogOutput;
use crate::host::{AttachedExec, ContainerTarget, ExecChunk};
use futures_util::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::{JoinHandle, JoinSet};
use crate::docker::exec::{create_attached_exec, AttachedExec};
use super::proc_net::PortFamily;
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
@@ -143,13 +141,13 @@ impl Drop for PortForward {
}
impl PortForward {
/// Bind `port` on the host loopback and start proxying into `container_id`.
/// Bind `port` on the host loopback and start proxying into `container`.
///
/// The bind happens before the task is spawned, so an already-taken port is
/// reported to the caller as an error rather than disappearing into a
/// background task.
pub async fn bind(
container_id: String,
container: ContainerTarget,
port: u16,
family: PortFamily,
) -> Result<Self, std::io::Error> {
@@ -196,7 +194,7 @@ impl PortForward {
};
let target = family.socat_target(port);
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
let task = tokio::spawn(accept_loop(container, port, target, v4, v6));
Ok(Self {
port,
@@ -219,7 +217,7 @@ impl PortForward {
/// Accept on both loopback listeners until aborted. Dropping this future drops
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
async fn accept_loop(
container_id: String,
container: ContainerTarget,
port: u16,
target: String,
v4: TcpListener,
@@ -258,7 +256,7 @@ async fn accept_loop(
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
let _ = stream.set_nodelay(true);
conns.spawn(tunnel_connection(
container_id.clone(),
container.clone(),
target.clone(),
stream,
port,
@@ -288,7 +286,12 @@ async fn accept_optional(
///
/// Nothing is forwarded until that decision is made, so a refused request never
/// reaches the container at all — not even a `docker exec`.
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
async fn tunnel_connection(
container: ContainerTarget,
target: String,
mut stream: TcpStream,
port: u16,
) {
let head = match read_leading_bytes(&mut stream).await {
Ok(head) => head,
Err(e) => {
@@ -319,7 +322,7 @@ async fn tunnel_connection(container_id: String, target: String, mut stream: Tcp
}
// The bytes already off the socket go back on the wire first, byte-exact.
tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await
tunnel_connection_with_prelude(container, target, stream, port, head.into_buffer()).await
}
/// What the first bytes of an accepted connection turned out to be.
@@ -512,7 +515,7 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict {
/// back on the wire. Passing them here keeps the byte stream exact, rather than
/// re-serialising a parsed request.
pub async fn tunnel_connection_with_prelude(
container_id: String,
container: ContainerTarget,
target: String,
stream: TcpStream,
port: u16,
@@ -524,7 +527,7 @@ pub async fn tunnel_connection_with_prelude(
mut output,
mut input,
..
} = match create_attached_exec(&container_id, cmd, false).await {
} = match container.attach_exec(cmd, false).await {
Ok(e) => e,
Err(e) => {
log::warn!(
@@ -587,12 +590,12 @@ pub async fn tunnel_connection_with_prelude(
// Only stdout is payload. The exec is created with tty = false
// precisely so Docker demultiplexes these, keeping socat's stderr
// diagnostics out of the proxied byte stream.
Ok(LogOutput::StdOut { message }) => {
Ok(ExecChunk::StdOut(message)) => {
if host_tx.write_all(&message).await.is_err() {
break;
}
}
Ok(LogOutput::StdErr { message }) => {
Ok(ExecChunk::StdErr(message)) => {
log::debug!(
"Auth bridge: socat stderr for port {}: {}",
port,
+332
View File
@@ -0,0 +1,332 @@
//! 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::timeout::TimeoutConfig;
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 and stalled-stream protection are each service client's
/// defaults (the `default-https-client` stack every AWS crate here is built
/// with). Timeouts are ours: [`OPERATION_TIMEOUT`] / [`ATTEMPT_TIMEOUT`].
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())
.timeout_config(timeouts())
.time_source(SharedTimeSource::default());
if let Some(sleep) = default_async_sleep() {
builder = builder.sleep_impl(sleep);
}
builder.build()
}
/// Every call the app makes through the SDK is a control-plane call with a
/// small answer (ECS, Secrets Manager, EFS, IAM, SSM, CloudWatch Logs, STS,
/// and S3 reads of state objects capped at 2 KiB); OpenTofu talks to AWS
/// itself, not through these clients. Without a bound a half-open
/// connection would hang a start, a stop or the death watch for minutes.
/// The operation bound covers every retry; the attempt bound is one try.
pub const OPERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn timeouts() -> TimeoutConfig {
TimeoutConfig::builder()
.operation_timeout(OPERATION_TIMEOUT)
.operation_attempt_timeout(ATTEMPT_TIMEOUT)
.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());
}
#[test]
fn every_sdk_call_is_bounded() {
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));
let t = cfg.timeout_config().expect("a timeout config");
assert_eq!(t.operation_timeout(), Some(OPERATION_TIMEOUT));
assert_eq!(t.operation_attempt_timeout(), Some(ATTEMPT_TIMEOUT));
assert!(ATTEMPT_TIMEOUT < OPERATION_TIMEOUT);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+164
View File
@@ -0,0 +1,164 @@
//! 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,
},
/// `s3:CreateBucket` said `BucketAlreadyExists`: the state bucket's
/// name belongs to another AWS account. Never treated as success.
StateBucketTaken {
bucket: String,
},
/// An account limit (Fargate vCPUs, tasks, ...) refused the request.
/// Never retried; `message` says what to do.
QuotaExceeded {
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::StateBucketTaken { bucket } => write!(
f,
"The state bucket name \"{bucket}\" is already taken by another AWS account, so \
Triple-C can't keep OpenTofu state there"
),
AwsError::QuotaExceeded { 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 { .. }
));
}
}
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
//! AWS connection plumbing: an SDK config built from a saved
//! `HostConnection`, the caller's identity, the tier policies and the
//! permission check.
//!
mod connection;
pub mod environments;
mod error;
mod identity;
#[cfg(test)]
mod live_tests;
pub mod permissions;
pub mod policies;
#[cfg(test)]
mod policy_eval;
pub mod runtime;
pub mod tasks;
pub mod tofu;
pub mod tofu_modules;
pub mod workspace;
pub use connection::{sdk_config, sdk_config_for_keys};
pub use error::{classify_sdk_error, AwsError};
pub use identity::{caller_identity, CallerIdentity};
+560
View File
@@ -0,0 +1,560 @@
//! 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,
SecretsListSecrets,
}
impl Probe {
const ALL: [Probe; 3] = [
Probe::EcsListClusters,
Probe::SsmGetParametersByPath,
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),
"secretsmanager" => Some(Probe::SecretsListSecrets),
_ => None,
}
}
fn action(self) -> &'static str {
match self {
Probe::EcsListClusters => "ecs:ListClusters",
Probe::SsmGetParametersByPath => "ssm:GetParametersByPath",
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,
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.
//
// The connection's 30 s operation limit counts the retries' backoff
// too, which would cut those ten attempts to about six; the check
// gets its own, longer limit, and the UI says it can take a while.
let iam = aws_sdk_iam::config::Builder::from(cfg)
.retry_config(aws_config::retry::RetryConfig::adaptive().with_max_attempts(10))
.timeout_config(iam_check_timeouts())
.build();
SdkPermissionApi {
iam: aws_sdk_iam::Client::from_conf(iam),
ecs: aws_sdk_ecs::Client::new(cfg),
ssm: aws_sdk_ssm::Client::new(cfg),
secrets: aws_sdk_secretsmanager::Client::new(cfg),
}
}
}
/// The whole of one simulation, retries and backoff included. Ten adaptive
/// attempts with backoff capped at 20 s fit in two minutes.
pub const IAM_CHECK_OPERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
fn iam_check_timeouts() -> aws_config::timeout::TimeoutConfig {
aws_config::timeout::TimeoutConfig::builder()
.operation_timeout(IAM_CHECK_OPERATION_TIMEOUT)
.operation_attempt_timeout(super::connection::ATTEMPT_TIMEOUT)
.build()
}
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::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 three 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;
+640
View File
@@ -0,0 +1,640 @@
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; 3] = [
Probe::EcsListClusters,
Probe::SsmGetParametersByPath,
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("ec2:CreateVpc");
let report = run(&api);
assert_eq!(report.summary, Summary::WorkspaceUser);
assert_eq!(
verdict(&report, Tier::EnvironmentAdmin, "ec2:CreateVpc"),
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, "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(), ALL_PROBES.len());
// 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![("ec2:CreateVpc", throttled())];
let report = run(&api);
assert_eq!(report.method, Method::Simulated);
let r = row(&report, Tier::EnvironmentAdmin, "ec2:CreateVpc");
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::SecretsListSecrets);
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>;
}
/// D8: CloudFormation left the policies, so nothing probes it either.
#[test]
fn no_probe_is_cloudformation() {
assert_eq!(Probe::ALL, ALL_PROBES);
for p in Probe::ALL {
assert!(!p.action().starts_with("cloudformation:"), "{p:?}");
assert_eq!(Probe::for_action(p.action()), Some(p));
}
assert_eq!(Probe::for_action("cloudformation:DescribeStacks"), None);
}
#[test]
fn the_iam_check_gets_room_for_its_throttled_retries() {
let t = iam_check_timeouts();
assert_eq!(t.operation_timeout(), Some(IAM_CHECK_OPERATION_TIMEOUT));
assert!(IAM_CHECK_OPERATION_TIMEOUT > crate::aws::connection::OPERATION_TIMEOUT);
// Ten adaptive attempts, backoff capped at 20 s, fit inside it.
assert!(IAM_CHECK_OPERATION_TIMEOUT >= std::time::Duration::from_secs(9 * 20 / 2 + 20));
assert_eq!(t.operation_attempt_timeout(), Some(crate::aws::connection::ATTEMPT_TIMEOUT));
}
File diff suppressed because it is too large. Load diff
+303
View File
@@ -0,0 +1,303 @@
//! The test-only IAM evaluator the policy drift guards use (moved here from
//! `policies.rs` so the Terraform-module tests can ask it too). It is
//! deliberately small: the operators the shipped policies use and nothing
//! else; an unknown operator fails the test rather than being ignored. It is
//! not IAM -- `live_tests.rs` and the live runs are the proof.
use serde_json::Value;
use super::policies::{render, Check, ContextValue, Placeholders};
pub(crate) const AT: Placeholders = Placeholders {
region: "us-west-2",
account: "111122223333",
principal: "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_Dev_0123456789abcdef",
};
pub(crate) const ME: &str = "AROAEXAMPLEME:alice@example.com";
pub(crate) const SOMEONE_ELSE: &str = "AROAEXAMPLEOTHER:bob@example.com";
/// What [`Request::resolve`] turns IAM's `${*}` into: a literal `*` in a
/// pattern, which [`glob`] matches only against a `*`.
const LITERAL_STAR: char = '\u{e000}';
/// IAM wildcard match: `*` is any run of characters (including `:` and
/// `/`), `?` is exactly one; [`LITERAL_STAR`] is a plain `*`.
pub(crate) fn glob(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0, 0);
let (mut star, mut mark) = (None, 0);
while ti < t.len() {
if pi < p.len()
&& (p[pi] == '?' || p[pi] == t[ti] || (p[pi] == LITERAL_STAR && t[ti] == '*'))
{
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
mark = ti;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
pub(crate) fn parse(name: &str, json: &str) -> Value {
serde_json::from_str(json).unwrap_or_else(|e| panic!("{name} is not JSON: {e}"))
}
pub(crate) fn statements_of(doc: &Value) -> Vec<Value> {
doc["Statement"]
.as_array()
.expect("policy has no Statement array")
.clone()
}
pub(crate) fn effect(stmt: &Value) -> &str {
stmt["Effect"].as_str().unwrap_or("<no Effect>")
}
/// `Action` / `Resource` / condition values: a string or an array of them.
pub(crate) fn strings(v: &Value) -> Vec<String> {
match v {
Value::String(s) => vec![s.clone()],
Value::Array(a) => a
.iter()
.map(|x| x.as_str().expect("expected a string").to_string())
.collect(),
other => panic!("expected a string or array, got {other}"),
}
}
pub(crate) fn actions(stmt: &Value) -> Vec<String> {
strings(&stmt["Action"])
}
pub(crate) fn resources(stmt: &Value) -> Vec<String> {
strings(&stmt["Resource"])
}
pub(crate) fn sid(stmt: &Value) -> &str {
stmt["Sid"].as_str().unwrap_or("<no Sid>")
}
pub(crate) fn rendered(check: &Check) -> String {
render(check.resource, &AT)
}
/// The request: who calls, and whose the resource / request tag is.
#[derive(Clone, Copy)]
pub(crate) struct Request<'a> {
pub check: &'a Check,
/// `${aws:userid}` of the caller.
pub caller: &'a str,
/// What `OwnerId` context entries carry.
pub owner: &'a str,
}
impl Request<'_> {
pub(crate) fn context_value(&self, key: &str) -> Option<String> {
self.context_values(key).into_iter().next()
}
/// All values of a (possibly multi-valued) key.
pub(crate) fn context_values(&self, key: &str) -> Vec<String> {
self.check
.context
.iter()
.filter(|(k, _)| *k == key)
.map(|(_, v)| match v {
ContextValue::OwnerId => self.owner.to_string(),
ContextValue::Literal(l) => render(l, &AT),
})
.collect()
}
/// Resolves `${…}` policy variables the way the simulator does: from
/// the request context. An unresolvable variable makes the string
/// match nothing (`None`).
pub(crate) fn resolve(&self, s: &str) -> Option<String> {
let mut out = String::new();
let mut rest = s;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let end = rest[start..].find('}').expect("unterminated ${") + start;
let name = &rest[start + 2..end];
let value = if name == "*" {
Some(LITERAL_STAR.to_string())
} else if name == "aws:userid" {
Some(self.caller.to_string())
} else {
self.context_value(name)
};
out.push_str(&value?);
rest = &rest[end + 1..];
}
out.push_str(rest);
Some(out)
}
}
/// Statements that name the action and one of whose resources matches
/// the rendered resource.
pub(crate) fn matching<'s>(stmts: &'s [Value], req: &Request) -> Vec<&'s Value> {
let resource = rendered(req.check);
stmts
.iter()
.filter(|s| actions(s).iter().any(|a| glob(a, req.check.action)))
.filter(|s| {
resources(s)
.iter()
.filter_map(|r| req.resolve(r))
.any(|r| glob(&r, &resource))
})
.collect()
}
/// A deliberately small IAM condition evaluator: the operators the
/// shipped policies use, and nothing else (an unknown operator fails the
/// test rather than being ignored).
pub(crate) fn conditions_hold(stmt: &Value, req: &Request) -> bool {
let Some(cond) = stmt.get("Condition") else {
return true;
};
let cond = cond.as_object().expect("Condition is an object");
cond.iter().all(|(op, entries)| {
let entries = entries.as_object().expect("condition block is an object");
// ForAllValues: every value of the (multi-valued) key matches
// one of the policy's values. Vacuously true when the key is
// absent, which is why statements pair it with a required key.
if let Some(base) = op.strip_prefix("ForAllValues:") {
assert_eq!(
base,
"StringEquals",
"{}: unsupported ForAllValues operator",
sid(stmt)
);
return entries.iter().all(|(key, want)| {
let allowed: Vec<String> = strings(want)
.iter()
.filter_map(|p| req.resolve(p))
.collect();
req.context_values(key).iter().all(|v| allowed.contains(v))
});
}
if op == "Null" {
return entries.iter().all(|(key, want)| {
let absent = req.context_value(key).is_none();
match want.as_str() {
Some("true") => absent,
Some("false") => !absent,
_ => panic!("{}: Null takes \"true\" or \"false\"", sid(stmt)),
}
});
}
let (base, if_exists) = match op.strip_suffix("IfExists") {
Some(b) => (b, true),
None => (op.as_str(), false),
};
// A negated operator holds when the key is absent, and
// otherwise when no value matches.
let (matches, negated): (fn(&str, &str) -> bool, bool) = match base {
"StringEquals" => (|p, v| p == v, false),
"StringLike" | "ArnLike" => (glob, false),
"StringNotEquals" => (|p, v| p == v, true),
"StringNotLike" => (glob, true),
other => panic!("{}: unsupported condition operator {other}", sid(stmt)),
};
entries
.iter()
.all(|(key, want)| match req.context_value(key) {
None => if_exists || negated,
Some(value) => {
let any = strings(want)
.iter()
.filter_map(|p| req.resolve(p))
.any(|p| matches(&p, &value));
any != negated
}
})
})
}
/// Allowed by some Allow and by no Deny among `stmts`.
pub(crate) fn evaluate_in(stmts: &[Value], req: Request) -> bool {
let applies = |s: &&Value| conditions_hold(s, &req);
let allowed = matching(stmts, &req)
.into_iter()
.filter(|s| effect(s) == "Allow")
.any(|s| applies(&s));
let denied = matching(stmts, &req)
.into_iter()
.filter(|s| effect(s) == "Deny")
.any(|s| applies(&s));
allowed && !denied
}
#[cfg(test)]
mod tests {
use super::*;
use ContextValue::Literal;
#[test]
fn glob_behaves_like_iam_wildcards() {
assert!(glob("ecs:Describe*", "ecs:DescribeClusters"));
assert!(!glob("ecs:Describe*", "ecs:ListClusters"));
assert!(glob(
"arn:aws:ecs:*:*:task/triple-c-*/*",
"arn:aws:ecs:us-west-2:1:task/triple-c-x/abc"
));
assert!(!glob(
"arn:aws:ecs:*:*:task/triple-c-*/*",
"arn:aws:ecs:us-west-2:1:task/other/abc"
));
assert!(glob("a?c", "abc"));
assert!(!glob("a?c", "ac"));
// `${*}` (resolved to LITERAL_STAR) matches only a real `*`.
let literal = format!("arn:aws:ec2:*:*:{LITERAL_STAR}/{LITERAL_STAR}");
assert!(glob(&literal, "arn:aws:ec2:us-west-2:1:*/*"));
assert!(!glob(&literal, "arn:aws:ec2:us-west-2:1:elastic-ip/eipalloc-1"));
}
#[test]
fn for_all_values_is_vacuous_on_an_absent_key() {
let stmt: Value = serde_json::json!({
"Sid": "T", "Effect": "Allow", "Action": "x:Y", "Resource": "*",
"Condition": { "ForAllValues:StringEquals": { "aws:TagKeys": ["a", "b"] } }
});
let check = |context| Check {
action: "x:Y",
resource: "*",
context,
write: true,
};
let holds = |c: &Check| {
conditions_hold(
&stmt,
&Request {
check: c,
caller: ME,
owner: ME,
},
)
};
assert!(holds(&check(&[])));
assert!(holds(&check(&[
("aws:TagKeys", Literal("a")),
("aws:TagKeys", Literal("b"))
])));
assert!(!holds(&check(&[
("aws:TagKeys", Literal("a")),
("aws:TagKeys", Literal("c"))
])));
}
}
+463
View File
@@ -0,0 +1,463 @@
//! What the AWS host asks AWS around a task, besides running it
//! (`tasks.rs`) and preparing its resources (`workspace.rs`): the
//! environment's record, whether the caller is onboarded, the agent token of
//! a running task (P2), waiting for a new access point, and removing a
//! project's resources.
//!
//! Like the other `aws/` modules, the decisions are made by the functions
//! here over a small trait ([`RuntimeApi`]) so a fake exercises them; the SDK
//! calls are in [`SdkRuntimeApi`].
use std::future::Future;
use std::time::Duration;
use aws_config::SdkConfig;
use aws_sdk_secretsmanager::error::ProvideErrorMetadata;
use super::environments::{knowable, parse_environment_record, EnvironmentInfo, Knowable};
use super::error::{classify_sdk_error, AwsError};
use super::tasks::{find_project_task, TaskApi, STOP_REASON};
use super::tofu_modules::{
access_point_root, environment_cluster_name, environment_parameter_name, project_secret_name, task_family,
user_file_system_token, user_role_names,
};
use super::workspace::WorkspaceApi;
/// The secret key holding the agent token (Task 4's payload).
pub const AGENT_TOKEN_KEY: &str = "TRIPLEC_AGENT_TOKEN";
/// A project secret is at most 64 KiB; anything bigger is not ours.
const MAX_SECRET_READ: usize = 70_000;
fn other(message: impl Into<String>) -> AwsError {
AwsError::Other {
message: message.into(),
}
}
pub trait RuntimeApi: Send + Sync {
/// `ssm:GetParameter` of an environment's record; `None` when absent.
fn environment_record(
&self,
env: &str,
) -> impl Future<Output = Result<Option<String>, AwsError>> + Send;
/// `iam:GetRole`.
fn role_exists(&self, name: &str) -> impl Future<Output = Result<Knowable, AwsError>> + Send;
/// `secretsmanager:GetSecretValue`'s `SecretString`.
fn secret_string(&self, name: &str) -> impl Future<Output = Result<String, AwsError>> + Send;
/// The access point's `LifeCycleState`; `None` when it is gone.
fn access_point_state(
&self,
id: &str,
) -> impl Future<Output = Result<Option<String>, AwsError>> + Send;
/// Delete at once (`ForceDeleteWithoutRecovery`, ruling R10): a secret
/// left pending deletion would make the project's next start fail, and
/// the app rebuilds it from local data. A secret that does not exist is
/// success.
fn delete_secret(&self, name: &str) -> impl Future<Output = Result<(), AwsError>> + Send;
/// A missing access point is success.
fn delete_access_point(&self, id: &str) -> impl Future<Output = Result<(), AwsError>> + Send;
}
// ------------------------------------------------------------ environment
/// The environment's record, read with one `GetParameter` and validated.
pub async fn read_environment<R: RuntimeApi>(
rt: &R,
env: &str,
account: &str,
region: &str,
) -> Result<EnvironmentInfo, AwsError> {
let value = rt.environment_record(env).await?.ok_or_else(|| {
other(format!(
"The environment \"{env}\" was not found in {region}. It may have been removed; \
check Settings → Hosts."
))
})?;
parse_environment_record(env, &value, account, region).map_err(|e| {
other(format!(
"The record of environment \"{env}\" in {region} is not usable: {e}"
))
})
}
/// Whether the caller's task role exists in the environment: the onboarding
/// signal a workspace user can read. A denied lookup is not an answer, so
/// it is let through (RunTask will say what is wrong).
pub async fn check_onboarded<R: RuntimeApi>(
rt: &R,
env: &str,
owner_key: &str,
) -> Result<(), AwsError> {
let (task_role, _) = user_role_names(env, owner_key);
match rt.role_exists(&task_role).await? {
Knowable::No => Err(other(format!(
"You have not joined the environment \"{env}\" yet. Join it in Settings → Hosts \
(or ask an administrator to onboard you), then start the project again."
))),
Knowable::Yes | Knowable::Unknown => Ok(()),
}
}
/// The agent token in a project secret's JSON. The error never echoes the
/// secret.
pub fn agent_token_from_secret(json: &str) -> Result<String, String> {
if json.len() > MAX_SECRET_READ {
return Err("The project secret is larger than a project secret can be".into());
}
let v: serde_json::Value = serde_json::from_str(json)
.map_err(|_| "The project secret is not a JSON object".to_string())?;
v.get(AGENT_TOKEN_KEY)
.and_then(|t| t.as_str())
.filter(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_graphic()))
.map(str::to_string)
.ok_or_else(|| "The project secret holds no agent token".to_string())
}
/// The token a running task of this project started with (P2: the caller
/// may read their own secret; `prepare_workspace` wrote it before RunTask).
pub async fn read_agent_token<R: RuntimeApi>(
rt: &R,
env: &str,
owner_key: &str,
project_id: &str,
) -> Result<String, AwsError> {
let name = project_secret_name(env, owner_key, project_id);
let json = rt.secret_string(&name).await?;
agent_token_from_secret(&json).map_err(other)
}
/// After a start this call ran is up and its agent answered with `ours`:
/// make sure the secret still holds `ours`. A start that lost the RunTask
/// race (another machine, the same moment) rotated the token in the secret
/// before it found this task, and every later attach would read a token the
/// task never saw. Returns whether the secret had to be put back.
pub async fn ensure_agent_token<W: WorkspaceApi, R: RuntimeApi>(
work: &W,
rt: &R,
env: &EnvironmentInfo,
owner_user_id: &str,
owner_key: &str,
project_id: &str,
ours: &str,
) -> Result<bool, AwsError> {
let name = project_secret_name(&env.name, owner_key, project_id);
let json = rt.secret_string(&name).await?;
if agent_token_from_secret(&json).ok().as_deref() == Some(ours) {
return Ok(false);
}
let mut v: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&json)
.map_err(|_| other("The project secret is not a JSON object"))?;
v.insert(AGENT_TOKEN_KEY.to_string(), serde_json::Value::String(ours.to_string()));
let repaired = serde_json::to_string(&v).map_err(|e| other(e.to_string()))?;
let tags = super::workspace::workspace_tags(owner_user_id, project_id, &env.name);
work.put_project_secret(&name, &env.kms_key_arn, &repaired, &tags).await?;
Ok(true)
}
/// How long a fresh access point may take to become `available`.
#[derive(Debug, Clone, Copy)]
pub struct PollTiming {
pub interval: Duration,
pub timeout: Duration,
}
impl Default for PollTiming {
fn default() -> Self {
PollTiming {
interval: Duration::from_secs(2),
timeout: Duration::from_secs(90),
}
}
}
/// Wait until the access point is `available` (a task mounting one that is
/// still `creating` fails to start).
pub async fn wait_access_point<R: RuntimeApi>(
rt: &R,
id: &str,
timing: PollTiming,
) -> Result<(), AwsError> {
let deadline = tokio::time::Instant::now() + timing.timeout;
loop {
match rt.access_point_state(id).await?.as_deref() {
Some("available") => return Ok(()),
Some("creating") | Some("updating") => {}
Some(s) => {
return Err(other(format!(
"The project's EFS access point {id} is {s}; start the project again"
)))
}
None => {
return Err(other(format!(
"The project's EFS access point {id} disappeared; start the project again"
)))
}
}
if tokio::time::Instant::now() + timing.interval > deadline {
return Err(other(format!(
"The project's EFS access point {id} did not become available in time; \
start the project again in a minute"
)));
}
tokio::time::sleep(timing.interval).await;
}
}
// ------------------------------------------------------------------ ARNs
/// The cluster *name* of `arn:...:task/<cluster>/<id>` (ECS accepts a name
/// wherever it takes a cluster).
pub fn cluster_of_task(task_arn: &str) -> Option<&str> {
let (_, rest) = task_arn.split_once(":task/")?;
let (cluster, id) = rest.split_once('/')?;
(task_arn.starts_with("arn:") && !cluster.is_empty() && !id.is_empty() && !id.contains('/'))
.then_some(cluster)
}
/// The cluster ARN of a task ARN (same partition, region and account).
pub fn cluster_arn_of_task(task_arn: &str) -> Option<String> {
let cluster = cluster_of_task(task_arn)?;
let (prefix, _) = task_arn.split_once(":task/")?;
Some(format!("{prefix}:cluster/{cluster}"))
}
// ---------------------------------------------------------------- removal
/// What removing a project's AWS resources needs to know.
pub struct RemoveRequest<'a> {
pub env: &'a str,
pub region: &'a str,
pub owner_user_id: &'a str,
pub owner_key: &'a str,
pub project_id: &'a str,
/// The task ARN the store has, if any (stopped too, in case the listing
/// missed it).
pub stored_task_arn: Option<&'a str>,
pub stored_access_point_id: Option<&'a str>,
/// Delete the EFS access point (ruling R9). The files stay on the
/// user's own file system (only their task role can read it) until
/// offboarding destroys it; a later start of the same project gets a new
/// access point onto the same directory.
pub discard_data: bool,
}
/// Stop the project's task, delete its secret (no recovery window, R10) and,
/// with `discard_data`, delete its access point (not the files, R9). Task definition revisions
/// are left registered (R8). A task that can't be stopped stops the removal (nothing
/// billed may be left running); the rest is attempted in full and every
/// failure reported.
pub async fn remove_workspace_resources<T: TaskApi, W: WorkspaceApi, R: RuntimeApi>(
tasks: &T,
work: &W,
rt: &R,
req: &RemoveRequest<'_>,
) -> Result<(), AwsError> {
let cluster = environment_cluster_name(req.env);
let family = task_family(req.env, req.owner_key, req.project_id);
// 1. The task.
let live =
find_project_task(tasks, &cluster, &family, req.owner_user_id, req.project_id).await?;
let mut to_stop: Vec<String> = live.into_iter().map(|t| t.arn).collect();
if let Some(arn) = req.stored_task_arn {
if cluster_of_task(arn) == Some(cluster.as_str()) && !to_stop.iter().any(|a| a == arn) {
to_stop.push(arn.to_string());
}
}
for arn in &to_stop {
tasks.stop_task(&cluster, arn, STOP_REASON).await?;
}
let mut failures: Vec<String> = Vec::new();
// 2. Task definition revisions are left ACTIVE (ruling R8): ECS
// authorizes `DeregisterTaskDefinition` against `*` only, with no tag
// condition, so the workspace tier can't retire just its own. They are
// inert (no secret values, only the secret's ARN) and free, and
// `RunOwnTasks` still requires the owner tag, so nobody else can run
// them. An admin can deregister them (`aws/README.md`).
log::info!(
"Project {}: its task definition revisions ({family}:*) are left registered; an \
administrator can deregister them",
req.project_id
);
// 3. The secret.
let secret = project_secret_name(req.env, req.owner_key, req.project_id);
if let Err(e) = rt.delete_secret(&secret).await {
failures.push(format!("secret {secret}: {e}"));
}
// 4. The access point, only when asked.
if req.discard_data {
let mut ids: Vec<String> = req.stored_access_point_id.map(String::from).into_iter().collect();
let token = user_file_system_token(req.env, req.owner_key);
match work.find_file_system(&token).await {
Ok(Some(fs)) if fs.owner.as_deref() == Some(req.owner_user_id) => {
let root = access_point_root(req.owner_key, req.project_id);
match work.access_points(&fs.id).await {
Ok(points) => ids.extend(
points
.into_iter()
.filter(|a| {
a.file_system_id == fs.id
&& a.root_path.as_deref() == Some(root.as_str())
&& a.owner.as_deref() == Some(req.owner_user_id)
&& a.project.as_deref() == Some(req.project_id)
})
.map(|a| a.id),
),
Err(e) => failures.push(format!("access points: {e}")),
}
}
Ok(_) => {}
Err(e) => failures.push(format!("file system: {e}")),
}
ids.sort();
ids.dedup();
for id in ids {
if let Err(e) = rt.delete_access_point(&id).await {
failures.push(format!("access point {id}: {e}"));
}
}
}
if failures.is_empty() {
Ok(())
} else {
Err(other(format!(
"Some of the project's AWS resources in {} could not be removed: {}",
req.region,
failures.join("; ")
)))
}
}
// ---------------------------------------------------------------- real API
pub struct SdkRuntimeApi {
ssm: aws_sdk_ssm::Client,
iam: aws_sdk_iam::Client,
secrets: aws_sdk_secretsmanager::Client,
efs: aws_sdk_efs::Client,
}
impl SdkRuntimeApi {
pub fn new(cfg: &SdkConfig) -> Self {
SdkRuntimeApi {
ssm: aws_sdk_ssm::Client::new(cfg),
iam: aws_sdk_iam::Client::new(cfg),
secrets: aws_sdk_secretsmanager::Client::new(cfg),
efs: aws_sdk_efs::Client::new(cfg),
}
}
}
impl RuntimeApi for SdkRuntimeApi {
async fn environment_record(&self, env: &str) -> Result<Option<String>, AwsError> {
const ACTION: &str = "ssm:GetParameter";
match self
.ssm
.get_parameter()
.name(environment_parameter_name(env))
.with_decryption(false)
.send()
.await
{
Ok(out) => Ok(out.parameter().and_then(|p| p.value()).map(str::to_string)),
Err(e)
if e.as_service_error()
.is_some_and(|s| s.is_parameter_not_found()) =>
{
Ok(None)
}
Err(e) => Err(classify_sdk_error(ACTION, &e)),
}
}
async fn role_exists(&self, name: &str) -> Result<Knowable, AwsError> {
let res = self.iam.get_role().role_name(name).send().await;
knowable("iam:GetRole", res)
}
async fn secret_string(&self, name: &str) -> Result<String, AwsError> {
const ACTION: &str = "secretsmanager:GetSecretValue";
let out = self
.secrets
.get_secret_value()
.secret_id(name)
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
out.secret_string()
.map(str::to_string)
.ok_or_else(|| other(format!("{ACTION} returned no SecretString")))
}
async fn access_point_state(&self, id: &str) -> Result<Option<String>, AwsError> {
const ACTION: &str = "elasticfilesystem:DescribeAccessPoints";
match self.efs.describe_access_points().access_point_id(id).send().await {
Ok(out) => Ok(out
.access_points()
.iter()
.find(|a| a.access_point_id() == Some(id))
.and_then(|a| a.life_cycle_state())
.map(|s| s.as_str().to_string())),
Err(e)
if e.as_service_error()
.is_some_and(|s| s.is_access_point_not_found()) =>
{
Ok(None)
}
Err(e) => Err(classify_sdk_error(ACTION, &e)),
}
}
async fn delete_secret(&self, name: &str) -> Result<(), AwsError> {
const ACTION: &str = "secretsmanager:DeleteSecret";
match self
.secrets
.delete_secret()
.secret_id(name)
.force_delete_without_recovery(true)
.send()
.await
{
Ok(_) => Ok(()),
Err(e)
if e.as_service_error()
.is_some_and(|s| s.is_resource_not_found_exception()) =>
{
Ok(())
}
// Scheduled by an older version of the app and AWS refused the
// forced delete: nothing more this tier may do (no RestoreSecret).
Err(e)
if e.as_service_error().is_some_and(|s| {
s.is_invalid_request_exception()
&& s.message().is_some_and(|m| m.contains("scheduled for deletion"))
}) =>
{
Ok(())
}
Err(e) => Err(classify_sdk_error(ACTION, &e)),
}
}
async fn delete_access_point(&self, id: &str) -> Result<(), AwsError> {
const ACTION: &str = "elasticfilesystem:DeleteAccessPoint";
match self.efs.delete_access_point().access_point_id(id).send().await {
Ok(_) => Ok(()),
Err(e)
if e.as_service_error()
.is_some_and(|s| s.is_access_point_not_found()) =>
{
Ok(())
}
Err(e) => Err(classify_sdk_error(ACTION, &e)),
}
}
}
#[cfg(test)]
mod tests;
+331
View File
@@ -0,0 +1,331 @@
use std::sync::Mutex;
use aws_sdk_ecs::operation::register_task_definition::RegisterTaskDefinitionInput;
use aws_sdk_ecs::operation::run_task::RunTaskInput;
use super::*;
use crate::aws::tasks::TaskInfo;
use crate::aws::tofu_modules::owner_key;
use crate::aws::workspace::{AccessPointRef, FileSystemRef, TaskDefinitionRef};
const REGION: &str = "eu-west-1";
const ACCOUNT: &str = "111122223333";
const USER: &str = "AROAEXAMPLEID:alice@example.com";
const PROJECT: &str = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
fn ok() -> String {
owner_key(REGION, USER)
}
fn family() -> String {
task_family("dev", &ok(), PROJECT)
}
fn task_arn(id: &str) -> String {
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task/triple-c-dev/{id}")
}
#[derive(Default)]
struct Fake {
calls: Mutex<Vec<String>>,
record: Option<String>,
role: Option<Knowable>,
secret: Option<String>,
ap_states: Mutex<Vec<Option<String>>>,
live_tasks: Vec<TaskInfo>,
revisions: Vec<TaskDefinitionRef>,
access_points: Vec<AccessPointRef>,
fail_secret_delete: bool,
}
impl Fake {
fn log(&self, s: String) {
self.calls.lock().unwrap().push(s);
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
impl RuntimeApi for Fake {
async fn environment_record(&self, env: &str) -> Result<Option<String>, AwsError> {
self.log(format!("record {env}"));
Ok(self.record.clone())
}
async fn role_exists(&self, name: &str) -> Result<Knowable, AwsError> {
self.log(format!("role {name}"));
Ok(self.role.clone().unwrap_or(Knowable::Yes))
}
async fn secret_string(&self, name: &str) -> Result<String, AwsError> {
self.log(format!("secret {name}"));
self.secret.clone().ok_or_else(|| other("no secret"))
}
async fn access_point_state(&self, id: &str) -> Result<Option<String>, AwsError> {
self.log(format!("ap {id}"));
let mut s = self.ap_states.lock().unwrap();
Ok(if s.len() > 1 { s.remove(0) } else { s.first().cloned().flatten() })
}
async fn delete_secret(&self, name: &str) -> Result<(), AwsError> {
self.log(format!("delete-secret {name}"));
if self.fail_secret_delete {
return Err(other("denied"));
}
Ok(())
}
async fn delete_access_point(&self, id: &str) -> Result<(), AwsError> {
self.log(format!("delete-ap {id}"));
Ok(())
}
}
impl TaskApi for Fake {
async fn run_task(&self, _req: RunTaskInput) -> Result<TaskInfo, AwsError> {
unreachable!("removal never runs a task")
}
async fn describe(&self, _c: &str, arns: &[String]) -> Result<Vec<TaskInfo>, AwsError> {
Ok(self.live_tasks.iter().filter(|t| arns.contains(&t.arn)).cloned().collect())
}
async fn list_project_tasks(&self, _c: &str, _f: &str) -> Result<Vec<String>, AwsError> {
Ok(self.live_tasks.iter().map(|t| t.arn.clone()).collect())
}
async fn stop_task(&self, cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> {
self.log(format!("stop {cluster} {arn} {reason}"));
Ok(())
}
async fn log_tail(&self, _g: &str, _s: &str, _l: i32) -> Result<Vec<String>, AwsError> {
Ok(Vec::new())
}
}
impl WorkspaceApi for Fake {
async fn find_file_system(&self, _token: &str) -> Result<Option<FileSystemRef>, AwsError> {
Ok(Some(FileSystemRef { id: "fs-1".into(), owner: Some(USER.into()) }))
}
async fn access_points(&self, _fs: &str) -> Result<Vec<AccessPointRef>, AwsError> {
Ok(self.access_points.clone())
}
async fn create_access_point(
&self,
_fs: &str,
_root: &str,
_tags: &[(String, String)],
) -> Result<String, AwsError> {
unreachable!()
}
async fn put_project_secret(
&self,
_n: &str,
_k: &str,
_j: &str,
_t: &[(String, String)],
) -> Result<String, AwsError> {
unreachable!()
}
async fn task_definition_revisions(&self, _f: &str) -> Result<Vec<String>, AwsError> {
Ok(self.revisions.iter().map(|r| r.arn.clone()).collect())
}
async fn describe_task_definition(
&self,
arn: &str,
) -> Result<Option<TaskDefinitionRef>, AwsError> {
Ok(self.revisions.iter().find(|r| r.arn == arn).cloned())
}
async fn register_task_definition(
&self,
_req: RegisterTaskDefinitionInput,
) -> Result<String, AwsError> {
unreachable!()
}
}
fn record() -> String {
serde_json::json!({
"schema": 1, "name": "dev", "template_version": 1,
"cluster_arn": format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev"),
"security_group_id": "sg-0123456789abcdef0",
"subnet_ids": ["subnet-0a", "subnet-0b"],
"assign_public_ip": true,
"kms_key_arn": format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234-abcd"),
"log_group": "/triple-c/dev", "network_mode": "public", "tenancy": "strict"
})
.to_string()
}
#[tokio::test]
async fn the_environment_is_read_with_one_lookup_and_validated() {
let fake = Fake { record: Some(record()), ..Default::default() };
let env = read_environment(&fake, "dev", ACCOUNT, REGION).await.unwrap();
assert_eq!(env.log_group, "/triple-c/dev");
assert_eq!(fake.calls(), vec!["record dev"]);
let missing = Fake::default();
let e = read_environment(&missing, "dev", ACCOUNT, REGION).await.unwrap_err();
assert!(e.to_string().contains("was not found"), "{e}");
// A record from another account is refused, by field name only.
let foreign = Fake { record: Some(record()), ..Default::default() };
let e = read_environment(&foreign, "dev", "999999999999", REGION).await.unwrap_err();
assert!(e.to_string().contains("cluster_arn"), "{e}");
}
#[tokio::test]
async fn a_missing_task_role_means_not_joined_and_a_denied_lookup_lets_the_start_try() {
let no = Fake { role: Some(Knowable::No), ..Default::default() };
let e = check_onboarded(&no, "dev", &ok()).await.unwrap_err();
assert!(e.to_string().contains("not joined"), "{e}");
assert_eq!(no.calls(), vec![format!("role triple-c-dev-{}-task", ok())]);
let unknown = Fake { role: Some(Knowable::Unknown), ..Default::default() };
assert!(check_onboarded(&unknown, "dev", &ok()).await.is_ok());
}
#[test]
fn the_agent_token_is_read_from_the_secret_without_echoing_it() {
let json = r#"{"TRIPLEC_AGENT_TOKEN":"abc-DEF_123","OTHER":"SENTINEL-VALUE-7f3a"}"#;
assert_eq!(agent_token_from_secret(json).unwrap(), "abc-DEF_123");
for bad in [
r#"{"OTHER":"SENTINEL-VALUE-7f3a"}"#,
r#"{"TRIPLEC_AGENT_TOKEN":""}"#,
r#"{"TRIPLEC_AGENT_TOKEN":"has space"}"#,
"SENTINEL-VALUE-7f3a",
] {
let e = agent_token_from_secret(bad).unwrap_err();
assert!(!e.contains("SENTINEL") && !e.contains("space"), "{e}");
}
}
#[tokio::test]
async fn the_token_comes_from_the_projects_own_secret() {
let fake = Fake {
secret: Some(r#"{"TRIPLEC_AGENT_TOKEN":"tok"}"#.into()),
..Default::default()
};
assert_eq!(read_agent_token(&fake, "dev", &ok(), PROJECT).await.unwrap(), "tok");
assert_eq!(fake.calls(), vec![format!("secret triple-c/dev/{}/{PROJECT}", ok())]);
}
#[tokio::test(start_paused = true)]
async fn a_new_access_point_is_waited_for_until_available() {
let fake = Fake {
ap_states: Mutex::new(vec![Some("creating".into()), Some("creating".into()), Some("available".into())]),
..Default::default()
};
wait_access_point(&fake, "fsap-1", PollTiming::default()).await.unwrap();
assert_eq!(fake.calls().len(), 3);
let stuck = Fake { ap_states: Mutex::new(vec![Some("creating".into())]), ..Default::default() };
let e = wait_access_point(&stuck, "fsap-1", PollTiming::default()).await.unwrap_err();
assert!(e.to_string().contains("did not become available"), "{e}");
let gone = Fake { ap_states: Mutex::new(vec![Some("deleting".into())]), ..Default::default() };
assert!(wait_access_point(&gone, "fsap-1", PollTiming::default()).await.is_err());
}
#[test]
fn task_arns_name_their_cluster() {
let arn = task_arn("abc123");
assert_eq!(cluster_of_task(&arn), Some("triple-c-dev"));
assert_eq!(
cluster_arn_of_task(&arn).as_deref(),
Some(format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev").as_str())
);
assert_eq!(cluster_of_task("arn:aws:ecs:r:a:task/abc"), None, "old format, no cluster");
assert_eq!(cluster_of_task("task/triple-c-dev/abc"), None);
assert_eq!(cluster_of_task("arn:aws:ecs:r:a:task/c/a/b"), None);
}
fn live(id: &str) -> TaskInfo {
TaskInfo {
arn: task_arn(id),
last_status: "RUNNING".into(),
desired_status: "RUNNING".into(),
health: None,
stop_code: None,
stopped_reason: None,
task_definition_arn: format!(
"arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:2",
family()
),
runtime_id: None,
exec_agent_running: false,
exit_code: None,
owner: Some(USER.into()),
project: Some(PROJECT.into()),
}
}
fn rev(n: u32, owner: &str) -> TaskDefinitionRef {
TaskDefinitionRef {
arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{}:{n}", family()),
family: family(),
owner: Some(owner.into()),
project: Some(PROJECT.into()),
fingerprint: None,
}
}
#[tokio::test]
async fn removal_stops_the_task_leaves_the_revisions_and_schedules_the_secret() {
let fake = Fake {
live_tasks: vec![live("t1")],
revisions: vec![rev(2, USER), rev(3, "AROAOTHER:mallory"), rev(1, USER)],
access_points: vec![AccessPointRef {
id: "fsap-found".into(),
file_system_id: "fs-1".into(),
root_path: Some(access_point_root(&ok(), PROJECT)),
owner: Some(USER.into()),
project: Some(PROJECT.into()),
state: Some("available".into()),
}],
..Default::default()
};
let key = ok();
let t0 = task_arn("t0");
let mut r = RemoveRequest {
env: "dev",
region: REGION,
owner_user_id: USER,
owner_key: &key,
project_id: PROJECT,
stored_task_arn: Some(&t0),
stored_access_point_id: Some("fsap-stored"),
discard_data: false,
};
remove_workspace_resources(&fake, &fake, &fake, &r).await.unwrap();
let calls = fake.calls();
let secret = project_secret_name("dev", &ok(), PROJECT);
assert_eq!(
calls,
vec![
format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t1")),
format!("stop triple-c-dev {} {STOP_REASON}", task_arn("t0")),
format!("delete-secret {secret}"),
],
"no task definition is touched (R8) and the access point is kept"
);
let keep = Fake { access_points: fake.access_points.clone(), ..Default::default() };
r.discard_data = true;
remove_workspace_resources(&keep, &keep, &keep, &r).await.unwrap();
let calls = keep.calls();
assert!(calls.contains(&"delete-ap fsap-found".to_string()), "{calls:?}");
assert!(calls.contains(&"delete-ap fsap-stored".to_string()), "{calls:?}");
}
#[tokio::test]
async fn a_failed_step_is_reported_but_the_rest_still_runs() {
let fake = Fake { fail_secret_delete: true, ..Default::default() };
let key = ok();
let r = RemoveRequest {
env: "dev",
region: REGION,
owner_user_id: USER,
owner_key: &key,
project_id: PROJECT,
stored_task_arn: None,
stored_access_point_id: Some("fsap-1"),
discard_data: true,
};
let e = remove_workspace_resources(&fake, &fake, &fake, &r).await.unwrap_err();
assert!(e.to_string().contains("secret"), "{e}");
assert!(fake.calls().contains(&"delete-ap fsap-1".to_string()));
}
+695
View File
@@ -0,0 +1,695 @@
//! A project's ECS task: find the one already running, or run one; wait
//! until it is ready; stop it; and read why it stopped.
//!
//! **A task is trusted by its tags, not its family.** Family names are built
//! from a 12-character id prefix and any workspace user may register into any
//! `triple-c-*` family, so a task listed under the project's family is reused
//! only when DescribeTasks shows the caller's own `triple-c:owner` (which only
//! the caller can set, at RunTask) and this project's `triple-c:project`.
//!
//! **RunTask names an exact revision.** Never a bare family: "the latest
//! revision" of a family is whatever anyone registered last.
//!
//! **A quota refusal is final.** Fargate's vCPU limit (and its kin) surface as
//! [`AwsError::QuotaExceeded`] with a message saying what to do; nothing here
//! retries a RunTask, and the SDK's own retries are made idempotent by a
//! client token.
use std::future::Future;
use std::time::Duration;
use aws_config::SdkConfig;
use aws_sdk_ecs::operation::run_task::RunTaskInput;
use aws_sdk_ecs::types::{
AssignPublicIp, AwsVpcConfiguration, CapacityProviderStrategyItem, DesiredStatus, LaunchType,
ManagedAgentName, NetworkConfiguration, Tag, Task, TaskField,
};
use super::environments::EnvironmentInfo;
use super::error::{classify_sdk_error, AwsError};
pub use super::tofu_modules::task_log_stream;
use super::workspace::{workspace_tags, CONTAINER_NAME, TAG_OWNER, TAG_PROJECT};
/// The `reason` StopTask records when the app stops a task.
pub const STOP_REASON: &str = "Stopped from Triple-C";
/// Log lines shown when a task stops before it is ready.
pub const LOG_TAIL_LINES: i32 = 50;
const SPOT_PROVIDER: &str = "FARGATE_SPOT";
/// DescribeTasks takes at most 100 tasks per call.
const DESCRIBE_BATCH: usize = 100;
/// ListTasks pages read per status; a project has one task, so this is slack.
const MAX_LIST_PAGES: usize = 5;
fn other(message: impl Into<String>) -> AwsError {
AwsError::Other {
message: message.into(),
}
}
/// What the app needs to know about a task.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskInfo {
pub arn: String,
/// `PROVISIONING`, `PENDING`, `ACTIVATING`, `RUNNING`, `DEACTIVATING`,
/// `STOPPING`, `DEPROVISIONING`, `STOPPED`.
pub last_status: String,
pub desired_status: String,
/// `HEALTHY`, `UNHEALTHY`, `UNKNOWN`.
pub health: Option<String>,
/// ECS `stopCode` (`EssentialContainerExited`, `SpotInterruption`, ...).
pub stop_code: Option<String>,
pub stopped_reason: Option<String>,
pub task_definition_arn: String,
/// The `workspace` container's runtime id (needed for an SSM target).
pub runtime_id: Option<String>,
/// The `workspace` container's ExecuteCommandAgent is RUNNING.
pub exec_agent_running: bool,
/// The `workspace` container's exit code, once it has one.
pub exit_code: Option<i32>,
/// Its `triple-c:owner` / `triple-c:project` tags (DescribeTasks with
/// TAGS; RunTask's answer carries the tags it was given).
pub owner: Option<String>,
pub project: Option<String>,
}
impl TaskInfo {
fn is_stopping(&self) -> bool {
self.last_status == "STOPPED" || self.desired_status == "STOPPED"
}
/// On its way up or up, and meant to stay that way.
fn is_live(&self) -> bool {
self.desired_status == "RUNNING"
&& matches!(
self.last_status.as_str(),
"PROVISIONING" | "PENDING" | "ACTIVATING" | "RUNNING"
)
}
fn is_ready(&self) -> bool {
self.last_status == "RUNNING" && self.exec_agent_running && self.runtime_id.is_some()
}
}
fn tag_value(tags: &[Tag], key: &str) -> Option<String> {
tags.iter()
.find(|t| t.key() == Some(key))
.and_then(|t| t.value())
.map(str::to_string)
}
/// An SDK task as a [`TaskInfo`]. The container facts come from the
/// `workspace` container only.
pub(crate) fn task_info(t: &Task) -> TaskInfo {
let workspace = t
.containers()
.iter()
.find(|c| c.name() == Some(CONTAINER_NAME));
TaskInfo {
arn: t.task_arn().unwrap_or_default().to_string(),
last_status: t.last_status().unwrap_or_default().to_string(),
desired_status: t.desired_status().unwrap_or_default().to_string(),
health: t.health_status().map(|h| h.as_str().to_string()),
stop_code: t.stop_code().map(|c| c.as_str().to_string()),
stopped_reason: t.stopped_reason().map(str::to_string),
task_definition_arn: t.task_definition_arn().unwrap_or_default().to_string(),
runtime_id: workspace.and_then(|c| c.runtime_id()).map(str::to_string),
exec_agent_running: workspace.is_some_and(|c| {
c.managed_agents().iter().any(|a| {
a.name() == Some(&ManagedAgentName::ExecuteCommandAgent)
&& a.last_status() == Some("RUNNING")
})
}),
exit_code: workspace.and_then(|c| c.exit_code()),
owner: tag_value(t.tags(), TAG_OWNER),
project: tag_value(t.tags(), TAG_PROJECT),
}
}
/// What running and watching tasks asks AWS. Like `WorkspaceApi`, each
/// method returns a `Send` future and an impl may write them as `async fn`;
/// the decisions are made by the functions below, so a fake exercises them.
pub trait TaskApi: Send + Sync {
/// RunTask. A quota refusal is [`AwsError::QuotaExceeded`].
fn run_task(
&self,
req: RunTaskInput,
) -> impl Future<Output = Result<TaskInfo, AwsError>> + Send;
/// DescribeTasks (with tags). Tasks ECS no longer knows are left out.
fn describe(
&self,
cluster: &str,
arns: &[String],
) -> impl Future<Output = Result<Vec<TaskInfo>, AwsError>> + Send;
/// ListTasks of exactly `family`, desired status RUNNING and PENDING.
fn list_project_tasks(
&self,
cluster: &str,
family: &str,
) -> impl Future<Output = Result<Vec<String>, AwsError>> + Send;
fn stop_task(
&self,
cluster: &str,
arn: &str,
reason: &str,
) -> impl Future<Output = Result<(), AwsError>> + Send;
/// The last `limit` lines of a log stream, oldest first. A stream (or
/// group) that does not exist is no lines, not an error.
fn log_tail(
&self,
group: &str,
stream: &str,
limit: i32,
) -> impl Future<Output = Result<Vec<String>, AwsError>> + Send;
}
// ------------------------------------------------------------- run request
/// The RunTask request: the exact revision, on the environment's cluster and
/// network, with ECS Exec on and exactly the `tags` given (no propagation,
/// no ECS-managed tags, no overrides — P1). Spot runs on the `FARGATE_SPOT`
/// capacity provider with no launch type (the two are exclusive).
pub fn run_task_request(
env: &EnvironmentInfo,
task_def_arn: &str,
spot: bool,
tags: &[(String, String)],
) -> RunTaskInput {
const BUILT: &str = "every required field is set";
let mut req = RunTaskInput::builder()
.cluster(&env.cluster_arn)
.task_definition(task_def_arn)
.count(1)
.enable_execute_command(true)
.network_configuration(
NetworkConfiguration::builder()
.awsvpc_configuration(
AwsVpcConfiguration::builder()
.set_subnets(Some(env.subnet_ids.clone()))
.security_groups(&env.security_group_id)
.assign_public_ip(if env.assign_public_ip {
AssignPublicIp::Enabled
} else {
AssignPublicIp::Disabled
})
.build()
.expect(BUILT),
)
.build(),
)
.set_tags(Some(
tags.iter()
.map(|(k, v)| Tag::builder().key(k).value(v).build())
.collect(),
))
// SDK retries of this call must not start a second task.
.client_token(uuid::Uuid::new_v4().to_string());
req = if spot {
req.capacity_provider_strategy(
CapacityProviderStrategyItem::builder()
.capacity_provider(SPOT_PROVIDER)
.weight(1)
.build()
.expect(BUILT),
)
} else {
req.launch_type(LaunchType::Fargate)
};
req.build().expect(BUILT)
}
/// The family of `arn:...:task-definition/<family>:<revision>`, when the
/// revision is a number. `None` for a bare family or anything else.
pub fn revision_family(arn: &str) -> Option<&str> {
if !arn.starts_with("arn:") {
return None;
}
let (_, rest) = arn.rsplit_once(":task-definition/")?;
let (family, rev) = rest.rsplit_once(':')?;
(!family.is_empty()
&& !family.contains('/')
&& !rev.is_empty()
&& rev.bytes().all(|c| c.is_ascii_digit()))
.then_some(family)
}
// ------------------------------------------------------------- find or run
/// The project's live task (provisioning through running, not being
/// stopped), owned by the caller and tagged with this project. A RUNNING
/// one is preferred.
pub async fn find_project_task<A: TaskApi>(
api: &A,
cluster: &str,
family: &str,
owner_user_id: &str,
project_id: &str,
) -> Result<Option<TaskInfo>, AwsError> {
let arns = api.list_project_tasks(cluster, family).await?;
if arns.is_empty() {
return Ok(None);
}
let mut mine: Vec<TaskInfo> = Vec::new();
for chunk in arns.chunks(DESCRIBE_BATCH) {
mine.extend(api.describe(cluster, chunk).await?.into_iter().filter(|t| {
t.owner.as_deref() == Some(owner_user_id)
&& t.project.as_deref() == Some(project_id)
&& revision_family(&t.task_definition_arn) == Some(family)
&& t.is_live()
}));
}
mine.sort_by_key(|t| t.last_status != "RUNNING");
Ok(mine.into_iter().next())
}
/// What [`find_or_run`] did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskStart {
pub task: TaskInfo,
/// An existing task was found (nothing was run). Its agent token is the
/// one it started with — see [`find_project_task`] in the report for
/// why a caller should look before rotating the token.
pub reused: bool,
}
/// The project's live task if there is one (Review Focus 2: never a second
/// task for a project), else RunTask with the exact `task_def_arn`.
pub async fn find_or_run<A: TaskApi>(
api: &A,
env: &EnvironmentInfo,
owner_user_id: &str,
project_id: &str,
task_def_arn: &str,
spot: bool,
) -> Result<TaskStart, AwsError> {
let family = revision_family(task_def_arn).ok_or_else(|| {
other(format!(
"\"{task_def_arn}\" is not an exact task definition revision"
))
})?;
if !family.starts_with("triple-c-") {
return Err(other(format!(
"\"{task_def_arn}\" is not a Triple-C task definition"
)));
}
if let Some(task) =
find_project_task(api, &env.cluster_arn, family, owner_user_id, project_id).await?
{
return Ok(TaskStart { task, reused: true });
}
let tags = workspace_tags(owner_user_id, project_id, &env.name);
let task = api
.run_task(run_task_request(env, task_def_arn, spot, &tags))
.await?;
Ok(TaskStart {
task,
reused: false,
})
}
// ------------------------------------------------------------------ quotas
/// Whether an AWS message says an account limit or quota was hit.
pub fn looks_like_quota(text: &str) -> bool {
let t = text.to_lowercase();
[
"you've reached the limit",
"you\u{2019}ve reached the limit",
"limitexceeded",
"limit exceeded",
"quota",
]
.iter()
.any(|s| t.contains(s))
}
/// The error for a RunTask refused on a quota.
pub fn quota_error(detail: &str) -> AwsError {
AwsError::QuotaExceeded {
message: format!(
"AWS would not start the task because an account limit was reached: {detail}. \
Stop another workspace, or ask an administrator to raise the Fargate quota \
(Service Quotas, AWS Fargate) for this region. Triple-C does not retry."
),
}
}
/// `e` as a quota error when its message is one; otherwise unchanged.
pub fn quota_or(e: AwsError) -> AwsError {
match e {
AwsError::Other { ref message } if looks_like_quota(message) => quota_error(message),
e => e,
}
}
/// RunTask's answer: its one task, or an error from its `failures`
/// (`(reason, detail)`), a quota one when they say so.
pub fn run_task_outcome(
tasks: Vec<TaskInfo>,
failures: Vec<(Option<String>, Option<String>)>,
) -> Result<TaskInfo, AwsError> {
if let Some(task) = tasks.into_iter().find(|t| !t.arn.is_empty()) {
return Ok(task);
}
let text = failures
.iter()
.map(|(r, d)| match (r, d) {
(Some(r), Some(d)) => format!("{r} ({d})"),
(Some(r), None) => r.clone(),
(None, Some(d)) => d.clone(),
(None, None) => "unknown failure".into(),
})
.collect::<Vec<_>>()
.join("; ");
if text.is_empty() {
return Err(other("ecs:RunTask returned no task and no failure"));
}
if looks_like_quota(&text) {
return Err(quota_error(&text));
}
Err(other(format!("ECS could not start the task: {text}")))
}
// -------------------------------------------------------------------- wait
/// How [`wait_ready`] polls. Injectable so tests don't wait.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaitTiming {
pub interval: Duration,
pub timeout: Duration,
}
impl Default for WaitTiming {
fn default() -> Self {
WaitTiming {
interval: Duration::from_secs(5),
timeout: Duration::from_secs(6 * 60),
}
}
}
/// Where a project's task logs go: the environment's log group and the
/// project's `awslogs-stream-prefix` (Task 4's `log_stream_prefix`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskLogs {
pub group: String,
pub stream_prefix: String,
}
fn describe_timeout(t: Duration) -> String {
let secs = t.as_secs();
if secs >= 60 && secs % 60 == 0 {
let m = secs / 60;
format!("{m} minute{}", if m == 1 { "" } else { "s" })
} else {
format!("{secs} seconds")
}
}
/// Poll until the task is RUNNING with its exec agent up and a runtime id
/// (what an SSM session needs), calling `progress` with each look. A task
/// that stops (or is being stopped) fails with its reason and the last
/// [`LOG_TAIL_LINES`] lines of its log; one that never gets there fails
/// after `timing.timeout` with its last status. The task is left as it is
/// either way: stopping it is the caller's call.
pub async fn wait_ready<A: TaskApi>(
api: &A,
cluster: &str,
arn: &str,
logs: &TaskLogs,
timing: WaitTiming,
progress: impl Fn(&TaskInfo),
) -> Result<TaskInfo, AwsError> {
let deadline = tokio::time::Instant::now() + timing.timeout;
let wanted = [arn.to_string()];
loop {
let Some(t) = api
.describe(cluster, &wanted)
.await?
.into_iter()
.find(|t| t.arn == arn)
else {
return Err(other(format!(
"The task {arn} is no longer known to ECS; start the project again"
)));
};
progress(&t);
if t.is_stopping() {
return Err(stopped_error(api, logs, &t).await);
}
if t.is_ready() {
return Ok(t);
}
if tokio::time::Instant::now() + timing.interval > deadline {
return Err(other(format!(
"The task did not become ready within {} (last status {}{}). It may still \
be starting; check the ECS console, or stop the project and start it again.",
describe_timeout(timing.timeout),
t.last_status,
if t.last_status == "RUNNING" && !t.exec_agent_running {
", ECS Exec agent not running yet"
} else {
""
}
)));
}
tokio::time::sleep(timing.interval).await;
}
}
async fn stopped_error<A: TaskApi>(api: &A, logs: &TaskLogs, t: &TaskInfo) -> AwsError {
let reason = t
.stopped_reason
.clone()
.unwrap_or_else(|| "no reason given".into());
let code = t
.stop_code
.as_deref()
.map(|c| format!(" ({c})"))
.unwrap_or_default();
let stream = task_log_stream(&logs.stream_prefix, &t.arn);
let tail = match api.log_tail(&logs.group, &stream, LOG_TAIL_LINES).await {
Ok(lines) if lines.is_empty() => "\n(The task wrote no log lines.)".to_string(),
Ok(lines) => format!("\nLast log lines:\n{}", lines.join("\n")),
Err(e) => format!("\n(Its log could not be read: {e})"),
};
other(format!(
"The task stopped before it was ready: {reason}{code}.{tail}"
))
}
// ----------------------------------------------------------- stop / watch
/// The entrypoint's exit status after an idle stop (`container/entrypoint.sh`,
/// `stop_keepalive`). A stop from ECS (SIGTERM) exits 0; this one is distinct
/// so an idle stop (`EssentialContainerExited`) is not mistaken for a crash.
pub const IDLE_EXIT_CODE: i32 = 75;
/// Why a task stopped, as the app reports it (spec, "Lifecycle and status").
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StopKind {
/// Exited on its own (the essential container ended, or AWS retired it),
/// with ECS's stopped reason when it gave one.
Exited(Option<String>),
/// The workspace's own idle stop ([`IDLE_EXIT_CODE`]).
Idle,
/// A Fargate Spot interruption (P8).
Spot,
/// Never got going: the project goes to Error with this reason.
FailedToStart(String),
/// Stopped by someone (the app, the console, the CLI).
UserInitiated,
}
pub fn classify_stop(info: &TaskInfo) -> StopKind {
match info.stop_code.as_deref() {
Some("SpotInterruption") => StopKind::Spot,
Some("TaskFailedToStart") => StopKind::FailedToStart(
info.stopped_reason
.clone()
.filter(|r| !r.trim().is_empty())
.unwrap_or_else(|| "The task failed to start".into()),
),
Some("UserInitiated") => StopKind::UserInitiated,
Some("EssentialContainerExited") if info.exit_code == Some(IDLE_EXIT_CODE) => StopKind::Idle,
_ => StopKind::Exited(info.stopped_reason.clone().filter(|r| !r.trim().is_empty())),
}
}
/// Of `arns`, the ones that are STOPPED or that ECS no longer knows (it
/// forgets stopped tasks after about an hour), each with why it stopped (a
/// forgotten task: [`StopKind::Exited`] without a reason). A task merely
/// being stopped is not yet reported. For the AWS death watch.
pub async fn stopped_among<A: TaskApi>(
api: &A,
cluster: &str,
arns: &[String],
) -> Result<Vec<(String, StopKind)>, AwsError> {
let mut stopped = Vec::new();
for chunk in arns.chunks(DESCRIBE_BATCH) {
let known = api.describe(cluster, chunk).await?;
for arn in chunk {
match known.iter().find(|t| &t.arn == arn) {
Some(t) if t.last_status != "STOPPED" => {}
Some(t) => stopped.push((arn.clone(), classify_stop(t))),
None => stopped.push((arn.clone(), StopKind::Exited(None))),
}
}
}
Ok(stopped)
}
// ---------------------------------------------------------------- real API
/// The real thing, from a connection's `SdkConfig`.
pub struct SdkTaskApi {
ecs: aws_sdk_ecs::Client,
logs: aws_sdk_cloudwatchlogs::Client,
}
impl SdkTaskApi {
pub fn new(cfg: &SdkConfig) -> Self {
SdkTaskApi {
ecs: aws_sdk_ecs::Client::new(cfg),
logs: aws_sdk_cloudwatchlogs::Client::new(cfg),
}
}
}
impl TaskApi for SdkTaskApi {
async fn run_task(&self, req: RunTaskInput) -> Result<TaskInfo, AwsError> {
const ACTION: &str = "ecs:RunTask";
// The fluent builder takes no whole input; copy every field.
let out = self
.ecs
.run_task()
.set_capacity_provider_strategy(req.capacity_provider_strategy)
.set_cluster(req.cluster)
.set_count(req.count)
.set_enable_ecs_managed_tags(req.enable_ecs_managed_tags)
.set_enable_execute_command(req.enable_execute_command)
.set_group(req.group)
.set_launch_type(req.launch_type)
.set_network_configuration(req.network_configuration)
.set_overrides(req.overrides)
.set_placement_constraints(req.placement_constraints)
.set_placement_strategy(req.placement_strategy)
.set_platform_version(req.platform_version)
.set_propagate_tags(req.propagate_tags)
.set_reference_id(req.reference_id)
.set_started_by(req.started_by)
.set_tags(req.tags)
.set_task_definition(req.task_definition)
.set_client_token(req.client_token)
.set_volume_configurations(req.volume_configurations)
.send()
.await
.map_err(|e| quota_or(classify_sdk_error(ACTION, &e)))?;
run_task_outcome(
out.tasks().iter().map(task_info).collect(),
out.failures()
.iter()
.map(|f| (f.reason().map(String::from), f.detail().map(String::from)))
.collect(),
)
}
async fn describe(&self, cluster: &str, arns: &[String]) -> Result<Vec<TaskInfo>, AwsError> {
const ACTION: &str = "ecs:DescribeTasks";
let mut out = Vec::new();
for chunk in arns.chunks(DESCRIBE_BATCH) {
let page = self
.ecs
.describe_tasks()
.cluster(cluster)
.set_tasks(Some(chunk.to_vec()))
.include(TaskField::Tags)
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
out.extend(page.tasks().iter().map(task_info));
}
Ok(out)
}
async fn list_project_tasks(
&self,
cluster: &str,
family: &str,
) -> Result<Vec<String>, AwsError> {
const ACTION: &str = "ecs:ListTasks";
let mut found: Vec<String> = Vec::new();
for status in [DesiredStatus::Running, DesiredStatus::Pending] {
let mut token: Option<String> = None;
for _ in 0..MAX_LIST_PAGES {
let page = self
.ecs
.list_tasks()
.cluster(cluster)
.family(family)
.desired_status(status.clone())
.set_next_token(token.take())
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
for a in page.task_arns() {
if !found.contains(a) {
found.push(a.clone());
}
}
match page.next_token() {
Some(t) => token = Some(t.to_string()),
None => break,
}
}
}
Ok(found)
}
async fn stop_task(&self, cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> {
const ACTION: &str = "ecs:StopTask";
self.ecs
.stop_task()
.cluster(cluster)
.task(arn)
.reason(reason)
.send()
.await
.map_err(|e| classify_sdk_error(ACTION, &e))?;
Ok(())
}
async fn log_tail(
&self,
group: &str,
stream: &str,
limit: i32,
) -> Result<Vec<String>, AwsError> {
const ACTION: &str = "logs:GetLogEvents";
let out = self
.logs
.get_log_events()
.log_group_name(group)
.log_stream_name(stream)
.start_from_head(false)
.limit(limit)
.send()
.await;
match out {
Ok(page) => Ok(page
.events()
.iter()
.filter_map(|e| e.message())
.map(str::to_string)
.collect()),
Err(e)
if e.as_service_error()
.is_some_and(|s| s.is_resource_not_found_exception()) =>
{
Ok(Vec::new())
}
Err(e) => Err(classify_sdk_error(ACTION, &e)),
}
}
}
#[cfg(test)]
mod tests;
+744
View File
@@ -0,0 +1,744 @@
use std::sync::Mutex;
use std::time::Duration;
use aws_sdk_ecs::types::{
AssignPublicIp, Container, LaunchType, ManagedAgent, ManagedAgentName, Tag, Task, TaskStopCode,
};
use super::*;
use crate::aws::environments::EnvironmentInfo;
use crate::aws::tofu_modules::{log_stream_prefix, owner_key};
const ACCOUNT: &str = "111122223333";
const REGION: &str = "eu-west-1";
const USER: &str = "AROAEXAMPLEID:alice@example.com";
const OTHER: &str = "AROAOTHERID:mallory";
const PROJECT_ID: &str = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0";
const FAMILY: &str = "triple-c-dev-abcdef012345-0f1e2d3c-4b5";
fn cluster() -> String {
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-dev")
}
fn td_arn(rev: u32) -> String {
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{FAMILY}:{rev}")
}
fn task_arn(id: &str) -> String {
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task/triple-c-dev/{id}")
}
fn env_info() -> EnvironmentInfo {
EnvironmentInfo {
schema: 1,
name: "dev".into(),
template_version: 1,
cluster_arn: cluster(),
security_group_id: "sg-0123456789abcdef0".into(),
subnet_ids: vec!["subnet-0a".into(), "subnet-0b".into()],
assign_public_ip: true,
kms_key_arn: format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234"),
log_group: "/triple-c/dev".into(),
network_mode: "public".into(),
tenancy: "strict".into(),
}
}
fn is_quota_error(e: &AwsError) -> bool {
matches!(e, AwsError::QuotaExceeded { .. })
}
fn info(id: &str, last: &str) -> TaskInfo {
TaskInfo {
arn: task_arn(id),
last_status: last.into(),
desired_status: if last == "STOPPED" {
"STOPPED"
} else {
"RUNNING"
}
.into(),
health: None,
stop_code: None,
stopped_reason: None,
task_definition_arn: td_arn(3),
runtime_id: None,
exec_agent_running: false,
exit_code: None,
owner: Some(USER.into()),
project: Some(PROJECT_ID.into()),
}
}
fn ready(id: &str) -> TaskInfo {
TaskInfo {
runtime_id: Some(format!("{id}-123")),
exec_agent_running: true,
..info(id, "RUNNING")
}
}
/// A scripted `TaskApi`: `listed` answers ListTasks, `described` answers
/// every DescribeTasks in turn (the last one repeats), `run` answers RunTask.
#[derive(Default)]
struct Fake {
listed: Vec<String>,
described: Mutex<Vec<Vec<TaskInfo>>>,
run: Mutex<Option<Result<TaskInfo, AwsError>>>,
logs: Vec<String>,
calls: Mutex<Vec<String>>,
run_requests: Mutex<Vec<RunTaskInput>>,
log_requests: Mutex<Vec<(String, String, i32)>>,
}
impl Fake {
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn describing(self, answers: Vec<Vec<TaskInfo>>) -> Self {
*self.described.lock().unwrap() = answers;
self
}
fn running(self, r: Result<TaskInfo, AwsError>) -> Self {
*self.run.lock().unwrap() = Some(r);
self
}
}
impl TaskApi for Fake {
async fn run_task(&self, req: RunTaskInput) -> Result<TaskInfo, AwsError> {
self.calls.lock().unwrap().push("run".into());
self.run_requests.lock().unwrap().push(req);
self.run
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| Ok(info("new", "PROVISIONING")))
}
async fn describe(&self, cluster: &str, arns: &[String]) -> Result<Vec<TaskInfo>, AwsError> {
self.calls
.lock()
.unwrap()
.push(format!("describe {cluster} {}", arns.join(",")));
let mut d = self.described.lock().unwrap();
let answer = if d.len() > 1 {
d.remove(0)
} else {
d.first().cloned().unwrap_or_default()
};
Ok(answer
.into_iter()
.filter(|t| arns.contains(&t.arn))
.collect())
}
async fn list_project_tasks(
&self,
cluster: &str,
family: &str,
) -> Result<Vec<String>, AwsError> {
self.calls
.lock()
.unwrap()
.push(format!("list {cluster} {family}"));
Ok(self.listed.clone())
}
async fn stop_task(&self, _cluster: &str, arn: &str, reason: &str) -> Result<(), AwsError> {
self.calls
.lock()
.unwrap()
.push(format!("stop {arn} {reason}"));
Ok(())
}
async fn log_tail(
&self,
group: &str,
stream: &str,
limit: i32,
) -> Result<Vec<String>, AwsError> {
self.log_requests
.lock()
.unwrap()
.push((group.into(), stream.into(), limit));
Ok(self.logs.clone())
}
}
fn tags() -> Vec<(String, String)> {
crate::aws::workspace::workspace_tags(USER, PROJECT_ID, "dev")
}
// ------------------------------------------------------------ run request
#[test]
fn on_demand_runs_fargate_on_the_environment_network_with_exec_and_the_three_tags() {
let req = run_task_request(&env_info(), &td_arn(3), false, &tags());
assert_eq!(req.cluster(), Some(cluster().as_str()));
assert_eq!(req.task_definition(), Some(td_arn(3).as_str()));
assert_eq!(req.launch_type(), Some(&LaunchType::Fargate));
assert!(req.capacity_provider_strategy().is_empty());
assert_eq!(req.enable_execute_command(), Some(true));
assert_eq!(req.count(), Some(1));
let vpc = req
.network_configuration()
.and_then(|n| n.awsvpc_configuration())
.expect("awsvpc");
assert_eq!(vpc.subnets(), ["subnet-0a", "subnet-0b"]);
assert_eq!(vpc.security_groups(), ["sg-0123456789abcdef0"]);
assert_eq!(vpc.assign_public_ip(), Some(&AssignPublicIp::Enabled));
let got: Vec<(String, String)> = req
.tags()
.iter()
.map(|t| (t.key().unwrap().to_string(), t.value().unwrap().to_string()))
.collect();
assert_eq!(got, tags());
// Nothing project-shaped rides along (P1), and tags are given, not propagated.
assert!(req.overrides().is_none());
assert!(req.propagate_tags().is_none());
assert!(req.enable_ecs_managed_tags().is_none());
}
#[test]
fn spot_uses_the_fargate_spot_capacity_provider_and_no_launch_type() {
let req = run_task_request(&env_info(), &td_arn(3), true, &tags());
assert_eq!(req.launch_type(), None);
let strategy = req.capacity_provider_strategy();
assert_eq!(strategy.len(), 1);
assert_eq!(strategy[0].capacity_provider(), "FARGATE_SPOT");
assert_eq!(strategy[0].weight(), 1);
}
#[test]
fn a_private_environment_gets_no_public_ip() {
let mut env = env_info();
env.assign_public_ip = false;
let req = run_task_request(&env, &td_arn(3), false, &tags());
let vpc = req
.network_configuration()
.unwrap()
.awsvpc_configuration()
.unwrap();
assert_eq!(vpc.assign_public_ip(), Some(&AssignPublicIp::Disabled));
}
// ------------------------------------------------------------ find or run
#[tokio::test]
async fn an_existing_running_task_is_reused_and_nothing_is_run() {
let api = Fake {
listed: vec![task_arn("t1")],
..Default::default()
}
.describing(vec![vec![ready("t1")]]);
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(4), false)
.await
.unwrap();
assert!(got.reused);
assert_eq!(got.task.arn, task_arn("t1"));
assert!(!api.calls().contains(&"run".to_string()));
assert_eq!(api.calls()[0], format!("list {} {FAMILY}", cluster()));
}
#[tokio::test]
async fn a_pending_task_is_reused_too() {
let api = Fake {
listed: vec![task_arn("t1")],
..Default::default()
}
.describing(vec![vec![info("t1", "PENDING")]]);
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false)
.await
.unwrap();
assert!(got.reused);
assert_eq!(got.task.last_status, "PENDING");
assert!(!api.calls().contains(&"run".to_string()));
}
#[tokio::test]
async fn a_task_of_another_owner_or_project_in_the_family_is_never_reused() {
let mut foreign = info("theirs", "RUNNING");
foreign.owner = Some(OTHER.into());
let mut other_project = info("other-project", "RUNNING");
other_project.project = Some("ffffffff-0000-0000-0000-000000000000".into());
let mut untagged = info("untagged", "RUNNING");
untagged.owner = None;
untagged.project = None;
let api = Fake {
listed: vec![
task_arn("theirs"),
task_arn("other-project"),
task_arn("untagged"),
],
..Default::default()
}
.describing(vec![vec![foreign, other_project, untagged]]);
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false)
.await
.unwrap();
assert!(!got.reused);
assert_eq!(got.task.arn, task_arn("new"));
assert_eq!(api.calls().last().unwrap(), "run");
}
#[tokio::test]
async fn a_task_already_stopping_is_not_reused() {
let mut stopping = info("t1", "RUNNING");
stopping.desired_status = "STOPPED".into();
let api = Fake {
listed: vec![task_arn("t1"), task_arn("t2")],
..Default::default()
}
.describing(vec![vec![stopping, info("t2", "DEPROVISIONING")]]);
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false)
.await
.unwrap();
assert!(!got.reused);
}
#[tokio::test]
async fn a_running_task_is_preferred_over_a_pending_one() {
let api = Fake {
listed: vec![task_arn("p"), task_arn("r")],
..Default::default()
}
.describing(vec![vec![info("p", "PENDING"), ready("r")]]);
let got = find_project_task(&api, &cluster(), FAMILY, USER, PROJECT_ID)
.await
.unwrap()
.unwrap();
assert_eq!(got.arn, task_arn("r"));
}
#[tokio::test]
async fn nothing_listed_means_no_describe_and_a_run_with_the_exact_revision_and_tags() {
let api = Fake::default();
let got = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(7), true)
.await
.unwrap();
assert!(!got.reused);
assert_eq!(api.calls().len(), 2, "{:?}", api.calls());
let reqs = api.run_requests.lock().unwrap();
assert_eq!(reqs[0].task_definition(), Some(td_arn(7).as_str()));
assert_eq!(reqs[0].launch_type(), None);
let keys: Vec<&str> = reqs[0].tags().iter().filter_map(|t| t.key()).collect();
assert_eq!(
keys,
["triple-c:environment", "triple-c:owner", "triple-c:project"]
);
assert_eq!(
reqs[0]
.tags()
.iter()
.find(|t| t.key() == Some("triple-c:owner"))
.unwrap()
.value(),
Some(USER)
);
}
#[tokio::test]
async fn a_bare_family_or_foreign_arn_is_refused_before_any_call() {
let api = Fake::default();
for bad in [
FAMILY.to_string(),
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{FAMILY}"),
format!("arn:aws:ecs:{REGION}:{ACCOUNT}:task-definition/{FAMILY}:latest"),
"arn:aws:ecs:eu-west-1:1:task-definition/someone-else:1".to_string(),
] {
let err = find_or_run(&api, &env_info(), USER, PROJECT_ID, &bad, false)
.await
.unwrap_err();
assert!(matches!(err, AwsError::Other { .. }), "{bad}: {err:?}");
}
assert!(api.calls().is_empty());
}
#[tokio::test]
async fn a_quota_refusal_is_reported_once_without_retrying() {
let api = Fake::default().running(Err(quota_error(
"You've reached the limit on the number of vCPUs you can run concurrently",
)));
let err = find_or_run(&api, &env_info(), USER, PROJECT_ID, &td_arn(3), false)
.await
.unwrap_err();
assert!(is_quota_error(&err));
let text = err.to_string();
assert!(text.contains("limit"), "{text}");
assert!(text.contains("quota"), "{text}");
assert!(text.contains("vCPUs"), "{text}");
assert_eq!(
api.calls().iter().filter(|c| *c == "run").count(),
1,
"no retry"
);
}
// ------------------------------------------------------------ quota mapping
#[test]
fn quota_texts_are_recognised_and_others_are_not() {
for q in [
"You've reached the limit on the number of vCPUs you can run concurrently",
"You\u{2019}ve reached the limit on the number of tasks you can run concurrently",
"LimitExceededException: too many",
"Service quota exceeded for Fargate On-Demand vCPU",
"limit exceeded",
] {
assert!(looks_like_quota(q), "{q}");
}
for not in [
"Capacity is unavailable at this time. Please try again later",
"AccessDeniedException: not authorized",
"CannotPullContainerError",
] {
assert!(!looks_like_quota(not), "{not}");
}
}
#[test]
fn quota_mapping_turns_other_errors_into_quota_errors_and_leaves_the_rest() {
let q = quota_or(AwsError::Other {
message: "ClientException: You've reached the limit on the number of vCPUs".into(),
});
assert!(is_quota_error(&q));
// The frontend's `quota_exceeded` kind (lib/types.ts).
let v = serde_json::to_value(&q).unwrap();
assert_eq!(v["kind"], "quota_exceeded");
assert!(v["message"].as_str().unwrap().contains("vCPUs"));
let denied = AwsError::AccessDenied {
action: "ecs:RunTask".into(),
message: "limit".into(),
};
assert_eq!(quota_or(denied.clone()), denied);
assert!(!is_quota_error(&AwsError::Other {
message: "x".into()
}));
}
#[test]
fn run_task_failures_become_errors_and_quota_failures_quota_errors() {
let ok = run_task_outcome(vec![info("a", "PROVISIONING")], vec![]).unwrap();
assert_eq!(ok.arn, task_arn("a"));
let err = run_task_outcome(
vec![],
vec![(Some("RESOURCE:ENI".into()), Some("no ENIs left".into()))],
)
.unwrap_err();
assert!(!is_quota_error(&err));
assert!(err.to_string().contains("RESOURCE:ENI"), "{err}");
assert!(err.to_string().contains("no ENIs left"), "{err}");
let err = run_task_outcome(
vec![],
vec![(
Some("You've reached the limit on the number of tasks you can run concurrently".into()),
None,
)],
)
.unwrap_err();
assert!(is_quota_error(&err));
let err = run_task_outcome(vec![], vec![]).unwrap_err();
assert!(err.to_string().contains("no task"), "{err}");
}
// ------------------------------------------------------------ wait
fn timing() -> WaitTiming {
WaitTiming {
interval: Duration::from_secs(5),
timeout: Duration::from_secs(360),
}
}
fn logs() -> TaskLogs {
TaskLogs {
group: "/triple-c/dev".into(),
stream_prefix: log_stream_prefix(&owner_key(REGION, USER), PROJECT_ID),
}
}
#[tokio::test(start_paused = true)]
async fn wait_polls_until_running_with_the_exec_agent_and_a_runtime_id() {
let mut half = info("t1", "RUNNING");
half.runtime_id = Some("rt".into());
let api = Fake::default().describing(vec![
vec![info("t1", "PROVISIONING")],
vec![info("t1", "PENDING")],
vec![half],
vec![ready("t1")],
]);
let seen = Mutex::new(Vec::new());
let t0 = tokio::time::Instant::now();
let got = wait_ready(
&api,
&cluster(),
&task_arn("t1"),
&logs(),
timing(),
|t: &TaskInfo| seen.lock().unwrap().push(t.last_status.clone()),
)
.await
.unwrap();
assert_eq!(got, ready("t1"));
assert_eq!(
*seen.lock().unwrap(),
["PROVISIONING", "PENDING", "RUNNING", "RUNNING"]
);
assert_eq!(t0.elapsed(), Duration::from_secs(15));
}
#[tokio::test(start_paused = true)]
async fn a_task_that_stops_while_waiting_reports_its_reason_and_last_log_lines() {
let mut stopped = info("t1", "STOPPED");
stopped.stop_code = Some("EssentialContainerExited".into());
stopped.stopped_reason = Some("Essential container in task exited".into());
let api = Fake {
logs: vec!["Triple-C: starting".into(), "fatal: boom".into()],
..Default::default()
}
.describing(vec![vec![info("t1", "PENDING")], vec![stopped]]);
let err = wait_ready(
&api,
&cluster(),
&task_arn("t1"),
&logs(),
timing(),
|_: &TaskInfo| {},
)
.await
.unwrap_err();
let text = err.to_string();
assert!(
text.contains("Essential container in task exited"),
"{text}"
);
assert!(text.contains("Triple-C: starting\nfatal: boom"), "{text}");
let reqs = api.log_requests.lock().unwrap();
assert_eq!(
*reqs,
[(
"/triple-c/dev".to_string(),
format!("{}/workspace/t1", logs().stream_prefix),
50
)]
);
}
#[tokio::test(start_paused = true)]
async fn a_task_being_stopped_fails_the_wait_at_once() {
let mut stopping = info("t1", "RUNNING");
stopping.desired_status = "STOPPED".into();
stopping.stop_code = Some("SpotInterruption".into());
stopping.stopped_reason = Some("Your Spot Task was interrupted.".into());
let api = Fake::default().describing(vec![vec![stopping]]);
let err = wait_ready(
&api,
&cluster(),
&task_arn("t1"),
&logs(),
timing(),
|_: &TaskInfo| {},
)
.await
.unwrap_err();
assert!(
err.to_string().contains("Your Spot Task was interrupted."),
"{err}"
);
}
#[tokio::test(start_paused = true)]
async fn a_task_that_never_gets_ready_times_out_with_its_last_status() {
let api = Fake::default().describing(vec![vec![info("t1", "PENDING")]]);
let t0 = tokio::time::Instant::now();
let err = wait_ready(
&api,
&cluster(),
&task_arn("t1"),
&logs(),
timing(),
|_: &TaskInfo| {},
)
.await
.unwrap_err();
assert!(t0.elapsed() >= Duration::from_secs(360));
assert!(t0.elapsed() < Duration::from_secs(366));
let text = err.to_string();
assert!(text.contains("6 minutes"), "{text}");
assert!(text.contains("PENDING"), "{text}");
}
#[tokio::test(start_paused = true)]
async fn a_task_that_vanishes_is_an_error_not_a_hang() {
let api = Fake::default().describing(vec![vec![]]);
let err = wait_ready(
&api,
&cluster(),
&task_arn("t1"),
&logs(),
timing(),
|_: &TaskInfo| {},
)
.await
.unwrap_err();
assert!(err.to_string().contains("no longer"), "{err}");
}
#[test]
fn the_default_wait_is_five_seconds_for_six_minutes() {
let t = WaitTiming::default();
assert_eq!(t.interval, Duration::from_secs(5));
assert_eq!(t.timeout, Duration::from_secs(360));
}
// ------------------------------------------------------------ stop / logs
#[tokio::test]
async fn stop_passes_the_reason() {
let api = Fake::default();
api.stop_task(&cluster(), &task_arn("t1"), STOP_REASON)
.await
.unwrap();
assert_eq!(
api.calls(),
[format!("stop {} Stopped from Triple-C", task_arn("t1"))]
);
}
#[test]
fn the_log_stream_is_prefix_container_task_id() {
assert_eq!(
task_log_stream("abc/p", &task_arn("0123abcd")),
"abc/p/workspace/0123abcd"
);
}
#[tokio::test]
async fn stopped_among_reports_stopped_and_vanished_tasks_only() {
let api = Fake::default().describing(vec![vec![
ready("up"),
info("down", "STOPPED"),
info("pending", "PENDING"),
]]);
let arns = vec![
task_arn("up"),
task_arn("down"),
task_arn("pending"),
task_arn("gone"),
];
let mut got = stopped_among(&api, &cluster(), &arns).await.unwrap();
got.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(
got,
[
(task_arn("down"), StopKind::Exited(None)),
(task_arn("gone"), StopKind::Exited(None)),
]
);
}
// ------------------------------------------------------------ classify
#[test]
fn classify_stop_follows_the_spec_table() {
let with = |code: Option<&str>, reason: Option<&str>| TaskInfo {
stop_code: code.map(String::from),
stopped_reason: reason.map(String::from),
..info("t", "STOPPED")
};
assert_eq!(
classify_stop(&with(Some("EssentialContainerExited"), Some("Essential container in task exited"))),
StopKind::Exited(Some("Essential container in task exited".into()))
);
// The entrypoint's idle stop exits with its own status.
let idle = TaskInfo { exit_code: Some(IDLE_EXIT_CODE), ..with(Some("EssentialContainerExited"), None) };
assert_eq!(classify_stop(&idle), StopKind::Idle);
let crashed = TaskInfo { exit_code: Some(137), ..with(Some("EssentialContainerExited"), None) };
assert_eq!(classify_stop(&crashed), StopKind::Exited(None));
// Only with that stop code: a user stop of a task that exits 75 is a user stop.
let user = TaskInfo { exit_code: Some(IDLE_EXIT_CODE), ..with(Some("UserInitiated"), None) };
assert_eq!(classify_stop(&user), StopKind::UserInitiated);
assert_eq!(
classify_stop(&with(Some("SpotInterruption"), None)),
StopKind::Spot
);
assert_eq!(
classify_stop(&with(
Some("TaskFailedToStart"),
Some("CannotPullContainerError: nope")
)),
StopKind::FailedToStart("CannotPullContainerError: nope".into())
);
assert!(matches!(
classify_stop(&with(Some("TaskFailedToStart"), None)),
StopKind::FailedToStart(r) if !r.is_empty()
));
assert_eq!(
classify_stop(&with(Some("UserInitiated"), Some("Stopped from Triple-C"))),
StopKind::UserInitiated
);
assert_eq!(
classify_stop(&with(Some("TerminationNotice"), None)),
StopKind::Exited(None)
);
assert_eq!(
classify_stop(&with(Some("ServiceSchedulerInitiated"), None)),
StopKind::Exited(None)
);
assert_eq!(classify_stop(&with(None, Some(" "))), StopKind::Exited(None));
}
// ------------------------------------------------------------ SDK mapping
#[test]
fn task_info_reads_the_workspace_container_and_its_exec_agent() {
let task = Task::builder()
.task_arn(task_arn("t1"))
.last_status("RUNNING")
.desired_status("RUNNING")
.health_status(aws_sdk_ecs::types::HealthStatus::Healthy)
.stop_code(TaskStopCode::SpotInterruption)
.stopped_reason("r")
.task_definition_arn(td_arn(3))
.containers(
Container::builder()
.name("sidecar")
.runtime_id("wrong")
.exit_code(1)
.build(),
)
.containers(
Container::builder()
.name("workspace")
.runtime_id("rt-1")
.exit_code(75)
.managed_agents(
ManagedAgent::builder()
.name(ManagedAgentName::ExecuteCommandAgent)
.last_status("RUNNING")
.build(),
)
.build(),
)
.tags(Tag::builder().key("triple-c:owner").value(USER).build())
.tags(
Tag::builder()
.key("triple-c:project")
.value(PROJECT_ID)
.build(),
)
.build();
let i = task_info(&task);
assert_eq!(i.arn, task_arn("t1"));
assert_eq!(i.health.as_deref(), Some("HEALTHY"));
assert_eq!(i.stop_code.as_deref(), Some("SpotInterruption"));
assert_eq!(i.runtime_id.as_deref(), Some("rt-1"));
assert!(i.exec_agent_running);
assert_eq!(i.exit_code, Some(75), "the workspace container's, not the sidecar's");
assert_eq!(i.owner.as_deref(), Some(USER));
assert_eq!(i.project.as_deref(), Some(PROJECT_ID));
let bare = task_info(&Task::builder().task_arn("x").last_status("PENDING").build());
assert!(!bare.exec_agent_running);
assert_eq!(bare.runtime_id, None);
assert_eq!(bare.exit_code, None);
assert_eq!(bare.owner, None);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+351
View File
@@ -0,0 +1,351 @@
//! The OpenTofu modules Triple-C ships (`aws/terraform/*` at the repo root)
//! and the names the modules and the app agree on.
//!
//! Every name a module or a phase-4 command uses — environment, parameter,
//! owner key, per-user roles, state bucket and keys — is built here and
//! nowhere else. The tests parse the shipped HCL and pin it to these
//! constants: versions, default tags, the record's fields, no inbound access
//! and no endpoint overrides.
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::models::host_connection::validate_supported_region;
/// `locals.template_version` (and the `triple-c:template-version` default
/// tag) of `aws/terraform/environment`.
pub const ENVIRONMENT_MODULE_VERSION: u32 = 1;
/// The same for the user-onboarding module.
#[allow(dead_code)] // the onboarding module's version; checked against its HCL by a test
pub const ONBOARD_MODULE_VERSION: u32 = 1;
/// The permissions boundary every module-created role carries (no path).
/// Equal to [`super::policies::PERMISSIONS_BOUNDARY_NAME`]; a test checks it.
pub const BOUNDARY_POLICY_NAME: &str = "triple-c-permissions-boundary";
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ModuleKind {
Environment,
UserOnboard,
PermissionsBoundary,
}
macro_rules! module {
($dir:literal: $($file:literal),+ $(,)?) => {
&[$(($file, include_str!(concat!("../../../../aws/terraform/", $dir, "/", $file)))),+]
};
}
const ENVIRONMENT_FILES: &[(&str, &str)] = module!("environment":
"versions.tf",
"variables.tf",
"main.tf",
"network.tf",
"outputs.tf",
".terraform.lock.hcl",
);
const USER_ONBOARD_FILES: &[(&str, &str)] = module!("user-onboard":
"versions.tf",
"variables.tf",
"main.tf",
"iam.tf",
"efs.tf",
"outputs.tf",
".terraform.lock.hcl",
);
const PERMISSIONS_BOUNDARY_FILES: &[(&str, &str)] = module!("permissions-boundary":
"versions.tf",
"variables.tf",
"main.tf",
"outputs.tf",
".terraform.lock.hcl",
);
/// The module's files as (file name relative to the module directory,
/// contents), lock file included, for writing into a work or bundle folder.
pub fn module_files(kind: ModuleKind) -> &'static [(&'static str, &'static str)] {
match kind {
ModuleKind::Environment => ENVIRONMENT_FILES,
ModuleKind::UserOnboard => USER_ONBOARD_FILES,
ModuleKind::PermissionsBoundary => PERMISSIONS_BOUNDARY_FILES,
}
}
/// `^[a-z][a-z0-9-]{0,19}$`, not ending in `-`, with no `--` — the same rule
/// as the module's `environment_name` validation.
pub fn validate_environment_name(name: &str) -> Result<String, String> {
let b = name.as_bytes();
let ok = !b.is_empty()
&& b.len() <= 20
&& b[0].is_ascii_lowercase()
&& b.iter()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-')
&& !name.ends_with('-')
&& !name.contains("--");
if ok {
Ok(name.to_string())
} else {
Err(format!(
"\"{}\" is not a valid environment name: use 1 to 20 lowercase letters, digits and \
single hyphens, starting with a letter and not ending in a hyphen",
name.escape_debug()
))
}
}
/// An environment's ECS cluster name (the environment module's).
pub fn environment_cluster_name(env: &str) -> String {
format!("triple-c-{env}")
}
/// An environment's CloudWatch log group (the environment module's).
pub fn environment_log_group(env: &str) -> String {
format!("/triple-c/{env}")
}
/// The SSM parameter holding an environment's record.
pub fn environment_parameter_name(env: &str) -> String {
format!("/triple-c/environments/{env}")
}
/// The first 12 hex characters of `sha256("<region>:<aws:userid>")`. A
/// `UserId` holds a `:`, which IAM role names reject, so per-user names use
/// this instead. The region is part of it because role names are
/// account-global while environments are regional: the same user in the same
/// environment name in two regions gets two sets of roles.
pub fn owner_key(region: &str, user_id: &str) -> String {
Sha256::digest(format!("{region}:{user_id}").as_bytes())
.iter()
.take(6)
.map(|b| format!("{b:02x}"))
.collect()
}
/// A user's (task role, execution role) names. At most 47 characters.
pub fn user_role_names(env: &str, owner_key: &str) -> (String, String) {
(
format!("triple-c-{env}-{owner_key}-task"),
format!("triple-c-{env}-{owner_key}-exec"),
)
}
/// Whether a project id is used in names as it is: 1 to 64 characters of
/// `[A-Za-z0-9-]`. Every UUID (how the app makes ids) is.
fn is_clean_project_id(id: &str) -> bool {
(1..=64).contains(&id.len()) && id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
}
/// 16 hex characters of `sha256(id)`.
fn project_id_hash(id: &str) -> String {
Sha256::digest(id.as_bytes())
.iter()
.take(8)
.map(|b| format!("{b:02x}"))
.collect()
}
/// A project id as a name component, safe in a secret name, an EFS path and
/// a log stream. A clean id (see [`is_clean_project_id`]) is used as is.
/// Anything else — only a hand-edited `projects.json` has one — becomes its
/// first 40 characters with everything outside `[A-Za-z0-9-]` replaced by
/// `-`, then `_` and a hash of the whole id. Clean ids never contain `_`, so
/// the two forms can't collide, and ids that sanitise alike (`a/b`, `a:b`)
/// still differ by the hash.
fn project_component(project_id: &str) -> String {
if is_clean_project_id(project_id) {
return project_id.to_string();
}
let prefix: String = project_id
.chars()
.take(40)
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect();
format!("{prefix}_{}", project_id_hash(project_id))
}
/// A project's ECS task definition family:
/// `triple-c-<env>-<owner_key>-<first 12 of the project id>` (an id that
/// isn't clean contributes `_` and 11 hex of its hash instead). At most 55
/// characters of `[A-Za-z0-9_-]`. Other users can register revisions into
/// any `triple-c-*` family, so a family is never trusted by name alone (see
/// `aws::workspace`), and two of a user's projects could share 12 leading
/// characters, so a family's tasks and revisions are told apart by their
/// `triple-c:project` tag.
pub fn task_family(env: &str, owner_key: &str, project_id: &str) -> String {
let id: String = if is_clean_project_id(project_id) {
project_id.chars().take(12).collect()
} else {
format!("_{}", &project_id_hash(project_id)[..11])
};
format!("triple-c-{env}-{owner_key}-{id}")
}
/// The project's Secrets Manager secret (P1): `triple-c/<env>/<owner_key>/<project id>`.
pub fn project_secret_name(env: &str, owner_key: &str, project_id: &str) -> String {
format!(
"triple-c/{env}/{owner_key}/{}",
project_component(project_id)
)
}
/// The root of the project's EFS access point (P4), at most 90 characters.
pub fn access_point_root(owner_key: &str, project_id: &str) -> String {
format!("/triple-c/{owner_key}/{}", project_component(project_id))
}
/// The container's `awslogs-stream-prefix`; ECS names the stream
/// `<prefix>/workspace/<task id>`.
pub fn log_stream_prefix(owner_key: &str, project_id: &str) -> String {
format!("{owner_key}/{}", project_component(project_id))
}
/// The awslogs stream of a task's `workspace` container:
/// `<prefix>/<container>/<task-id>`, the task id being the last segment of
/// its ARN.
pub fn task_log_stream(stream_prefix: &str, task_arn: &str) -> String {
let task_id = task_arn.rsplit('/').next().unwrap_or(task_arn);
format!("{stream_prefix}/workspace/{task_id}")
}
/// The `creation_token` of the user's file system (the onboarding module's
/// `local.name`).
pub fn user_file_system_token(env: &str, owner_key: &str) -> String {
format!("triple-c-{env}-{owner_key}")
}
/// The per-account, per-region state bucket.
pub fn state_bucket_name(account: &str, region: &str) -> String {
format!("triple-c-tfstate-{account}-{region}")
}
/// One deployment, i.e. one state object in the bucket (D9).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Deployment<'a> {
/// The environment module for this environment name.
Environment(&'a str),
/// A user's onboarding module in an environment.
User { env: &'a str, owner_key: &'a str },
/// The account's permissions-boundary module.
Boundary,
}
/// A deployment's state object in [`state_bucket_name`] (D9); the lock is the
/// same key plus `.tflock`.
pub fn state_key(d: Deployment) -> String {
match d {
Deployment::Environment(env) => format!("environments/{env}/terraform.tfstate"),
Deployment::User { env, owner_key } => {
format!("users/{env}/{owner_key}/terraform.tfstate")
}
Deployment::Boundary => "boundary/terraform.tfstate".to_string(),
}
}
/// How an environment's tasks reach the network (D4).
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "mode", rename_all = "kebab-case")]
pub enum NetworkChoice {
/// A new VPC with two public subnets; tasks get a public IP. No NAT, $0.
Public,
/// A new VPC with private subnets behind one NAT gateway (~$33/month).
PrivateNat,
/// The user's own VPC and exactly two subnets.
Existing {
vpc_id: String,
subnet_ids: Vec<String>,
assign_public_ip: bool,
},
}
/// `vpc-` / `subnet-` followed by 8 or 17 lowercase hex characters.
fn is_aws_id(id: &str, prefix: &str) -> bool {
id.strip_prefix(prefix).is_some_and(|hex| {
(hex.len() == 8 || hex.len() == 17)
&& hex.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
})
}
/// The environment module's `terraform.tfvars.json`. Applies the same rules
/// as the module's variable validations, so a bad value fails here first.
pub fn environment_vars(
env: &str,
region: &str,
network: &NetworkChoice,
) -> Result<serde_json::Value, String> {
let env = validate_environment_name(env)?;
let region = validate_supported_region(region)?;
let (mode, vpc_id, subnet_ids, assign_public_ip) = match network {
NetworkChoice::Public => ("public", String::new(), Vec::new(), false),
NetworkChoice::PrivateNat => ("private-nat", String::new(), Vec::new(), false),
NetworkChoice::Existing {
vpc_id,
subnet_ids,
assign_public_ip,
} => {
if !is_aws_id(vpc_id, "vpc-") {
return Err(format!("\"{}\" is not a VPC id", vpc_id.escape_debug()));
}
if subnet_ids.len() != 2 {
return Err(format!(
"An existing network needs exactly 2 subnets (in different availability \
zones), not {}",
subnet_ids.len()
));
}
if let Some(bad) = subnet_ids.iter().find(|s| !is_aws_id(s, "subnet-")) {
return Err(format!("\"{}\" is not a subnet id", bad.escape_debug()));
}
if subnet_ids[0] == subnet_ids[1] {
return Err("The two subnets must be different".into());
}
(
"existing",
vpc_id.clone(),
subnet_ids.clone(),
*assign_public_ip,
)
}
};
Ok(json!({
"environment_name": env,
"region": region,
"network_mode": mode,
"vpc_id": vpc_id,
"subnet_ids": subnet_ids,
"assign_public_ip": assign_public_ip,
}))
}
/// The onboarding module's `terraform.tfvars.json` for the user whose
/// `aws:userid` is `owner_id`. Bedrock stays on the workspace user's own
/// credentials unless a later setting opts the task role in.
pub fn onboarding_vars(env: &str, region: &str, owner_id: &str) -> serde_json::Value {
json!({
"environment_name": env,
"region": region,
"owner_id": owner_id,
"owner_key": owner_key(region, owner_id),
"bedrock_on_task_role": false,
})
}
/// The boundary module's `terraform.tfvars.json`: the shipped boundary
/// document itself, so the module holds no second copy.
pub fn boundary_vars(region: &str) -> serde_json::Value {
json!({
"region": region,
"policy_json": super::policies::permissions_boundary_json(),
})
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+143 -35
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.
@@ -15,6 +16,12 @@ use crate::AppState;
/// non-`Running` status carrying an explanation rather than an error, so the
/// pane always has something specific to say. This is host-side only — no
/// container recreation is involved either way.
///
/// Either way the choice is persisted, so it survives an app restart. This is
/// the only caller allowed to write `false`: every other path to
/// [`BrowserViewManager::stop`](crate::browser_view::BrowserViewManager::stop)
/// is a teardown rather than the user changing their mind. Enabling persists
/// inside `start`, which is the single funnel for it.
#[tauri::command]
pub async fn set_browser_view_enabled(
project_id: String,
@@ -23,27 +30,71 @@ pub async fn set_browser_view_enabled(
state: State<'_, AppState>,
) -> Result<BrowserViewStatus, String> {
if !enabled {
// Persist first, then tear down: the supervisor's own teardown emit
// reads this flag back out of the store, and reading it mid-stop would
// announce a view that is going away as still enabled.
//
// But the write's outcome is a *value*, not a branch. A `?` here meant
// that a store with no such project record returned early and
// `manager().stop()` never ran, leaving the supervisor, the proxy and
// the host port up for a project that, as far as the user is concerned,
// just had its view switched off. That state is not hypothetical while
// a session is live — the supervisor's own `store.get()` check in
// [`crate::browser_view`] exists because a record can go away
// underneath it — and before the flag was persisted at all, turning the
// view off always tore the session down.
let persisted = state
.projects_store
.set_browser_view_enabled(&project_id, false);
// Awaits the supervisor, so the host port is released before we return.
manager().stop(&project_id).await;
return Ok(manager().status(&project_id).await);
//
// A failed write is still reported rather than logged and swallowed.
// The resources are gone either way by this point, so surfacing it
// costs nothing that matters, and the failure it describes is one the
// user needs: the stored flag still says *enabled*, so the view comes
// back by itself on the next launch. Returning `Ok` would be a claim
// about persistence that isn't true.
tear_down_then_report(persisted, manager().stop(&project_id)).await?;
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(),
)
.await
}
/// Current status. Cheap: reads in-process state only, never the container.
/// Await `teardown`, then report `persisted`.
///
/// Trivial on purpose, and split out for one reason: it is the whole rule the
/// disable path of [`set_browser_view_enabled`] has to obey — the teardown is
/// unconditional, and a failed persist surfaces only after it has run — and as
/// a free function that rule can be tested without a live `AppState`.
async fn tear_down_then_report(
persisted: Result<(), String>,
teardown: impl std::future::Future<Output = ()>,
) -> Result<(), String> {
teardown.await;
persisted
}
/// Current status. Cheap: the session map in this process plus the stored flag,
/// never the container.
///
/// The two are independent on purpose — this is what the pane reads on mount,
/// and after an app restart the honest answer is "enabled, nothing running".
#[tauri::command]
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
Ok(manager().status(&project_id).await)
pub async fn get_browser_view_status(
project_id: String,
state: State<'_, AppState>,
) -> Result<BrowserViewStatus, String> {
Ok(manager().status(&project_id, enabled_for(&state, &project_id)).await)
}
/// Probe the container for Playwright without starting anything.
@@ -56,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.
@@ -75,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
@@ -93,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.
@@ -110,7 +161,9 @@ pub async fn open_browser_view_popout(
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let status = manager().status(&project_id).await;
let status = manager()
.status(&project_id, enabled_for(&state, &project_id))
.await;
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
return Err(
"The browser view isn't running. Start it before opening it in its own window."
@@ -188,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),
@@ -209,7 +262,9 @@ pub async fn open_page_in_container_browser(
// the user to go and press Start in the Browser tab themselves — and from
// the terminal's URL prompt, with no indication that was even needed.
// Asking for a page *is* asking to watch it, so the viewer comes up too.
let status = manager().status(&project_id).await;
let status = manager()
.status(&project_id, enabled_for(&state, &project_id))
.await;
if status.state != BrowserViewState::Running {
crate::commands::project_commands::emit_progress(
&app_handle,
@@ -219,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(),
)
@@ -229,7 +284,9 @@ pub async fn open_page_in_container_browser(
// From the terminal there is no pane on screen to fill, so the page needs a
// window of its own or it lands somewhere the user isn't looking.
if show_window {
let status = manager().status(&project_id).await;
let status = manager()
.status(&project_id, enabled_for(&state, &project_id))
.await;
if let Some(url) = status.url.as_deref() {
let name = state
.projects_store
@@ -253,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.
@@ -263,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.
@@ -275,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(())
}
@@ -300,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
@@ -311,6 +368,20 @@ pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, S
Ok(popout::match_window(&project_id))
}
/// The project's stored browser-view opt-in.
///
/// The manager holds no copy of this — see
/// [`BrowserViewManager`](crate::browser_view::BrowserViewManager) — so every
/// status call reads it here, the way `get_auth_bridge_status` does. A project
/// that has gone away reads as off, which is the only answer that can be given
/// about a record that no longer exists.
fn enabled_for(state: &State<'_, AppState>, project_id: &str) -> bool {
state
.projects_store
.get(project_id)
.is_some_and(|p| p.browser_view_enabled)
}
/// The project's container, or a sentence saying why there isn't one.
///
/// Every command here needs a *running* container, and every one of them used
@@ -321,26 +392,63 @@ 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)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
/// The regression: turning the view off must not leave the supervisor, the
/// proxy and the host port running just because the project record could
/// not be written — which is exactly what a missing record did.
#[tokio::test]
async fn a_failed_persist_does_not_skip_the_teardown() {
let torn_down = AtomicBool::new(false);
let result = tear_down_then_report(Err("Project x not found".to_string()), async {
torn_down.store(true, Ordering::SeqCst);
})
.await;
assert!(
torn_down.load(Ordering::SeqCst),
"the session must be torn down even when the store write failed"
);
assert_eq!(
result.err().as_deref(),
Some("Project x not found"),
"and the write failure must still reach the caller, not be swallowed"
);
}
#[tokio::test]
async fn a_successful_persist_reports_success_after_the_teardown() {
let torn_down = AtomicBool::new(false);
let result = tear_down_then_report(Ok(()), async {
torn_down.store(true, Ordering::SeqCst);
})
.await;
assert!(torn_down.load(Ordering::SeqCst));
assert!(result.is_ok());
}
}
+9 -7
View File
@@ -36,7 +36,7 @@
use serde::{Deserialize, Serialize};
use crate::docker::exec::exec_oneshot;
use crate::host::ContainerTarget;
/// Marks the JSON payload in the probe's stdout, so unrelated chatter on the
/// same stream (npm notices, Node warnings) can't be mistaken for the result.
@@ -277,12 +277,14 @@ impl PlaywrightDetection {
/// Playwright installed with `npm i -g`, or merely *run* once through
/// `npx @playwright/mcp`, is found as readily as one in
/// `/workspace/node_modules`.
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
let output = exec_oneshot(
container_id,
vec!["node".to_string(), "-e".to_string(), PROBE.to_string()],
)
.await?;
pub async fn detect(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
let output = container
.exec_oneshot(vec![
"node".to_string(),
"-e".to_string(),
PROBE.to_string(),
])
.await?;
parse_probe_output(&output)
}
+43 -36
View File
@@ -67,9 +67,7 @@ use serde::Serialize;
use tauri::AppHandle;
use crate::commands::project_commands::emit_progress;
use crate::docker::exec::{
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
};
use crate::host::{AttachedExec, ContainerTarget, ExecSpec};
use super::detect::{self, PlaywrightDetection};
@@ -240,7 +238,7 @@ pub struct BrowserSetupOutcome {
pub async fn install_packages(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
) -> Result<BrowserSetupOutcome, String> {
emit_progress(
app,
@@ -248,7 +246,7 @@ pub async fn install_packages(
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
);
let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
let mut step = npm_install(app, project_id, container, &[VIEWER_PACKAGE]).await?;
if step.exit_code != 0 {
return Err(format!(
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
@@ -265,9 +263,9 @@ pub async fn install_packages(
// see the note on `npm_install`. The pin can only be read after the first
// install has written the manifest, which is why this stays two commands
// rather than one.
let spec = pinned_playwright_spec(container_id).await;
let spec = pinned_playwright_spec(container).await;
emit_progress(app, project_id, &format!("Installing {}…", spec));
let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
let second = npm_install(app, project_id, container, &[VIEWER_PACKAGE, &spec]).await?;
if second.exit_code != 0 {
return Err(format!(
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
@@ -279,7 +277,7 @@ pub async fn install_packages(
step.log = merge_logs(step.log, second.log);
emit_progress(app, project_id, "Re-checking what the container has…");
let detection = detect::detect(container_id).await?;
let detection = detect::detect(container).await?;
// A probe that still finds something missing after a successful npm run is
// the interesting case, so it is surfaced rather than swallowed. And a
@@ -331,7 +329,7 @@ pub async fn install_packages(
async fn npm_install(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
specs: &[&str],
) -> Result<StepResult, String> {
let mut cmd = vec![
@@ -348,7 +346,7 @@ async fn npm_install(
run_step(
app,
project_id,
container_id,
container,
"claude",
INSTALL_DIR,
cmd,
@@ -363,20 +361,20 @@ async fn npm_install(
/// Read from the manifest npm just wrote rather than guessed, and falling back
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
/// install something, not nothing.
async fn pinned_playwright_spec(container_id: &str) -> String {
async fn pinned_playwright_spec(container: &ContainerTarget) -> String {
let script = format!(
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
process.stdout.write(d.playwright||'');}}catch(e){{}}",
INSTALL_DIR
);
let (out, _code) = exec_oneshot_as(
container_id,
"claude",
vec!["node".to_string(), "-e".to_string(), script],
Vec::new(),
)
.await
.unwrap_or_default();
let (out, _code) = container
.exec_oneshot_as(
"claude",
vec!["node".to_string(), "-e".to_string(), script],
Vec::new(),
)
.await
.unwrap_or_default();
let version: &str = out.trim();
// A version, not a range or a URL: anything else goes to the fallback
@@ -411,10 +409,10 @@ fn merge_logs(first: String, second: String) -> String {
pub async fn install_browser(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
target: BrowserTarget,
) -> Result<BrowserSetupOutcome, String> {
let before = detect::detect(container_id).await?;
let before = detect::detect(container).await?;
let Some(cli) = before.playwright_cli.clone() else {
return Err(
"Playwright isn't installed in this container yet, so there is nothing to install a \
@@ -458,7 +456,7 @@ pub async fn install_browser(
project_id,
"Step 1/3 — checking whether this container already has the browser system libraries…",
);
let state = check_libraries(container_id, &cli, target).await;
let state = check_libraries(container, &cli, target).await;
match &state {
LibraryState::Present => {
emit_progress(
@@ -482,7 +480,7 @@ pub async fn install_browser(
let deps = run_step(
app,
project_id,
container_id,
container,
"root",
"/tmp",
vec![
@@ -530,7 +528,7 @@ pub async fn install_browser(
let dl = run_step(
app,
project_id,
container_id,
container,
user,
workdir,
vec![
@@ -559,7 +557,7 @@ pub async fn install_browser(
project_id,
&format!("Step 3/3 — checking that {} actually launches…", target.cli_name()),
);
let verdict = verify_launch(container_id, &before, target).await;
let verdict = verify_launch(container, &before, target).await;
push_section(&mut log, &verdict.detail);
if !verdict.ok {
warning = merge(
@@ -590,7 +588,7 @@ pub async fn install_browser(
}
emit_progress(app, project_id, "Re-checking what the container has…");
let detection = detect::detect(container_id).await?;
let detection = detect::detect(container).await?;
Ok(BrowserSetupOutcome {
detection,
@@ -642,9 +640,12 @@ impl LibraryState {
/// dpkg's status file. A *missing* package on such an image is simply not in
/// any index, so apt fails, Playwright reports the failure, and this returns
/// [`LibraryState::Unknown`] — which installs, which is the right answer.
async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState {
let run = exec_oneshot_as(
container_id,
async fn check_libraries(
container: &ContainerTarget,
cli: &str,
target: BrowserTarget,
) -> LibraryState {
let run = container.exec_oneshot_as(
"root",
vec![
"node".to_string(),
@@ -708,7 +709,7 @@ struct LaunchVerdict {
/// Launch the browser headless, load one page, and close it. Seconds, and it is
/// the only thing that distinguishes "downloaded" from "usable".
async fn verify_launch(
container_id: &str,
container: &ContainerTarget,
detection: &PlaywrightDetection,
target: BrowserTarget,
) -> LaunchVerdict {
@@ -724,8 +725,7 @@ async fn verify_launch(
};
let dir = manifest.trim_end_matches("package.json").trim_end_matches('/');
let run = exec_oneshot_as(
container_id,
let run = container.exec_oneshot_as(
"claude",
vec![
"node".to_string(),
@@ -884,13 +884,13 @@ impl StepResult {
/// Run one command in the container, streaming every line it prints to the pane
/// as `container-progress` and keeping the tail for the result.
///
/// Uses `create_attached_exec_as` — the single attached-exec path — rather than
/// Uses [`ContainerTarget::attach`] — the single attached-exec path — rather than
/// `exec_oneshot`, because these commands run for minutes and the point is that
/// the user can watch them.
async fn run_step(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
user: &str,
workdir: &str,
cmd: Vec<String>,
@@ -900,7 +900,14 @@ async fn run_step(
exec_id,
mut output,
input,
} = create_attached_exec_as(container_id, cmd, false, user, workdir).await?;
} = container
.attach(
ExecSpec::new(cmd)
.tty(false)
.user(user)
.working_dir(Some(workdir)),
)
.await?;
// Nothing is ever written to this exec. Closing stdin means anything that
// would prompt sees EOF and gives up, instead of waiting for a person who
@@ -944,7 +951,7 @@ async fn run_step(
)),
Ok(Err(e)) => Err(e),
Ok(Ok(())) => Ok(StepResult {
exit_code: wait_for_exec_exit(&exec_id).await.unwrap_or(0),
exit_code: container.wait_exec_exit(&exec_id).await.unwrap_or(0),
log,
}),
}
+137 -88
View File
@@ -34,15 +34,23 @@
//!
//! ## Lifecycle
//!
//! Off by default and per-project opt-in, exactly like `auth_bridge_enabled`.
//! Off by default and per-project opt-in. The opt-in itself is
//! [`Project::browser_view_enabled`](crate::models::Project), persisted like
//! `auth_bridge_enabled` and read from the store on demand rather than cached
//! here — so the pane comes back the way it was left. What does *not* persist
//! is the session: nothing starts a viewer on app start, so a project left
//! enabled reports `enabled: true` with a state of `Off` until the pane asks
//! for one. That is deliberate, and the reason the flag and the session are
//! separate ideas — see [`BrowserViewManager::status`].
//!
//! One supervisor task per session owns the proxy and the viewer process, and it
//! is the only thing that tears them down, so every way a session can end funnels
//! through one code path:
//!
//! | Trigger | Path |
//! |---|---|
//! | Turned off in the UI | `set_browser_view_enabled(false)` → [`BrowserViewManager::stop`] |
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
//! | Container stopped, by the UI or otherwise | supervisor's `is_running` check |
//! | Project deleted | supervisor's `store.get()` check |
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
//! | Viewer died in the container | supervisor's periodic HTTP liveness probe |
@@ -59,7 +67,10 @@
//! orphan is reachable on container loopback only: the host-side port dies with
//! the app, and [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] is a constant
//! precisely so the bridge will not mirror an orphan the next time the app
//! starts. The next [`BrowserViewManager::start`] reclaims it.
//! starts. The next [`BrowserViewManager::start`] reclaims it — and since the
//! opt-in is now durable, the restarted app says `enabled` with nothing running,
//! which is exactly the state that invites the user to press the button that
//! reclaims it. Nothing reclaims it on its own, because nothing auto-starts.
pub mod commands;
pub mod detect;
@@ -79,8 +90,7 @@ use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use crate::auth_bridge::proc_net::{self, PortFamily};
use crate::docker::container::is_container_running;
use crate::docker::exec::exec_oneshot;
use crate::host::ContainerTarget;
use crate::storage::projects_store::ProjectsStore;
use detect::PlaywrightDetection;
@@ -134,7 +144,10 @@ pub enum BrowserViewState {
#[derive(Debug, Clone, Serialize)]
pub struct BrowserViewStatus {
/// The per-project opt-in. Off by default.
/// The per-project opt-in, read from the persisted project record. Off by
/// default, and true without a `Running` state whenever the view is turned
/// on but has nothing up — a stopped container, or an app that has just
/// restarted and does not auto-start viewers.
pub enabled: bool,
pub state: BrowserViewState,
/// Fully-formed, token-bearing URL for the pane's iframe. Loopback only.
@@ -201,17 +214,20 @@ struct Session {
type SessionMap = Arc<Mutex<HashMap<String, Session>>>;
/// Live sessions, and nothing else.
///
/// The per-project opt-in deliberately is **not** a field here. It lives on
/// the project record as
/// [`browser_view_enabled`](crate::models::Project::browser_view_enabled) and
/// is read from [`ProjectsStore`] at each use, exactly as
/// [`crate::auth_bridge::AuthBridgeManager`] treats `auth_bridge_enabled`:
/// one copy, durable across a restart, and impossible to get out of step with
/// what the Config tab shows. A cached copy here was the previous design and
/// its only observable behaviour was forgetting the user's choice on every
/// app start.
#[derive(Default)]
pub struct BrowserViewManager {
sessions: SessionMap,
/// The per-project opt-in.
///
/// NOTE: in memory only, so it does not survive an app restart. The durable
/// home for this is a `browser_view_enabled: bool` field on
/// `models::Project` (see the report) — `models/project.rs` is out of scope
/// for this change, so the flag lives here and the wiring is otherwise
/// identical to `auth_bridge_enabled`.
enabled: Mutex<std::collections::HashSet<String>>,
next_epoch: AtomicU64,
}
@@ -226,22 +242,15 @@ pub fn manager() -> &'static Arc<BrowserViewManager> {
}
impl BrowserViewManager {
pub async fn is_enabled(&self, project_id: &str) -> bool {
self.enabled.lock().await.contains(project_id)
}
async fn set_enabled(&self, project_id: &str, enabled: bool) {
let mut set = self.enabled.lock().await;
if enabled {
set.insert(project_id.to_string());
} else {
set.remove(project_id);
}
}
/// Current status without touching the container.
pub async fn status(&self, project_id: &str) -> BrowserViewStatus {
let enabled = self.is_enabled(project_id).await;
///
/// `enabled` is passed in rather than looked up, the way
/// [`crate::auth_bridge::AuthBridgeManager::status`] takes it: the flag is
/// the caller's to read from the store, and keeping it out of here is what
/// stops a second copy of it appearing. A project whose view is enabled but
/// whose container is stopped — or whose app has just restarted — reports
/// `enabled: true` with a state of `Off`, which is the honest answer.
pub async fn status(&self, project_id: &str, enabled: bool) -> BrowserViewStatus {
match self.sessions.lock().await.get(project_id) {
Some(session) => BrowserViewStatus {
enabled,
@@ -261,14 +270,22 @@ impl BrowserViewManager {
///
/// Idempotent: a call while a live session exists returns that session's
/// status untouched, so re-opening the tab does not restart the dashboard.
///
/// This is the single funnel for turning the view **on**, so it is also
/// where the durable flag is written — both call sites (the toggle and
/// `open_page_in_container_browser`, which opens a page and then shows it)
/// mean "on", and neither can forget. The **off** direction is not
/// symmetric and must not be: [`Self::stop`] is reached by teardown paths
/// that are not the user changing their mind, so the command owns that
/// write. See [`Self::stop`].
pub async fn start(
&self,
project_id: String,
container_id: String,
container: ContainerTarget,
app: AppHandle,
store: Arc<ProjectsStore>,
) -> Result<BrowserViewStatus, String> {
self.set_enabled(&project_id, true).await;
store.set_browser_view_enabled(&project_id, true)?;
// Bind the answer before acting on it: `status()` takes the same lock,
// and this mutex is not reentrant.
@@ -279,10 +296,10 @@ impl BrowserViewManager {
.get(&project_id)
.is_some_and(|s| !s.supervisor.is_finished());
if already_live {
return Ok(self.status(&project_id).await);
return Ok(self.status(&project_id, true).await);
}
let detection = detect::detect(&container_id).await?;
let detection = detect::detect(&container).await?;
if !detection.is_usable() {
let blocker = detection.blocker().unwrap_or_else(|| {
"Playwright is present but incomplete in this container.".to_string()
@@ -302,15 +319,15 @@ impl BrowserViewManager {
// Doing this before choosing a port also frees the one a previous
// session was using, so sessions don't walk up the range. Best-effort:
// a container with no dashboard makes this a no-op.
let _ = kill_dashboard(&container_id, &cli_entry).await;
let _ = kill_dashboard(&container, &cli_entry).await;
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
let (container_port, entry_path) = start_viewer(&container, &cli_entry).await?;
let token = generate_token();
// `--host 127.0.0.1` is ours to set, so the family is known and there is
// no need to go back to /proc/net to work it out.
let proxy = match BrowserViewProxy::bind(
container_id.clone(),
container.clone(),
container_port,
PortFamily::V4,
token.clone(),
@@ -319,7 +336,7 @@ impl BrowserViewManager {
{
Ok(p) => p,
Err(e) => {
let _ = kill_dashboard(&container_id, &cli_entry).await;
let _ = kill_dashboard(&container, &cli_entry).await;
return Err(e);
}
};
@@ -336,7 +353,7 @@ impl BrowserViewManager {
let (cancel_tx, cancel_rx) = watch::channel(false);
let supervisor = tokio::spawn(supervise(
project_id.clone(),
container_id.clone(),
container,
cli_entry,
container_port,
epoch,
@@ -364,14 +381,21 @@ impl BrowserViewManager {
},
);
let status = self.status(&project_id).await;
let status = self.status(&project_id, true).await;
emit(&app, &project_id, &status);
Ok(status)
}
/// Stop one project's view and wait until its host port has been released.
///
/// Tears the *session* down and deliberately leaves the durable flag alone.
/// Most callers are not the user turning the feature off — a migration
/// removes the container out from under a running view
/// (`migration_commands`), and the container can stop for any other reason
/// — and persisting `false` for those would quietly opt the project out of
/// a feature it never asked to lose. `set_browser_view_enabled(false)` is
/// the one caller that means it, and it writes the flag itself first.
pub async fn stop(&self, project_id: &str) {
self.set_enabled(project_id, false).await;
// Remove under the lock, then release it before awaiting: the
// supervisor takes the same lock to deregister itself on exit.
let session = self.sessions.lock().await.remove(project_id);
@@ -402,7 +426,7 @@ impl BrowserViewManager {
#[allow(clippy::too_many_arguments)]
async fn supervise(
project_id: String,
container_id: String,
container: ContainerTarget,
cli_entry: String,
container_port: u16,
epoch: u64,
@@ -418,7 +442,7 @@ async fn supervise(
log::info!("Browser view: project {} is gone — tearing down", project_id);
break;
}
if !is_container_running(&container_id).await.unwrap_or(false) {
if !container.is_running().await.unwrap_or(false) {
log::info!(
"Browser view: container for project {} is no longer running — tearing down",
project_id
@@ -434,7 +458,7 @@ async fn supervise(
// view never waits out an in-flight exec.
let alive = tokio::select! {
_ = cancel.changed() => break,
res = probe_entry_path(&container_id, container_port) => res.is_ok(),
res = probe_entry_path(&container, container_port) => res.is_ok(),
};
if !alive {
log::warn!(
@@ -452,7 +476,7 @@ async fn supervise(
}
proxy.shutdown().await;
let _ = kill_dashboard(&container_id, &cli_entry).await;
let _ = kill_dashboard(&container, &cli_entry).await;
// Deregister, unless a newer session has already taken this project's slot.
let superseded = {
@@ -483,7 +507,12 @@ async fn supervise(
// longer exists. The session owns it, and this is where the session ends.
let _ = popout::close(&app, &project_id);
let enabled = manager().is_enabled(&project_id).await;
// Straight from the store, like the auth bridge's own teardown emit: the
// session is over, but the project may well still be opted in — a stopped
// container is not a changed mind, and the pane has to show the difference.
let enabled = store
.get(&project_id)
.is_some_and(|p| p.browser_view_enabled);
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
}
@@ -508,7 +537,11 @@ const VIEWER_LOG: &str = "/tmp/triple-c-browser-view.log";
/// redirected to [`VIEWER_LOG`] (both so `exec_oneshot` can return immediately
/// rather than waiting on an inherited stdout, and so a failure has a trail),
/// and readiness is established by [`wait_until_ready`] instead.
async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result<(), String> {
async fn launch_viewer(
container: &ContainerTarget,
cli_entry: &str,
port: u16,
) -> Result<(), String> {
// `NO_UPDATE_NOTIFIER` stops the CLI phoning registry.npmjs.org on every
// launch; the container may have no egress, and we don't want to wait out a
// DNS timeout before the dashboard binds.
@@ -519,13 +552,11 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
port,
VIEWER_LOG
);
exec_oneshot(
container_id,
vec!["sh".to_string(), "-c".to_string(), script],
)
.await
.map(|_| ())
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
container
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
.await
.map(|_| ())
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
}
/// The dashboard singleton is keyed on a hash of the working directory, so
@@ -537,17 +568,15 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true";
/// Stop the dashboard daemon. Verified to free the port and stop answering.
async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result<String, String> {
async fn kill_dashboard(container: &ContainerTarget, cli_entry: &str) -> Result<String, String> {
let script = format!(
"{}; NO_UPDATE_NOTIFIER=1 node {} show --kill",
WORKDIR_PREFIX,
shell_quote(cli_entry)
);
exec_oneshot(
container_id,
vec!["sh".to_string(), "-c".to_string(), script],
)
.await
container
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
.await
}
/// Turn a failed start into something the user can act on.
@@ -574,13 +603,16 @@ fn explain_start_failure(err: &str, log: &str) -> String {
}
/// Tail of the viewer's own output, for a start that didn't come up.
async fn read_viewer_log(container_id: &str) -> String {
exec_oneshot(
container_id,
vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()],
)
.await
.unwrap_or_default()
async fn read_viewer_log(container: &ContainerTarget) -> String {
container
.exec_oneshot(vec![
"tail".to_string(),
"-n".to_string(),
"40".to_string(),
VIEWER_LOG.to_string(),
])
.await
.unwrap_or_default()
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -615,14 +647,17 @@ const MAX_PORT_ATTEMPTS: usize = 3;
/// this layer, and the pane would then front it. What contains that is
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
/// iframe is sandboxed — not this function.
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
async fn start_viewer(
container: &ContainerTarget,
cli_entry: &str,
) -> Result<(u16, String), String> {
let mut tried: Vec<u16> = Vec::new();
let mut last: Option<String> = None;
for _ in 0..MAX_PORT_ATTEMPTS {
// Re-read the listener snapshot each attempt: the port that was free a
// moment ago is exactly the one we may have just lost.
let port = match pick_viewer_port(container_id, &tried).await {
let port = match pick_viewer_port(container, &tried).await {
Ok(p) => p,
Err(e) => {
// Report why the *attempts* failed, not just "nothing free":
@@ -636,21 +671,21 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
};
tried.push(port);
launch_viewer(container_id, cli_entry, port).await?;
launch_viewer(container, cli_entry, port).await?;
// Wait for it to actually answer, and learn the entry URL while we're
// there — see `probe_entry_path` for why that matters. This, not the
// launcher's stdout, is the readiness signal: verified that the
// "Listening on …" line is printed only on the very first start.
match wait_until_ready(container_id, port).await {
match wait_until_ready(container, port).await {
Ok(path) => return Ok((port, path)),
Err(e) => {
let log = read_viewer_log(container_id).await;
let log = read_viewer_log(container).await;
// Always kill before retrying: the dashboard is a singleton, so
// a launcher that came up on some *other* port would otherwise
// make every further attempt a no-op that silently ignores the
// port we asked for.
let _ = kill_dashboard(container_id, cli_entry).await;
let _ = kill_dashboard(container, cli_entry).await;
last = Some(explain_start_failure(&e, &log));
}
}
@@ -661,10 +696,9 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
/// and that this start has not already tried.
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
let text = exec_oneshot(
container_id,
vec![
async fn pick_viewer_port(container: &ContainerTarget, tried: &[u16]) -> Result<u16, String> {
let text = container
.exec_oneshot(vec![
// Absolute path, deliberately, for the same reason the auth bridge
// uses one: `container/Dockerfile` puts a container-writable
// directory first on `PATH`, so a bare `cat` is a name the container
@@ -674,10 +708,9 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
"/usr/bin/cat".to_string(),
"/proc/net/tcp".to_string(),
"/proc/net/tcp6".to_string(),
],
)
.await
.unwrap_or_default();
])
.await
.unwrap_or_default();
let taken = proc_net::parse_loopback_listeners(&text);
VIEWER_PORTS
.clone()
@@ -692,10 +725,10 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
}
/// Poll the viewer until it answers, and return the path the pane should load.
async fn wait_until_ready(container_id: &str, port: u16) -> Result<String, String> {
async fn wait_until_ready(container: &ContainerTarget, port: u16) -> Result<String, String> {
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
loop {
let last = match probe_entry_path(container_id, port).await {
let last = match probe_entry_path(container, port).await {
Ok(path) => return Ok(path),
Err(e) => e,
};
@@ -721,7 +754,7 @@ const PROBE_MARKER: &str = "__TRIPLE_C_BV_PATH__";
/// a redirect — which matters, because a redirect drops the `?token=` the proxy
/// gate wants and would leave a fresh connection to be authorised with nothing.
/// A `200` (no redirect) is fine too; then the entry point is just `/`.
async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, String> {
async fn probe_entry_path(container: &ContainerTarget, port: u16) -> Result<String, String> {
// The request is bounded on both sides. Verified: the dashboard answers a
// bad WebSocket path by holding the socket open forever rather than
// erroring, so "no reply" is a state this probe has to be able to leave —
@@ -736,10 +769,7 @@ async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, Strin
);
let out = tokio::time::timeout(
PROBE_TIMEOUT * 2,
exec_oneshot(
container_id,
vec!["node".to_string(), "-e".to_string(), script],
),
container.exec_oneshot(vec!["node".to_string(), "-e".to_string(), script]),
)
.await
.map_err(|_| "the viewer probe did not return".to_string())??;
@@ -915,6 +945,25 @@ mod tests {
assert!(s.url.is_none());
}
#[tokio::test]
async fn the_opt_in_and_the_live_session_are_separate_answers() {
let manager = BrowserViewManager::default();
// Exactly what the pane reads on mount after an app restart of a
// project that was left enabled: the durable flag says on, and nothing
// auto-starts, so the state is honestly `Off`. The old in-memory flag
// could not express this — it came back `false` and the pane silently
// showed the feature as never having been turned on.
let status = manager.status("p1", true).await;
assert!(status.enabled);
assert_eq!(status.state, BrowserViewState::Off);
assert!(status.url.is_none());
// The flag belongs to the caller, read from the store. The manager
// keeps no copy, so it has nothing to contradict it with.
assert!(!manager.status("p1", false).await.enabled);
}
#[test]
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
let mut d = PlaywrightDetection::default();
+44 -44
View File
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use crate::commands::project_commands::emit_progress;
use crate::docker::exec::exec_oneshot_as;
use crate::host::ContainerTarget;
use super::detect::PlaywrightDetection;
@@ -91,7 +91,7 @@ pub struct PageState {
pub async fn open(
app: &AppHandle,
project_id: &str,
container_id: &str,
container: &ContainerTarget,
detection: &PlaywrightDetection,
url: &str,
viewport: Viewport,
@@ -115,17 +115,17 @@ pub async fn open(
// Reuse a helper that is already up. Relaunching would throw away the
// browser's cookies and storage — which for the auth case means signing in
// again to reach the second page, having just signed in on the first.
if state(container_id).await.ready {
if state(container).await.ready {
emit_progress(app, project_id, "Navigating the container's browser…");
set_viewport(container_id, viewport).await?;
navigate(container_id, url).await?;
if let Some(state) = wait_for_url(container_id, url).await {
set_viewport(container, viewport).await?;
navigate(container, url).await?;
if let Some(state) = wait_for_url(container, url).await {
return Ok(state);
}
// It stopped answering; fall through and start a fresh one.
}
close(container_id).await;
close(container).await;
// Cold start: a browser launch plus a page load, which is the several
// seconds the user would otherwise spend wondering whether the click
// registered.
@@ -150,52 +150,52 @@ pub async fn open(
shell_quote(&script),
HELPER_LOG
);
exec_oneshot_as(
container_id,
"claude",
vec!["sh".to_string(), "-c".to_string(), launcher],
Vec::new(),
)
.await
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
container
.exec_oneshot_as(
"claude",
vec!["sh".to_string(), "-c".to_string(), launcher],
Vec::new(),
)
.await
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
emit_progress(app, project_id, "Waiting for the page to load…");
wait_until_ready(container_id).await
wait_until_ready(container).await
}
/// Resize the open page. Cheap enough to call from a window-resize handler.
pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> {
pub async fn set_viewport(container: &ContainerTarget, viewport: Viewport) -> Result<(), String> {
write_control(
container_id,
container,
serde_json::json!({ "viewport": viewport }).to_string(),
)
.await
}
/// Navigate the open page without relaunching the browser.
pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> {
write_control(container_id, serde_json::json!({ "url": url }).to_string()).await
pub async fn navigate(container: &ContainerTarget, url: &str) -> Result<(), String> {
write_control(container, serde_json::json!({ "url": url }).to_string()).await
}
/// Ask the helper to shut down. Best effort: a container that has none is the
/// normal case, and the caller is usually about to start one anyway.
pub async fn close(container_id: &str) {
let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await;
pub async fn close(container: &ContainerTarget) {
let _ = write_control(container, serde_json::json!({ "close": true }).to_string()).await;
}
/// Current state, or a default when no helper has ever run here.
pub async fn state(container_id: &str) -> PageState {
pub async fn state(container: &ContainerTarget) -> PageState {
let script = format!(
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
STATE_PATH
);
let Ok((out, _)) = exec_oneshot_as(
container_id,
"claude",
vec!["node".to_string(), "-e".to_string(), script],
Vec::new(),
)
.await
let Ok((out, _)) = container
.exec_oneshot_as(
"claude",
vec!["node".to_string(), "-e".to_string(), script],
Vec::new(),
)
.await
else {
return PageState::default();
};
@@ -204,20 +204,20 @@ pub async fn state(container_id: &str) -> PageState {
/// Write the control file through Node rather than a shell redirect, so a URL
/// is never interpreted by `sh`.
async fn write_control(container_id: &str, json: String) -> Result<(), String> {
async fn write_control(container: &ContainerTarget, json: String) -> Result<(), String> {
let script = format!(
"require('fs').writeFileSync('{}',process.argv[1]);",
CONTROL_PATH
);
exec_oneshot_as(
container_id,
"claude",
vec!["node".to_string(), "-e".to_string(), script, json],
Vec::new(),
)
.await
.map(|_| ())
.map_err(|e| format!("Could not reach the browser helper: {}", e))
container
.exec_oneshot_as(
"claude",
vec!["node".to_string(), "-e".to_string(), script, json],
Vec::new(),
)
.await
.map(|_| ())
.map_err(|e| format!("Could not reach the browser helper: {}", e))
}
/// Wait for a *running* helper to report the URL we just asked it for.
@@ -225,10 +225,10 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> {
/// Bounded much tighter than a cold start: the browser is already up, so this
/// is one navigation. `None` means it stopped answering, and the caller starts
/// a fresh helper rather than reporting a page that isn't there.
async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
async fn wait_for_url(container: &ContainerTarget, url: &str) -> Option<PageState> {
let deadline = std::time::Instant::now() + REUSE_TIMEOUT;
loop {
let state = state(container_id).await;
let state = state(container).await;
if state.ready && state.url.as_deref() == Some(url) {
return Some(state);
}
@@ -240,10 +240,10 @@ async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
}
/// Poll the state file until the helper says the page is up, or says why not.
async fn wait_until_ready(container_id: &str) -> Result<PageState, String> {
async fn wait_until_ready(container: &ContainerTarget) -> Result<PageState, String> {
let deadline = std::time::Instant::now() + READY_TIMEOUT;
loop {
let state = state(container_id).await;
let state = state(container).await;
if let Some(error) = state.error.clone() {
return Err(error);
}
+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.
+581
View File
@@ -0,0 +1,581 @@
//! The command census shared by `build.rs` and the `cargo test` suite.
//!
//! `build.rs` pulls this file in with `#[path = "src/command_census.rs"]` and `lib.rs` with
//! `#[cfg(test)] mod command_census;`, so the parser that decides what the Tauri `AppManifest`
//! declares is the parser the tests exercise, and the rules that decide whether the build
//! passes have unit tests. Nothing here may reference the crate: only `std` and `serde_json`
//! (a dependency of both the crate and the build script).
//!
//! Spec: `docs/superpowers/specs/2026-09-22-app-manifest-lockdown-design.md` §3.2.
use std::collections::{BTreeMap, BTreeSet};
/// The command names inside `generate_handler![ … ])` in `lib.rs`, in registration order,
/// duplicates kept (the caller decides whether that is an error). `None` if the block is
/// missing or unterminated.
///
/// Comma-split, not line-split: `// Docker` style comments are stripped from every line first
/// (a whole-line comment strips to nothing; a trailing one leaves the code before it), and the
/// *cleaned* text is then split on `,` so each grant is its own item regardless of how many
/// share a line. A line-split version of this parser shipped first and used
/// `rsplit("::").next()` once *per line*: two commands on one line (`a::x, b::y,`) collapsed to
/// a single item, silently dropping `a::x` — a denied command at runtime with nothing flagging
/// it. Comma-splitting fixes that because it no longer assumes one item per line.
pub fn registered_commands(lib_rs: &str) -> Option<Vec<String>> {
let (_, rest) = lib_rs.split_once("generate_handler![")?;
let (inside, _) = rest.split_once("])")?;
let cleaned: String = inside
.lines()
// Strip a trailing `//` comment (and a whole-line one, which strips to "").
.map(|l| l.split("//").next().unwrap_or(""))
.collect::<Vec<_>>()
.join("\n");
Some(
cleaned
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|s| {
// `a::b::name` → `name`; a bare `name` (no `::`) is its own last segment.
s.rsplit("::").next().map(|n| n.trim().to_string())
})
.filter(|n| !n.is_empty())
.collect(),
)
}
/// `viewer_read_file` → `allow-viewer-read-file`. tauri-utils 2.9.0 (`acl/build.rs:290`)
/// replaces only `_`; permission identifiers may not contain `_`, but the command name inside
/// the generated permission stays snake_case.
pub fn allow_permission(command: &str) -> String {
format!("allow-{}", command.replace('_', "-"))
}
/// The `windows` list of the one capability file that may grant `command`. A command that
/// must be callable from both windows is a design change: make it here, visibly, rather than
/// by widening a capability file.
pub fn expected_windows(command: &str) -> &'static [&'static str] {
if command.starts_with("viewer_") {
&["file-viewer-*"]
} else {
&["main"]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapabilityFile {
pub name: String,
pub windows: Vec<String>,
pub bare: Vec<String>,
}
/// One `capabilities/*.json`, reduced to what the census checks. Plugin and core grants
/// (anything with a `:`) are not this module's business; the exact-set tests in `lib.rs` and
/// `file_viewer/mod.rs` pin those.
pub fn capability_file(name: &str, json: &str) -> Result<CapabilityFile, String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("{name}: not valid JSON: {e}"))?;
// `webviews` would extend the grants to webviews by label (the browser-view pop-out is
// meant to be in no capability), and `remote` would extend them to a remote origin. The
// census reasons about `windows` only, so either key is refused rather than half-checked.
for key in ["webviews", "remote"] {
if value.get(key).is_some() {
return Err(format!(
"{name}: `{key}` is not allowed; capabilities here are scoped by `windows` only"
));
}
}
let windows = value["windows"]
.as_array()
.ok_or_else(|| format!("{name}: `windows` must be an array"))?
.iter()
.map(|w| {
w.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{name}: `windows` entries must be strings"))
})
.collect::<Result<Vec<_>, _>>()?;
let mut bare = Vec::new();
for grant in value["permissions"]
.as_array()
.ok_or_else(|| format!("{name}: `permissions` must be an array"))?
{
let id = match grant {
serde_json::Value::String(s) => s.as_str(),
serde_json::Value::Object(o) => o
.get("identifier")
.and_then(|i| i.as_str())
.ok_or_else(|| format!("{name}: a scoped grant needs a string `identifier`"))?,
_ => return Err(format!("{name}: a grant is a string or an object")),
};
if !id.contains(':') {
bare.push(id.to_string());
}
}
Ok(CapabilityFile { name: name.to_string(), windows, bare })
}
/// Why an entry directly under `capabilities/` cannot be a capability the census reads, or
/// `None` if it is one (a top-level `*.json` file). tauri-build loads `capabilities/**/*` with
/// the extensions `json`, `toml` and (with a feature) `json5`, subdirectories included; the
/// census reads only top-level JSON, so anything else tauri might load is refused rather than
/// left for tauri to grant from unchecked. OS and editor junk, which tauri never loads, is the
/// caller's to skip first (see [`is_os_junk`]).
pub fn stray_capability_entry(name: &str, is_file: bool) -> Option<String> {
if !is_file {
return Some(format!(
"capabilities/{name} is not a regular file; tauri loads capabilities from \
subdirectories too, so every capability must be a top-level capabilities/*.json"
));
}
if name.ends_with(".json") {
return None;
}
Some(format!(
"capabilities/{name} is not a .json file; tauri may load it (it reads .toml and .json5 \
too) but the census cannot check it, so every capability must be a top-level \
capabilities/*.json"
))
}
/// Files the OS or an editor drops next to real ones (`.DS_Store`, `Thumbs.db`, `desktop.ini`,
/// Vim swap files, `name~` backups). tauri-build loads only `json`/`toml`/`json5` from
/// `capabilities/` and `permissions/`, so a junk name with one of those extensions (an Emacs
/// `.#default.json` lock, a macOS `._default.json`) is *not* junk: tauri would try to load it,
/// and the caller must refuse it.
pub fn is_os_junk(name: &str) -> bool {
let loadable = [".json", ".json5", ".toml"].iter().any(|e| name.ends_with(e));
!loadable
&& (matches!(name, ".DS_Store" | "Thumbs.db" | "desktop.ini")
|| name.ends_with(".swp")
|| name.ends_with(".swo")
|| name.ends_with('~'))
}
/// Which files next to `Cargo.toml` tauri reads as its config: `tauri.conf.json[5]`,
/// `Tauri.toml` and the per-platform `tauri.<platform>.conf.json[5]` / `Tauri.<platform>.toml`
/// (tauri-utils `config/parse.rs`). `Some(true)` = JSON the census can read, `Some(false)` = a
/// format it cannot (JSON5/TOML), `None` = not a tauri config file.
pub fn tauri_config_file(name: &str) -> Option<bool> {
if name.starts_with("tauri.") && name.ends_with(".conf.json") {
Some(true)
} else if (name.starts_with("tauri.") && name.ends_with(".conf.json5"))
|| (name.starts_with("Tauri.") && name.ends_with(".toml"))
{
Some(false)
} else {
None
}
}
/// A problem with a tauri config (a `tauri*.conf.json` file, or the `TAURI_CONFIG` JSON that
/// tauri-build merges over it), or `None`. `app.security.capabilities` is refused whenever it
/// is non-empty: an inline object is a capability the census never sees, and a list of
/// identifiers switches every *other* capability file off, which the census also assumes is
/// not happening.
pub fn tauri_config_problem(name: &str, json: &str) -> Option<String> {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => return Some(format!("{name}: not valid JSON: {e}")),
};
match value.pointer("/app/security/capabilities") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::Array(a)) if a.is_empty() => None,
Some(_) => Some(format!(
"{name}: app.security.capabilities is not allowed; every capability lives in a \
top-level capabilities/*.json file, where the census checks it"
)),
}
}
/// Everything that must hold between the handler list and the capability files. Returns every
/// violation rather than the first, so a batch of forgotten grants is one build failure; an
/// empty vector is a pass.
pub fn check(commands: &[String], files: &[CapabilityFile]) -> Vec<String> {
let mut problems = Vec::new();
if commands.is_empty() {
problems.push(
"no commands were parsed out of generate_handler! — an empty AppManifest would \
silently leave every app command ungated"
.to_string(),
);
return problems;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for c in commands {
if !c.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
problems.push(format!("{c:?} is not a command name ([a-z0-9_]+)"));
}
if !seen.insert(c.as_str()) {
problems.push(format!("{c} is registered more than once"));
}
}
let known: BTreeMap<String, &str> =
seen.iter().map(|c| (allow_permission(c), *c)).collect();
for f in files {
let windows: Vec<&str> = f.windows.iter().map(String::as_str).collect();
for id in &f.bare {
match known.get(id) {
Some(command) => {
let want = expected_windows(command);
if windows.as_slice() != want {
problems.push(format!(
"{}: {id} must be granted in the capability file whose windows are \
{want:?}, not {windows:?}",
f.name
));
}
}
None if id.starts_with("deny-") => problems.push(format!(
"{}: {id}: deny-* is global in tauri 2.11 — it would deny the command for \
every window, not just this one; use allow-lists only",
f.name
)),
None if id.starts_with("allow-") => problems.push(format!(
"{}: {id} names no registered command (the identifier is allow-<command> \
with every `_` replaced by `-`)",
f.name
)),
None => problems.push(format!(
"{}: {id}: only allow-<command> app grants are permitted as bare identifiers",
f.name
)),
}
}
}
for c in &seen {
let id = allow_permission(c);
let holders: Vec<&str> = files
.iter()
.filter(|f| f.bare.iter().any(|b| b == &id))
.map(|f| f.name.as_str())
.collect();
match holders.len() {
0 => problems.push(format!(
"{c} is registered but no capability file grants {id}; add it to the file \
whose windows are {:?}",
expected_windows(c)
)),
1 => {}
_ => problems.push(format!(
"{id} is granted in more than one capability file: {holders:?}"
)),
}
}
problems
}
#[cfg(test)]
mod tests {
use super::*;
fn cmds(names: &[&str]) -> Vec<String> {
names.iter().map(|n| n.to_string()).collect()
}
fn file(name: &str, windows: &[&str], bare: &[&str]) -> CapabilityFile {
CapabilityFile {
name: name.to_string(),
windows: windows.iter().map(|w| w.to_string()).collect(),
bare: bare.iter().map(|b| b.to_string()).collect(),
}
}
/// The two files as they must look after the lockdown, for a three-command app.
fn good_files() -> Vec<CapabilityFile> {
vec![
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
]
}
const THREE: &[&str] = &["check_docker", "open_file_viewer", "viewer_read_file"];
#[test]
fn the_parser_reads_the_handler_list_in_order_and_ignores_comments() {
let lib_rs = r#"
.invoke_handler(tauri::generate_handler![
// Docker
commands::docker_commands::check_docker,
commands::docker_commands::build_image, // trailing comment is not a command
url_open::open_url_external,
// Viewer
commands::file_viewer_commands::viewer_read_file
])
.run(tauri::generate_context!())
"#;
assert_eq!(
registered_commands(lib_rs).unwrap(),
cmds(&["check_docker", "build_image", "open_url_external", "viewer_read_file"])
);
}
#[test]
fn the_parser_keeps_duplicates_so_the_caller_can_report_them() {
let lib_rs = "generate_handler![\n a::x,\n b::x,\n])";
assert_eq!(registered_commands(lib_rs).unwrap(), cmds(&["x", "x"]));
}
#[test]
fn the_parser_returns_none_without_a_handler_block() {
assert_eq!(registered_commands("fn main() {}"), None);
assert_eq!(registered_commands("generate_handler![ a::b, "), None, "unterminated");
}
/// The bug this regression-tests: a line-split parser applies `rsplit("::").next()` once
/// per *line*, so two commands sharing a line collapse into one item and the first is
/// silently dropped. Comma-splitting must keep both regardless of layout.
#[test]
fn two_commands_on_one_line_are_both_kept() {
let lib_rs = "generate_handler![\n a::x, b::y,\n])";
assert_eq!(registered_commands(lib_rs).unwrap(), cmds(&["x", "y"]));
}
/// Mirrors the real `lib.rs` handler list's shape: `// Section` comments between groups,
/// and command paths one (`open_url_external`), two (`url_open::open_url_external`) and
/// three (`commands::docker_commands::check_docker`) segments deep, all ending in a comma
/// except the last entry before `])`.
#[test]
fn a_fixture_shaped_like_the_real_handler_list_parses_every_command() {
let lib_rs = r#"
.invoke_handler(tauri::generate_handler![
// Docker
commands::docker_commands::check_docker,
commands::docker_commands::build_image,
// Opening a link in the host browser
url_open::open_url_external,
// Bare, module-less command
open_help,
// Terminal file viewer
commands::file_viewer_commands::viewer_read_file
])
.run(tauri::generate_context!())
"#;
assert_eq!(
registered_commands(lib_rs).unwrap(),
cmds(&[
"check_docker",
"build_image",
"open_url_external",
"open_help",
"viewer_read_file",
])
);
}
#[test]
fn permission_identifiers_replace_only_underscores() {
assert_eq!(allow_permission("check_docker"), "allow-check-docker");
assert_eq!(allow_permission("viewer_read_file"), "allow-viewer-read-file");
assert_eq!(allow_permission("aws_sso_refresh"), "allow-aws-sso-refresh");
}
#[test]
fn viewer_commands_belong_to_the_viewer_windows_and_nothing_else_does() {
assert_eq!(expected_windows("viewer_read_file"), ["file-viewer-*"]);
assert_eq!(expected_windows("open_file_viewer"), ["main"]);
assert_eq!(expected_windows("check_docker"), ["main"]);
}
#[test]
fn a_capability_file_yields_its_windows_and_bare_grants_only() {
let json = r#"{
"identifier": "default",
"description": "x",
"windows": ["main"],
"permissions": [
"core:event:allow-listen",
{ "identifier": "fs:allow-read", "allow": [{ "path": "$APPDATA/*" }] },
"allow-check-docker",
{ "identifier": "allow-list-projects" }
]
}"#;
let parsed = capability_file("default.json", json).unwrap();
assert_eq!(parsed.name, "default.json");
assert_eq!(parsed.windows, vec!["main"]);
assert_eq!(parsed.bare, vec!["allow-check-docker", "allow-list-projects"]);
}
#[test]
fn a_capability_file_without_windows_or_permissions_is_an_error() {
assert!(capability_file("x.json", r#"{"permissions": []}"#).unwrap_err().contains("windows"));
assert!(capability_file("x.json", r#"{"windows": ["main"]}"#).unwrap_err().contains("permissions"));
assert!(capability_file("x.json", "not json").unwrap_err().contains("x.json"));
}
#[test]
fn webviews_and_remote_keys_are_refused() {
let with = |extra: &str| {
format!(r#"{{"windows": ["main"], {extra}, "permissions": ["allow-check-docker"]}}"#)
};
let err = capability_file("d.json", &with(r#""webviews": ["browser-view-*"]"#)).unwrap_err();
assert!(err.contains("d.json") && err.contains("`webviews`"), "{err}");
let err = capability_file("d.json", &with(r#""remote": {"urls": ["https://*"]}"#)).unwrap_err();
assert!(err.contains("`remote`"), "{err}");
// Present-but-empty is still refused: the key itself is the widening surface.
assert!(capability_file("d.json", &with(r#""webviews": []"#)).is_err());
}
#[test]
fn only_top_level_json_files_are_capabilities() {
assert_eq!(stray_capability_entry("default.json", true), None);
for name in ["extra.toml", "extra.json5", "notes.txt", ".DS_Store"] {
let err = stray_capability_entry(name, true).expect(name);
assert!(err.contains(name) && err.contains("not a .json file"), "{err}");
}
let err = stray_capability_entry("sub", false).unwrap();
assert!(err.contains("capabilities/sub") && err.contains("not a regular file"), "{err}");
// A directory named like a capability is still a directory.
assert!(stray_capability_entry("x.json", false).is_some());
}
#[test]
fn os_junk_is_recognised_but_never_something_tauri_would_load() {
for junk in [".DS_Store", "Thumbs.db", "desktop.ini", ".default.json.swp", ".x.swo", "default.json~"] {
assert!(is_os_junk(junk), "{junk}");
}
for real in ["default.json", "x.toml", "x.json5", ".#default.json", "._default.json", "notes.txt", "extra"] {
assert!(!is_os_junk(real), "{real}");
}
}
#[test]
fn tauri_config_files_are_found_by_name_and_format() {
assert_eq!(tauri_config_file("tauri.conf.json"), Some(true));
assert_eq!(tauri_config_file("tauri.linux.conf.json"), Some(true));
assert_eq!(tauri_config_file("tauri.conf.json5"), Some(false));
assert_eq!(tauri_config_file("tauri.windows.conf.json5"), Some(false));
assert_eq!(tauri_config_file("Tauri.toml"), Some(false));
assert_eq!(tauri_config_file("Tauri.macos.toml"), Some(false));
assert_eq!(tauri_config_file("Cargo.toml"), None);
assert_eq!(tauri_config_file("build.rs"), None);
}
#[test]
fn inline_capabilities_in_the_tauri_config_are_refused() {
let ok = r#"{"app": {"security": {"csp": "default-src 'self'"}}}"#;
assert_eq!(tauri_config_problem("tauri.conf.json", ok), None);
assert_eq!(tauri_config_problem("t", r#"{"app": {"security": {"capabilities": []}}}"#), None);
assert_eq!(tauri_config_problem("t", r#"{"build": {"beforeBuildCommand": ""}}"#), None);
let inline = r#"{"app": {"security": {"capabilities": [
{"identifier": "x", "windows": ["file-viewer-*"], "permissions": ["allow-read-container-file"]}
]}}}"#;
let err = tauri_config_problem("tauri.conf.json", inline).unwrap();
assert!(err.contains("tauri.conf.json") && err.contains("app.security.capabilities"), "{err}");
let by_name = r#"{"app": {"security": {"capabilities": ["default"]}}}"#;
assert!(tauri_config_problem("TAURI_CONFIG", by_name).unwrap().contains("TAURI_CONFIG"));
assert!(tauri_config_problem("t", "{").unwrap().contains("not valid JSON"));
}
#[test]
fn a_correct_census_has_no_problems() {
assert_eq!(check(&cmds(THREE), &good_files()), Vec::<String>::new());
}
#[test]
fn an_empty_command_list_is_refused_because_it_would_disable_the_acl() {
let problems = check(&[], &good_files());
assert_eq!(problems.len(), 1);
assert!(problems[0].contains("no commands"), "{problems:?}");
}
#[test]
fn a_command_without_a_grant_is_named_together_with_the_file_it_belongs_in() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("open_file_viewer"));
assert!(problems[0].contains("allow-open-file-viewer"));
assert!(problems[0].contains("[\"main\"]"));
}
#[test]
fn a_grant_in_two_files_is_reported_once_naming_both() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer"]),
file("extra.json", &["main"], &["allow-check-docker"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("allow-check-docker"));
assert!(problems[0].contains("default.json") && problems[0].contains("extra.json"));
}
#[test]
fn a_grant_that_names_no_command_is_a_typo() {
let mut files = good_files();
files[0].bare.push("allow-check-dokcer".to_string());
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("default.json: allow-check-dokcer"));
assert!(problems[0].contains("no registered command"));
}
#[test]
fn deny_grants_are_refused_with_the_reason() {
let mut files = good_files();
files[1].bare.push("deny-check-docker".to_string());
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("file-viewer.json: deny-check-docker"));
assert!(problems[0].contains("global"));
}
#[test]
fn other_bare_identifiers_are_refused() {
let mut files = good_files();
files[0].bare.push("default".to_string());
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("default.json: default"));
}
#[test]
fn a_grant_in_the_wrong_file_is_refused_even_though_it_is_granted_exactly_once() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker", "allow-open-file-viewer", "allow-viewer-read-file"]),
file("file-viewer.json", &["file-viewer-*"], &[]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("allow-viewer-read-file"));
assert!(problems[0].contains("[\"file-viewer-*\"]"));
}
#[test]
fn a_widened_windows_list_is_the_wrong_file_too() {
let files = vec![
file("default.json", &["main", "file-viewer-*"], &["allow-check-docker", "allow-open-file-viewer"]),
file("file-viewer.json", &["file-viewer-*"], &["allow-viewer-read-file"]),
];
let problems = check(&cmds(THREE), &files);
assert_eq!(problems.len(), 2, "{problems:?}");
}
#[test]
fn bad_names_and_duplicate_registrations_are_refused() {
let commands = cmds(&["check_docker", "Check-Docker", "check_docker", "open_file_viewer", "viewer_read_file"]);
let problems = check(&commands, &good_files());
assert!(problems.iter().any(|p| p.contains("\"Check-Docker\"") && p.contains("[a-z0-9_]+")), "{problems:?}");
assert!(problems.iter().any(|p| p.contains("check_docker is registered more than once")), "{problems:?}");
}
#[test]
fn every_problem_is_reported_in_one_pass() {
let files = vec![
file("default.json", &["main"], &["allow-check-docker", "allow-nope", "deny-check-docker"]),
file("file-viewer.json", &["file-viewer-*"], &[]),
];
let problems = check(&cmds(THREE), &files);
// typo, deny, open_file_viewer missing, viewer_read_file missing
assert_eq!(problems.len(), 4, "{problems:?}");
}
}
@@ -5,6 +5,7 @@
use tauri::{AppHandle, State};
use crate::auth_bridge::AuthBridgeStatus;
use crate::host::ContainerTarget;
use crate::AppState;
/// Turn the bridge on or off for a project and return the resulting status.
@@ -29,16 +30,13 @@ pub async fn set_auth_bridge_enabled(
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
if let Some(container_id) = project.container_id {
if crate::docker::container::is_container_running(&container_id)
.await
.unwrap_or(false)
{
if let Some(container) = ContainerTarget::for_project(&project) {
if container.is_running().await.unwrap_or(false) {
state
.auth_bridge
.start(
project_id.clone(),
container_id,
container,
app_handle,
state.projects_store.clone(),
)
@@ -76,8 +76,7 @@ use tauri::{AppHandle, Emitter, State};
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, oneshot, Mutex};
use crate::docker::container::is_container_running;
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
use crate::host::{AttachedExec, ContainerTarget};
use crate::storage::secure;
use crate::AppState;
@@ -106,6 +105,16 @@ const CODE_REJECTED_EVENT: &str = "claude-token-code-rejected";
/// browser, sign in, and approve. Bounded so a wedged exec can't leak a task.
const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// Pause between typing the pasted code and pressing Enter.
///
/// The CLI's prompt reads a multi-byte chunk as a paste, and a `\r` inside a
/// paste is swallowed with it rather than submitting. Code and Enter in one
/// write therefore fill the prompt and submit nothing, and the flow waits out
/// [`SETUP_TIMEOUT`]. Measured against 2.1.283 under a pty: 20 ms apart
/// already submits reliably; this leaves headroom for the extra hops through
/// Docker's exec socket, which can merge writes that arrive close together.
pub(crate) const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(250);
/// Documented shape of a `setup-token` credential.
const TOKEN_PREFIX: &str = "sk-ant-oat01-";
@@ -611,7 +620,7 @@ const MAX_ANSI_CARRY: usize = 64 * 1024;
/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete
/// trailing sequence over to the next chunk.
#[derive(Default)]
struct AnsiStripper {
pub(crate) struct AnsiStripper {
carry: Vec<u8>,
/// OSC 8 link targets seen since the last [`AnsiStripper::take_links`].
/// Kept out of the return value so every existing caller and test of
@@ -620,7 +629,7 @@ struct AnsiStripper {
}
impl AnsiStripper {
fn push(&mut self, chunk: &[u8]) -> String {
pub(crate) fn push(&mut self, chunk: &[u8]) -> String {
self.carry.extend_from_slice(chunk);
let (mut out, links, consumed) = strip_ansi_prefix(&self.carry);
self.record_links(links);
@@ -634,7 +643,7 @@ impl AnsiStripper {
// fresh chunk, which re-enters here.
if self.carry.len() > MAX_ANSI_CARRY {
log::warn!(
"`claude setup-token` emitted an unterminated control sequence \
"the command emitted an unterminated control sequence \
longer than {} bytes — treating it as text",
MAX_ANSI_CARRY
);
@@ -724,7 +733,7 @@ const REJECTION_SCAN_WINDOW: usize = 4096;
const CODE_REJECTED_MARKERS: &[&str] = &["invalid code", "press enter to retry"];
/// Append `chunk` to `buf`, keeping no more than `cap` bytes of the tail.
fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) {
pub(crate) fn push_capped_tail(buf: &mut String, chunk: &str, cap: usize) {
buf.push_str(chunk);
if buf.len() <= cap {
return;
@@ -837,13 +846,27 @@ unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_B
ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK
exec claude setup-token"#;
/// Type `code` into the CLI's prompt, then press Enter as a separate keystroke.
///
/// See [`SUBMIT_ENTER_DELAY`] for why the two cannot share a write.
async fn type_code_then_enter<W: tokio::io::AsyncWrite + Unpin>(
input: &mut W,
code: &[u8],
) -> std::io::Result<()> {
input.write_all(code).await?;
input.flush().await?;
tokio::time::sleep(SUBMIT_ENTER_DELAY).await;
input.write_all(b"\r").await?;
input.flush().await
}
/// Run `claude setup-token` in the container and return the token it printed.
/// Streams redacted output as it arrives and forwards anything arriving on
/// `input_rx` (the user's pasted code) to the command's stdin.
/// `input_rx` (the user's pasted code) to the command's stdin, each followed by Enter.
async fn run_setup_token(
app: &AppHandle,
project_id: &str,
container_id: &str,
target: &ContainerTarget,
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
mut cancel_rx: oneshot::Receiver<()>,
) -> Result<String, String> {
@@ -853,16 +876,16 @@ async fn run_setup_token(
exec_id,
mut output,
mut input,
} = create_attached_exec(
container_id,
vec![
"sh".to_string(),
"-c".to_string(),
SETUP_TOKEN_SCRIPT.to_string(),
],
true,
)
.await?;
} = target
.attach_exec(
vec![
"sh".to_string(),
"-c".to_string(),
SETUP_TOKEN_SCRIPT.to_string(),
],
true,
)
.await?;
let mut stripper = AnsiStripper::default();
let mut redactor = SecretRedactor::default();
@@ -894,14 +917,13 @@ async fn run_setup_token(
"Authentication cancelled. No token was stored.".to_string()
);
}
Some(data) = input_rx.recv() => {
if let Err(e) = input.write_all(&data).await {
Some(code) = input_rx.recv() => {
if let Err(e) = type_code_then_enter(&mut input, &code).await {
return Err(format!(
"Could not send the code to `claude setup-token`: {}. No token was stored.",
e
));
}
let _ = input.flush().await;
// Arm the rejection detector. Anything the CLI says from here
// on is a verdict on *this* code.
awaiting_code_result = true;
@@ -1018,7 +1040,7 @@ async fn run_setup_token(
// Falling through to the token parse is right either way — a run that
// printed a token succeeded whatever Docker says about it — but it is worth
// saying so rather than silently calling it a clean exit.
match wait_for_exec_exit(&exec_id).await {
match target.wait_exec_exit(&exec_id).await {
Some(0) => {}
Some(code) => {
return Err(format!(
@@ -1068,13 +1090,13 @@ pub async fn acquire_claude_token(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project.container_id.clone().ok_or_else(|| {
let target = ContainerTarget::for_project(&project).ok_or_else(|| {
format!(
"Project '{}' has no container yet. Start it, then run authentication again.",
project.name
)
})?;
if !is_container_running(&container_id).await.unwrap_or(false) {
if !target.is_running().await.unwrap_or(false) {
return Err(format!(
"The container for '{}' is not running. Start it, then run authentication again.",
project.name
@@ -1110,8 +1132,7 @@ pub async fn acquire_claude_token(
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
);
let result =
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await;
let result = run_setup_token(&app_handle, &project_id, &target, input_rx, cancel_rx).await;
// Release the flow. Nothing else needs unwinding: this command changes no
// persisted state until the token itself is stored, which is the point.
@@ -1158,10 +1179,9 @@ pub async fn submit_claude_token_code(code: String) -> Result<(), String> {
.to_string()
})?;
let mut keystrokes = code.as_bytes().to_vec();
keystrokes.push(b'\r');
// Just the code: the flow presses Enter itself, as a separate keystroke.
sender
.send(keystrokes)
.send(code.as_bytes().to_vec())
.map_err(|_| "The authentication flow has already ended.".to_string())
}
@@ -2229,4 +2249,50 @@ mod tests {
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
assert!(outcome.snapshots_failed.is_empty(), "{:?}", outcome);
}
/// Records every `poll_write` as a separate entry, with when it landed, so
/// a test can see write boundaries that a byte pipe would merge.
#[derive(Default)]
struct RecordingWriter {
writes: Vec<(tokio::time::Instant, Vec<u8>)>,
}
impl tokio::io::AsyncWrite for RecordingWriter {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
self.writes
.push((tokio::time::Instant::now(), buf.to_vec()));
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
/// Code and Enter in one write is read by the CLI as a paste: the code
/// fills the prompt and the `\r` is swallowed with it, so nothing is
/// submitted and the flow sits until `SETUP_TIMEOUT`. Measured against
/// 2.1.283 under a pty; a separate Enter 20 ms later submits.
#[tokio::test(start_paused = true)]
async fn the_code_and_its_enter_are_separate_writes() {
let mut w = RecordingWriter::default();
type_code_then_enter(&mut w, b"abc#def").await.unwrap();
assert_eq!(w.writes.len(), 2, "expected two writes, got {:?}", w.writes);
assert_eq!(w.writes[0].1, b"abc#def");
assert_eq!(w.writes[1].1, b"\r");
assert!(w.writes[1].0 - w.writes[0].0 >= SUBMIT_ENTER_DELAY);
}
}
+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))?;
@@ -34,6 +34,11 @@ pub async fn get_container_info(
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
// An AWS project has no Docker container (its own name could match a
// stale one from before it moved to AWS).
if project.is_aws() {
return Ok(None);
}
docker::get_container_info(&project).await
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,631 @@
use std::fs;
use std::time::Duration;
use serde_json::json;
use super::*;
use crate::aws::tofu::Workdir;
use crate::aws::environments::{
EnvironmentState, Knowable, LockInfo, OnboardingState,
};
use crate::aws::tofu_modules::{
environment_vars, module_files, onboarding_vars, owner_key, state_key, ModuleKind,
};
const ACCOUNT: &str = "123456789012";
const REGION: &str = "eu-west-1";
const USER_ID: &str = "AROAEXAMPLEID12345678:alice";
fn info(name: &str, mode: &str, assign_public_ip: bool) -> EnvironmentInfo {
EnvironmentInfo {
schema: 1,
name: name.into(),
template_version: 1,
cluster_arn: format!("arn:aws:ecs:{REGION}:{ACCOUNT}:cluster/triple-c-{name}"),
security_group_id: "sg-0123456789abcdef0".into(),
subnet_ids: vec![
"subnet-0123456789abcdef0".into(),
"subnet-0fedcba9876543210".into(),
],
assign_public_ip,
kms_key_arn: format!("arn:aws:kms:{REGION}:{ACCOUNT}:key/1234abcd-12ab"),
log_group: format!("/triple-c/{name}"),
network_mode: mode.into(),
tenancy: "strict".into(),
}
}
fn env(name: &str, state: EnvironmentState, actions: Vec<EnvironmentAction>) -> DetectedEnvironment {
DetectedEnvironment {
name: name.into(),
template_version: None,
state,
actions,
info: None,
onboarding: None,
onboarding_actions: Vec::new(),
onboarded_users: Some(0),
}
}
fn scan(boundary: Knowable, environments: Vec<DetectedEnvironment>) -> EnvironmentScan {
EnvironmentScan {
region: REGION.into(),
account: ACCOUNT.into(),
owner_id: USER_ID.into(),
owner_key: owner_key(REGION, USER_ID),
state_bucket: Knowable::Yes,
boundary,
environments,
}
}
fn lock(age_secs: u64, local_process: bool) -> LockInfo {
LockInfo {
who: "someone@host".into(),
created: String::new(),
operation: "OperationTypeApply".into(),
age_secs,
local_process,
}
}
#[test]
fn only_our_state_keys_are_accepted() {
let ok = owner_key(REGION, USER_ID);
let good = [
(
"environments/dev/terraform.tfstate".to_string(),
DeploymentKey::Environment("dev".into()),
),
(
format!("users/dev/{ok}/terraform.tfstate"),
DeploymentKey::User { env: "dev".into(), owner_key: ok.clone() },
),
("boundary/terraform.tfstate".to_string(), DeploymentKey::Boundary),
];
for (key, want) in good {
let parsed = parse_state_key(&key).unwrap();
assert_eq!(parsed, want);
assert_eq!(state_key(parsed.deployment()), key, "round trip");
}
let bad = [
"",
"environments/dev/terraform.tfstate.tflock",
"environments/Dev/terraform.tfstate",
"environments/dev-/terraform.tfstate",
"environments/../terraform.tfstate",
"environments/dev/../../boundary/terraform.tfstate",
"/environments/dev/terraform.tfstate",
"environments//terraform.tfstate",
"environments/dev/x/terraform.tfstate",
"environments/dev/terraform.tfstate/",
"users/dev/terraform.tfstate",
"users/dev/ABCDEF012345/terraform.tfstate",
"users/dev/abcdef01234/terraform.tfstate",
"users/dev/abcdef0123456/terraform.tfstate",
"users/dev/abcdef01234g/terraform.tfstate",
"boundary/x/terraform.tfstate",
"boundary/terraform.tfstate ",
"something/else",
"environments\\dev\\terraform.tfstate",
];
for key in bad {
assert!(parse_state_key(key).is_err(), "{key:?} must be refused");
}
}
#[test]
fn deploy_refuses_an_existing_name_and_a_missing_boundary() {
use EnvironmentAction::*;
let existing = vec![
env("dev", EnvironmentState::Ready, vec![Join]),
env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]),
];
let s = scan(Knowable::Yes, existing.clone());
assert!(deploy_refusal(&s, "prod").is_ok());
let e = deploy_refusal(&s, "dev").unwrap_err();
assert!(e.contains("already exists"), "{e}");
assert!(deploy_refusal(&s, "half").is_err(), "state without a record is taken too");
assert!(deploy_refusal(&s, "Not Valid").is_err());
let no_boundary = scan(Knowable::No, existing.clone());
let e = deploy_refusal(&no_boundary, "prod").unwrap_err();
assert!(e.contains("permissions boundary"), "{e}");
// A caller who can't tell is let through (the apply itself would fail).
assert!(deploy_refusal(&scan(Knowable::Unknown, existing), "prod").is_ok());
}
#[test]
fn update_reuses_the_recorded_network() {
assert_eq!(
network_from_record(&info("dev", "public", true), None).unwrap(),
NetworkChoice::Public
);
assert_eq!(
network_from_record(&info("dev", "private-nat", false), None).unwrap(),
NetworkChoice::PrivateNat
);
let existing = info("dev", "existing", false);
assert_eq!(
network_from_record(&existing, Some("vpc-0123456789abcdef0")).unwrap(),
NetworkChoice::Existing {
vpc_id: "vpc-0123456789abcdef0".into(),
subnet_ids: existing.subnet_ids.clone(),
assign_public_ip: false,
}
);
// The VPC isn't in the record: without it from the state, refuse rather
// than guess.
assert!(network_from_record(&existing, None).is_err());
assert!(network_from_record(&existing, Some("not-a-vpc")).is_err());
assert!(network_from_record(&info("dev", "carrier-pigeon", false), None).is_err());
// And it round-trips into the module's variables unchanged.
let vars = environment_vars(
"dev",
REGION,
&network_from_record(&existing, Some("vpc-0123456789abcdef0")).unwrap(),
)
.unwrap();
assert_eq!(vars["network_mode"], "existing");
assert_eq!(vars["subnet_ids"], json!(existing.subnet_ids));
}
#[test]
fn the_vpc_is_read_from_the_state_outputs() {
let state = json!({
"version": 4,
"outputs": { "vpc_id": { "value": "vpc-0123456789abcdef0", "type": "string" } },
})
.to_string();
assert_eq!(
vpc_id_from_state(state.as_bytes()).as_deref(),
Some("vpc-0123456789abcdef0")
);
assert_eq!(vpc_id_from_state(b"{}"), None);
assert_eq!(vpc_id_from_state(b"not json"), None);
let hostile = json!({"outputs": {"vpc_id": {"value": "vpc-1\"; rm -rf /"}}}).to_string();
assert_eq!(vpc_id_from_state(hostile.as_bytes()), None);
}
#[test]
fn bundles_contain_the_module_backend_vars_and_readme() {
let dir = tempfile::tempdir().unwrap();
let ok = owner_key(REGION, USER_ID);
let d = Deployment::User { env: "dev", owner_key: &ok };
let vars = onboarding_vars("dev", REGION, USER_ID);
let written = write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap();
for (name, text) in module_files(ModuleKind::UserOnboard) {
assert_eq!(&fs::read_to_string(dir.path().join(name)).unwrap(), text);
assert!(written.contains(&name.to_string()));
}
let bucket = format!("triple-c-tfstate-{ACCOUNT}-{REGION}");
let key = state_key(d);
let backend = fs::read_to_string(dir.path().join("backend.hcl")).unwrap();
assert!(backend.contains(&format!("bucket = \"{bucket}\"")), "{backend}");
assert!(backend.contains(&format!("key = \"{key}\"")), "{backend}");
assert!(backend.contains("use_lockfile = true"), "{backend}");
let tfvars: Value =
serde_json::from_str(&fs::read_to_string(dir.path().join("terraform.tfvars.json")).unwrap())
.unwrap();
assert_eq!(tfvars, vars);
let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap();
for needle in [
bucket.as_str(),
key.as_str(),
ACCOUNT,
REGION,
"tofu init -backend-config=backend.hcl",
"tofu apply",
"terraform providers lock",
"registry.opentofu.org",
"1.10",
"aws s3api create-bucket",
] {
assert!(readme.contains(needle), "README lacks {needle:?}:\n{readme}");
}
// Outside us-east-1 the bucket needs a location constraint.
assert!(readme.contains(&format!("\"LocationConstraint\":\"{REGION}\"")), "{readme}");
// The admin policy allows s3:CreateBucket only with the component tag
// in the request, so the tag goes on the create itself; and the bucket
// is configured as the app configures it, TLS-only policy included.
let create = readme
.lines()
.find(|l| l.contains("aws s3api create-bucket"))
.unwrap();
assert!(
create.contains(r#""Tags":[{"Key":"triple-c:component","Value":"state"}]"#),
"{create}"
);
for needle in [
"put-bucket-versioning",
"put-bucket-encryption",
"put-public-access-block",
"put-bucket-policy",
"aws:SecureTransport",
"DenyInsecureTransport",
] {
assert!(readme.contains(needle), "README lacks {needle:?}:\n{readme}");
}
assert!(!readme.contains("put-bucket-tagging"), "a later tag can't satisfy the create");
// Writing again over our own files is fine...
write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap();
// ...but an unrelated file (or folder) means the wrong folder.
fs::write(dir.path().join("notes.txt"), "mine").unwrap();
let e = write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).unwrap_err();
assert!(e.contains("notes.txt"), "{e}");
assert_eq!(fs::read_to_string(dir.path().join("notes.txt")).unwrap(), "mine");
fs::remove_file(dir.path().join("notes.txt")).unwrap();
fs::create_dir(dir.path().join(".terraform")).unwrap();
assert!(write_bundle(dir.path(), d, ACCOUNT, REGION, &vars).is_err());
}
#[test]
fn a_us_east_1_bundle_creates_its_bucket_without_a_location_constraint() {
let dir = tempfile::tempdir().unwrap();
let vars = environment_vars("dev", "us-east-1", &NetworkChoice::Public).unwrap();
write_bundle(dir.path(), Deployment::Environment("dev"), ACCOUNT, "us-east-1", &vars).unwrap();
let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap();
assert!(readme.contains("aws s3api create-bucket"), "{readme}");
assert!(!readme.contains("LocationConstraint"), "{readme}");
assert!(readme.contains(r#""Tags":[{"Key":"triple-c:component","Value":"state"}]"#), "{readme}");
assert!(readme.contains("tofu destroy"), "{readme}");
for (name, _) in module_files(ModuleKind::Environment) {
assert!(dir.path().join(name).is_file(), "{name}");
}
}
#[test]
fn owner_ids_must_look_like_an_aws_user_id() {
for ok in ["AIDAEXAMPLEUSERID1234", "AROAEXAMPLEID12345678:alice", "AROA1234567890ABCDEF:a.b@c_d-e+f=g,h"] {
assert_eq!(validate_owner_id(ok).unwrap(), ok);
}
for bad in [
"",
"123456789012",
"aidaexampleuserid1234",
"AROAEXAMPLEID12345678:",
"AROAEXAMPLEID12345678:a b",
"AROAEXAMPLEID12345678:a:b",
"AROAEXAMPLEID12345678:\"",
&format!("AROAEXAMPLEID12345678:{}", "a".repeat(65)),
&"A".repeat(129),
] {
assert!(validate_owner_id(bad).is_err(), "{bad:?}");
}
}
#[test]
fn server_side_rechecks_follow_the_detected_actions() {
use EnvironmentAction::*;
let s = scan(
Knowable::Yes,
vec![
env("broken", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]),
env(
"working",
EnvironmentState::Incomplete { lock: Some(lock(9000, false)) },
vec![Repair, Unlock],
),
env("ready", EnvironmentState::Ready, vec![Join]),
env("busy", EnvironmentState::Deploying { lock: Some(lock(10, true)) }, vec![Follow]),
],
);
assert!(require_action(&s, "broken", Destroy).is_ok());
assert!(require_action(&s, "working", Destroy).is_err(), "never destroy a working env (D6)");
assert!(require_action(&s, "ready", Destroy).is_err());
assert!(require_action(&s, "missing", Destroy).is_err());
assert!(require_action(&s, "working", Repair).is_ok());
assert!(require_action(&s, "ready", Update).is_err());
assert!(unlock_refusal(&s, "working").is_ok());
assert!(unlock_refusal(&s, "broken").is_err(), "no lock to remove");
assert!(unlock_refusal(&s, "busy").is_err(), "a local runner holds it");
let local_stale = scan(
Knowable::Yes,
vec![env(
"x",
EnvironmentState::Incomplete { lock: Some(lock(9000, true)) },
vec![Unlock],
)],
);
assert!(unlock_refusal(&local_stale, "x").is_err());
}
#[test]
fn onboarding_needs_a_joinable_environment_and_the_boundary() {
use EnvironmentAction::*;
let mut ready = env("dev", EnvironmentState::Ready, vec![Join]);
ready.onboarding = Some(OnboardingState::NotJoined);
let mut busy = env("busy", EnvironmentState::Ready, vec![Join]);
busy.onboarding = Some(OnboardingState::InProgress { lock: None });
let half = env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]);
let s = scan(Knowable::Yes, vec![ready.clone(), busy, half]);
assert!(onboard_refusal(&s, "dev").is_ok());
assert!(onboard_refusal(&s, "busy").is_err());
assert!(onboard_refusal(&s, "half").is_err());
assert!(onboard_refusal(&s, "nope").is_err());
assert!(onboard_refusal(&scan(Knowable::No, vec![ready]), "dev").is_err());
}
#[test]
fn progress_events_have_the_documented_shape() {
let p = DeployProgress {
op_id: "op".into(),
state_key: "boundary/terraform.tfstate".into(),
events: vec![],
done: false,
cancellable: true,
result: None,
};
let v = serde_json::to_value(&p).unwrap();
assert_eq!(
v,
json!({"op_id": "op", "state_key": "boundary/terraform.tfstate", "events": [], "done": false, "cancellable": true})
);
let done = DeployProgress {
done: true,
result: Some(TofuOutcome { ok: false, exit_code: 1, summary: Some("boom".into()) }),
..p
};
let v = serde_json::to_value(&done).unwrap();
assert_eq!(v["result"], json!({"ok": false, "exit_code": 1, "summary": "boom"}));
}
#[test]
fn an_already_running_start_names_the_deployment_and_suggests_follow() {
let e = start_error(StartError::AlreadyRunning, "environment \"dev\"");
let msg = e.to_string();
assert!(msg.contains("already running for environment \"dev\""), "{msg}");
assert!(msg.contains("Follow"), "{msg}");
let inner = AwsError::KeysMissing;
assert_eq!(start_error(StartError::Failed(inner.clone()), "x"), inner);
}
#[tokio::test]
async fn a_follower_emits_nothing_until_its_gate_opens() {
use std::sync::{Arc, Mutex};
// A finished run: the whole replay and `done` are available at once,
// the case where events could otherwise beat the op id to the caller.
let dir = tempfile::tempdir().unwrap();
let lines: String = (0..450).map(|i| format!("line {i}\n")).collect();
fs::write(dir.path().join("apply.log"), lines).unwrap();
fs::write(dir.path().join("apply.exit"), "0\n").unwrap();
let wd = Workdir { dir: dir.path().to_path_buf() };
let base = DeployProgress {
op_id: "op-1".into(),
state_key: "boundary/terraform.tfstate".into(),
events: vec![],
done: false,
cancellable: true,
result: None,
};
let seen: Arc<Mutex<Vec<DeployProgress>>> = Arc::default();
let sink = seen.clone();
let (gate, rx) = Gate::new();
let task = tokio::spawn(follow_and_report(
rx,
wd,
base,
Duration::from_millis(10),
move |p| sink.lock().unwrap().push(p.clone()),
));
tokio::time::sleep(Duration::from_millis(150)).await;
assert!(seen.lock().unwrap().is_empty(), "emitted before the gate opened");
gate.open();
task.await.unwrap();
let seen = seen.lock().unwrap();
let batches: Vec<usize> = seen.iter().filter(|p| !p.done).map(|p| p.events.len()).collect();
assert_eq!(batches, vec![200, 200, 50], "the whole log, in capped batches");
assert!(seen.iter().all(|p| p.op_id == "op-1" && p.state_key == "boundary/terraform.tfstate"));
let last = seen.last().unwrap();
assert!(last.done && !last.cancellable);
assert_eq!(last.result, Some(TofuOutcome { ok: true, exit_code: 0, summary: None }));
assert_eq!(seen.iter().filter(|p| p.done).count(), 1);
}
#[tokio::test]
async fn a_dropped_gate_still_releases_the_follower() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("apply.log"), "x\n").unwrap();
fs::write(dir.path().join("apply.exit"), "1\n").unwrap();
let (gate, rx) = Gate::new();
drop(gate);
let mut n = 0;
follow_and_report(
rx,
Workdir { dir: dir.path().to_path_buf() },
DeployProgress {
op_id: "o".into(),
state_key: "k".into(),
events: vec![],
done: false,
cancellable: false,
result: None,
},
Duration::from_millis(10),
|_| n += 1,
)
.await;
assert_eq!(n, 2);
}
#[test]
fn destroy_needs_the_action_no_record_and_no_lock() {
use EnvironmentAction::*;
let mut stale_with_record = env(
"rec",
EnvironmentState::Incomplete { lock: Some(lock(9000, false)) },
vec![Repair, Destroy, Unlock],
);
// Stale lock + record + no state: detection offers Destroy; the guard
// must still refuse (the record says it may be working).
stale_with_record.info = Some(info("rec", "public", true));
let mut record_unlocked = env("rec2", EnvironmentState::Incomplete { lock: None }, vec![Destroy]);
record_unlocked.info = Some(info("rec2", "public", true));
let s = scan(
Knowable::Yes,
vec![
stale_with_record,
record_unlocked,
env("locked", EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, vec![Repair, Destroy, Unlock]),
env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]),
env("ready", EnvironmentState::Ready, vec![Join]),
],
);
let e = destroy_refusal(&s, "rec").unwrap_err();
assert!(e.contains("valid record"), "{e}");
assert!(destroy_refusal(&s, "rec2").unwrap_err().contains("valid record"));
assert!(destroy_refusal(&s, "locked").unwrap_err().contains("Unlock it first"));
assert!(destroy_refusal(&s, "ready").is_err());
assert!(destroy_refusal(&s, "missing").is_err());
assert_eq!(destroy_refusal(&s, "half").unwrap().name, "half");
}
#[test]
fn destroy_is_refused_while_users_are_onboarded() {
use EnvironmentAction::*;
let mut onboarded = env("half", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]);
onboarded.onboarded_users = Some(2);
let mut unknown = env("blind", EnvironmentState::Incomplete { lock: None }, vec![Repair, Destroy]);
unknown.onboarded_users = None;
let s = scan(Knowable::Yes, vec![onboarded, unknown]);
let e = destroy_refusal(&s, "half").unwrap_err();
assert!(e.contains("remove the onboarded users' stacks first"), "{e}");
assert!(e.contains("users/half/"), "{e}");
// Without a listing there's no telling, and nothing to destroy from.
assert!(destroy_refusal(&s, "blind").unwrap_err().contains("can't list"));
}
#[test]
fn only_your_own_stale_onboarding_lock_can_be_removed() {
use EnvironmentAction::*;
let mut stale = env("dev", EnvironmentState::Ready, vec![Join]);
stale.onboarding = Some(OnboardingState::Incomplete { lock: Some(lock(9000, false)) });
stale.onboarding_actions = vec![Unlock];
let mut live = env("live", EnvironmentState::Ready, vec![Join]);
live.onboarding = Some(OnboardingState::InProgress { lock: Some(lock(10, false)) });
let mut none = env("none", EnvironmentState::Ready, vec![Join]);
none.onboarding = Some(OnboardingState::Incomplete { lock: None });
let s = scan(Knowable::Yes, vec![stale, live, none]);
let mine = owner_key(REGION, USER_ID);
assert!(onboarding_unlock_refusal(&s, "dev", &mine).is_ok());
let e = onboarding_unlock_refusal(&s, "dev", "0123456789ab").unwrap_err();
assert!(e.contains("your own"), "{e}");
assert!(onboarding_unlock_refusal(&s, "live", &mine).is_err());
assert!(onboarding_unlock_refusal(&s, "none", &mine).is_err());
assert!(onboarding_unlock_refusal(&s, "missing", &mine).is_err());
}
#[test]
fn a_boundary_bundle_names_an_administrator_without_the_admin_files() {
let dir = tempfile::tempdir().unwrap();
write_bundle(dir.path(), Deployment::Boundary, ACCOUNT, REGION, &boundary_vars(REGION)).unwrap();
let readme = fs::read_to_string(dir.path().join("README.txt")).unwrap();
assert!(readme.contains("account administrator"), "{readme}");
assert!(readme.contains("WITHOUT"), "{readme}");
assert!(!readme.contains("that carry the Triple-C environment-admin"), "{readme}");
}
#[test]
fn repair_and_update_are_refused_under_a_held_lock() {
use EnvironmentAction::*;
let s = scan(
Knowable::Yes,
vec![
env("locked", EnvironmentState::Incomplete { lock: Some(lock(9000, false)) }, vec![Repair, Unlock]),
env("free", EnvironmentState::Incomplete { lock: None }, vec![Repair]),
env("old", EnvironmentState::UpdateAvailable, vec![Join, Update]),
],
);
let e = reapply_refusal(&s, "locked", Repair).unwrap_err();
assert!(e.contains("Unlock it first"), "{e}");
assert!(reapply_refusal(&s, "free", Repair).is_ok());
assert!(reapply_refusal(&s, "old", Update).is_ok());
assert!(reapply_refusal(&s, "free", Update).is_err());
}
#[test]
fn unlock_deletes_only_a_still_stale_lock_and_reads_412_as_changed() {
assert!(stale_lock_refusal(0).is_err());
assert!(stale_lock_refusal(LIVE_LOCK_SECS - 1).is_err());
assert!(stale_lock_refusal(LIVE_LOCK_SECS).is_ok());
assert!(is_precondition_failed(Some("PreconditionFailed"), Some(412)));
assert!(is_precondition_failed(None, Some(412)));
assert!(!is_precondition_failed(Some("AccessDenied"), Some(403)));
assert!(!is_precondition_failed(None, Some(404)));
}
#[test]
fn an_existing_boundary_without_state_here_is_refused() {
assert!(boundary_refusal(&Knowable::Yes, Some(false)).unwrap_err().contains("already exists"));
assert!(boundary_refusal(&Knowable::Yes, Some(true)).is_ok(), "re-apply our own");
assert!(boundary_refusal(&Knowable::Yes, None).is_ok(), "can't tell");
assert!(boundary_refusal(&Knowable::No, Some(false)).is_ok());
assert!(boundary_refusal(&Knowable::Unknown, Some(false)).is_ok());
}
#[test]
fn a_follow_is_refused_while_a_launch_holds_the_folder() {
let dir = Path::new("/w/env/dev");
let mut slots: Slots<u32> = Slots::default();
slots.begin_launch(dir).unwrap();
let mut spawned = false;
let r = slots.install_follower(dir, "f1", false, || {
spawned = true;
1
});
assert_eq!(r.unwrap_err(), SlotBusy::Launching);
assert!(!spawned, "nothing is spawned for a refused follow");
// The launch's own follower replaces its Launching slot.
assert_eq!(slots.install_follower(dir, "l1", true, || 2).unwrap(), None);
slots.end_launch(dir); // the guard dropping afterwards
assert!(!slots.is_free(dir), "end_launch must not clear a follower");
}
#[test]
fn a_launch_is_refused_while_a_follower_or_launch_holds_the_folder() {
let dir = Path::new("/w/env/dev");
let mut slots: Slots<u32> = Slots::default();
slots.install_follower(dir, "f1", false, || 1).unwrap();
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Following);
slots.finish_follower(dir, "f1");
slots.begin_launch(dir).unwrap();
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Launching);
slots.end_launch(dir);
assert!(slots.is_free(dir));
// Other folders are independent.
slots.begin_launch(dir).unwrap();
slots.begin_launch(Path::new("/w/env/prod")).unwrap();
}
#[test]
fn a_finishing_follower_only_removes_its_own_slot() {
let dir = Path::new("/w/env/dev");
let mut slots: Slots<u32> = Slots::default();
slots.install_follower(dir, "old", false, || 1).unwrap();
// A re-follow replaces it and hands back the old task to abort.
assert_eq!(slots.install_follower(dir, "new", false, || 2).unwrap(), Some(1));
slots.finish_follower(dir, "old");
assert!(!slots.is_free(dir), "the old follower must not remove the new one's slot");
slots.finish_follower(dir, "new");
assert!(slots.is_free(dir));
// Nor a launch's slot.
slots.begin_launch(dir).unwrap();
slots.finish_follower(dir, "new");
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Launching);
}
#[test]
fn ending_a_launch_never_clears_a_following_slot() {
let dir = Path::new("/w/env/dev");
let mut slots: Slots<u32> = Slots::default();
slots.install_follower(dir, "f", false, || 1).unwrap();
slots.end_launch(dir);
assert_eq!(slots.begin_launch(dir).unwrap_err(), SlotBusy::Following);
slots.end_launch(Path::new("/w/env/none")); // nothing there: no-op
}
+112 -160
View File
@@ -4,14 +4,11 @@ use std::sync::Arc;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use bollard::container::{DownloadFromContainerOptions, LogOutput};
use bollard::exec::{CreateExecOptions, StartExecResults};
use futures_util::StreamExt;
use serde::Serialize;
use tauri::State;
use crate::docker::client::get_docker;
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, OUTPUT_LIMIT_MARKER};
use crate::host::{ContainerTarget, ExecChunk, ExecSpec, OUTPUT_LIMIT_MARKER};
use crate::AppState;
#[derive(Debug, PartialEq, Serialize)]
@@ -46,7 +43,7 @@ pub struct FileContents {
/// Hard ceiling on a single viewer read, whatever the caller asks for. The tar
/// path buffers the whole payload in host RAM, so a caller-supplied cap is not
/// something to take on trust.
const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
pub(crate) const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
#[tauri::command]
pub async fn list_container_files(
@@ -63,9 +60,7 @@ pub async fn list_container_files(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "Container not running".to_string())?;
// `exec_oneshot` discards the exit code, which is how a `find` that listed
@@ -78,7 +73,8 @@ pub async fn list_container_files(
// are the error message, its `-printf` records are the listing, and the
// parser should never be handed the former.
let (records, diagnostics, code) =
exec_oneshot_streams_as(container_id, "claude", list_argv(&path), Vec::new())
target
.exec_oneshot_streams_as("claude", list_argv(&path), Vec::new())
.await
.map_err(|e| describe_listing_failure(&path, e))?;
@@ -156,7 +152,7 @@ fn list_argv(path: &str) -> Vec<String> {
/// folder can act on.
///
/// One case is worth naming: a directory with more entries than
/// [`crate::docker::exec::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is
/// [`crate::host::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is
/// the point where a `find` record set passes 8 MiB, and what the panel showed
/// was "Command output exceeded 8388608 bytes and was abandoned" — a true
/// statement about a buffer, and no help at all about a directory.
@@ -352,7 +348,7 @@ const CONTAINER_WRITE_ROOTS: &[&str] = &["/workspace", "/home/claude", "/tmp"];
///
/// `what` names the parameter in the error, because these messages are shown to
/// a user who is looking at a folder, not at argv.
fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
pub(crate) fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
if path.is_empty() {
return Err(format!("{} path cannot be empty", what));
}
@@ -394,7 +390,7 @@ fn validate_container_path(what: &str, path: &str) -> Result<(), String> {
/// directly. What it buys is that the *panel* keeps its promise — the roots
/// named in the refusal are the roots it writes to — and that a mis-aimed drop
/// cannot quietly land outside them.
fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
pub(crate) fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
validate_container_path(what, path)?;
if CONTAINER_WRITE_ROOTS
.iter()
@@ -430,7 +426,11 @@ fn validate_container_write_path(what: &str, path: &str) -> Result<(), String> {
/// and the lexical answer stands: failing every write closed would break the
/// panel outright for a risk the container user does not need this code path to
/// take.
async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Result<(), String> {
async fn resolve_container_dir(
target: &ContainerTarget,
what: &str,
dir: &str,
) -> Result<(), String> {
validate_container_write_path(what, dir)?;
// Split streams, not the combined buffer: `realpath`'s answer is a *path*
@@ -438,18 +438,18 @@ async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Res
// hazard the listing above took apart for `find`. A warning on stderr —
// and there is one whenever a component is unreadable — used to be spliced
// into the string this then compared against the write roots.
let (stdout, diagnostics, code) = exec_oneshot_streams_as(
container_id,
"claude",
vec![
"realpath".to_string(),
"-m".to_string(),
"--".to_string(),
dir.to_string(),
],
Vec::new(),
)
.await?;
let (stdout, diagnostics, code) = target
.exec_oneshot_streams_as(
"claude",
vec![
"realpath".to_string(),
"-m".to_string(),
"--".to_string(),
dir.to_string(),
],
Vec::new(),
)
.await?;
let resolved = stdout.trim();
if code != 0 || resolved.is_empty() {
@@ -1178,17 +1178,17 @@ fn push_capped(buf: &mut String, frame: &[u8]) {
}
/// One regular file's bytes, pulled out of a container.
struct FetchedFile {
bytes: Vec<u8>,
pub(crate) struct FetchedFile {
pub(crate) bytes: Vec<u8>,
/// The size the tar header declared, i.e. the file's real size — which is
/// not `bytes.len()` once `max_bytes` has cut the read short.
size: u64,
truncated: bool,
pub(crate) size: u64,
pub(crate) truncated: bool,
}
/// Fetch a single regular file from a container as exact bytes.
///
/// The viewer's read. It deliberately goes through Docker's archive endpoint rather than
/// The viewer's read. It deliberately goes through the archive endpoint rather than
/// `exec_oneshot`: that reader runs every chunk through `String::from_utf8_lossy`
/// and merges stderr into stdout, so it would both corrupt any non-UTF-8 file
/// and be able to splice diagnostics into what the caller believes is content.
@@ -1202,19 +1202,12 @@ 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.
async fn fetch_container_file(
container_id: &str,
pub(crate) async fn fetch_container_file(
container: &ContainerTarget,
container_path: &str,
max_bytes: u64,
) -> Result<FetchedFile, String> {
let docker = get_docker()?;
let mut stream = docker.download_from_container(
container_id,
Some(DownloadFromContainerOptions {
path: container_path.to_string(),
}),
);
let mut stream = container.download_archive(container_path);
// A tar member is a 512-byte header plus payload padded to 512. 8 KiB of
// slack past the payload cap guarantees the header and the whole capped
@@ -1297,13 +1290,11 @@ pub async fn read_container_file(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "Container not running".to_string())?;
let cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES);
let fetched = fetch_container_file(container_id, &path, cap).await?;
let fetched = fetch_container_file(&target, &path, cap).await?;
Ok(FileContents {
contents_base64: BASE64.encode(&fetched.bytes),
@@ -1333,9 +1324,7 @@ pub async fn rename_container_path(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "Container not running".to_string())?;
// The name is checked by `validate_entry_name`; the path it is applied to
@@ -1349,7 +1338,7 @@ pub async fn rename_container_path(
// directory component to resolve.
let parent = parent_dir(&from_path);
if parent != "/" {
resolve_container_dir(container_id, "Item", &parent).await?;
resolve_container_dir(&target, "Item", &parent).await?;
}
let new_name = to_path.trim();
@@ -1364,8 +1353,7 @@ pub async fn rename_container_path(
// and exits 0 — so `-n` on its own would report a rename that never
// happened. The existence check is what turns it into an error the user
// sees; `-n` stays as the belt-and-braces against the race between them.
let (_, exists) = exec_oneshot_as(
container_id,
let (_, exists) = target.exec_oneshot_as(
"claude",
vec!["test".to_string(), "-e".to_string(), dest.clone()],
Vec::new(),
@@ -1375,8 +1363,7 @@ pub async fn rename_container_path(
return Err(format!("\"{}\" already exists in this folder", new_name));
}
let (output, code) = exec_oneshot_as(
container_id,
let (output, code) = target.exec_oneshot_as(
"claude",
vec![
"mv".to_string(),
@@ -1417,19 +1404,16 @@ pub async fn create_container_directory(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "Container not running".to_string())?;
resolve_container_dir(container_id, "Folder", &parent_path).await?;
resolve_container_dir(&target, "Folder", &parent_path).await?;
let name = name.trim();
validate_entry_name(name)?;
let dest = join_path(&parent_path, name);
let (output, code) = exec_oneshot_as(
container_id,
let (output, code) = target.exec_oneshot_as(
"claude",
vec!["mkdir".to_string(), "--".to_string(), dest.clone()],
Vec::new(),
@@ -1448,30 +1432,34 @@ pub async fn create_container_directory(
Ok(dest)
}
/// Every "container is not running" refusal starts with this, so a caller (the file
/// viewer's poll, `app/src/viewer/ipcMessages.ts`) can tell it apart from any other failure.
pub(crate) const NOT_RUNNING_PREFIX: &str = "Start the project before";
pub(crate) fn not_running_message(action: &str, why: &str) -> String {
format!("{} {} — {}.", NOT_RUNNING_PREFIX, action, why)
}
/// Refuse, in a sentence, before a Docker error has to speak for us.
///
/// Both file transfers and the backup run through `docker exec`, which needs a
/// running container. Without this the failure surfaces as bollard's
/// running container. Without this the failure surfaces as Docker's
/// `is not running` wrapped in whatever the caller was doing, and for the
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
/// is the first thing to touch the container, so a stopped project fails inside
/// path *validation* and reads like the path was the problem.
async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
let docker = get_docker()?;
let running = docker
.inspect_container(container_id, None)
.await
.ok()
.and_then(|info| info.state)
.and_then(|s| s.running)
.unwrap_or(false);
pub(crate) async fn require_running(
container: &ContainerTarget,
action: &str,
) -> Result<(), String> {
// An unreachable daemon is an error; an inspect failure (container gone)
// reads as "not running" — `is_running`'s contract, and what this did
// when it inspected the container itself.
let running = container.is_running().await?;
if running {
return Ok(());
}
Err(format!(
"Start the project before {} — it runs inside the running container.",
action
))
Err(not_running_message(action, "it runs inside the running container"))
}
/// Copy one regular file out of a container onto a host path the user chose in
@@ -1538,12 +1526,10 @@ pub async fn download_container_file(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
require_running(container_id, "saving a file to the host").await?;
require_running(&target, "saving a file to the host").await?;
let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else {
// Dismissed. Not an error, and deliberately distinguishable from one:
@@ -1559,8 +1545,6 @@ pub async fn download_container_file(
// costs. See the note on `pick_save_path` about which ones those are.
let dest = resolve_host_path(&chosen, HostPathUse::WriteChosenName).await?;
let docker = get_docker()?;
// `$TC_SRC`, never argv interpolation: the path reaches the shell as an
// environment value, so a name containing a quote or a `$` is data.
//
@@ -1622,30 +1606,19 @@ esac
echo "TC_SIZE=$SZ" >&2
dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(vec!["sh".to_string(), "-c".to_string(), script.to_string()]),
env: Some(vec![format!("TC_SRC={}", container_path)]),
user: Some("claude".to_string()),
..Default::default()
},
// As `claude`, no TTY, no stdin and no working directory (the image's
// default) — the options this exec has always been created with.
let exec = target
.attach(
ExecSpec::new(vec!["sh".to_string(), "-c".to_string(), script.to_string()])
.env(vec![format!("TC_SRC={}", container_path)])
.working_dir(None)
.no_stdin(),
)
.await
.map_err(|e| format!("Failed to start download: {}", e))?;
let result = docker
.start_exec(&exec.id, None)
.await
.map_err(|e| format!("Failed to start download: {}", e))?;
let mut output = match result {
StartExecResults::Attached { output, .. } => output,
StartExecResults::Detached => return Err("Download exec started detached".to_string()),
};
let exec_id = exec.exec_id;
let mut output = exec.output;
use tokio::io::AsyncWriteExt;
let partial = partial_download_path(&dest)?;
@@ -1691,7 +1664,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
while let Some(msg) = output.next().await {
match msg {
Ok(LogOutput::StdOut { message }) => {
Ok(ExecChunk::StdOut(message)) => {
if let Err(e) = writer.write_all(&message).await {
stream_err = Some(format!("Failed to write {}: {}", dest.display(), e));
break;
@@ -1722,7 +1695,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
}
}
}
Ok(LogOutput::StdErr { message }) => {
Ok(ExecChunk::StdErr(message)) => {
push_capped(&mut stderr_text, &message);
if declared.is_none() {
declared = parse_declared_size(&stderr_text);
@@ -1755,7 +1728,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
// success renames a truncated partial over the file the user already had
// and reports the byte count as if it were the whole thing. That is the one
// way this command could destroy data, so silence is failure.
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
let exit_code = target.wait_exec_exit(&exec_id).await;
if stream_err.is_none() && exit_code != Some(0) {
// Framed and clipped, never verbatim.
//
@@ -1889,7 +1862,7 @@ pub struct UploadOutcome {
/// * the destination goes through [`resolve_container_dir`], so it is inside
/// [`CONTAINER_WRITE_ROOTS`] both as written and as it resolves;
/// * the read, the size ceiling and the descriptor check are
/// `docker::exec::upload_host_file_with_ids`', unchanged — including
/// `ContainerTarget::upload_host_file_with_ids`', unchanged — including
/// landing the file owned by the container user rather than root.
///
/// The dialog itself is opened from Rust ([`pick_files_to_upload`]), which is
@@ -1906,15 +1879,13 @@ pub async fn upload_files_to_container(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
// Both checks before the dialog, so a project that cannot receive files
// says so instead of asking the user to choose some first.
require_running(container_id, "uploading files").await?;
resolve_container_dir(container_id, "Upload", &container_dir).await?;
require_running(&target, "uploading files").await?;
resolve_container_dir(&target, "Upload", &container_dir).await?;
let Some(picked) = pick_files_to_upload(&window).await else {
return Ok(None);
@@ -1925,14 +1896,14 @@ pub async fn upload_files_to_container(
// files; this one is the check that actually guards the extraction. A modal
// has no time limit, and `resolve_container_dir` answers a question about
// the container's filesystem — which the container is free to change while
// the dialog is open. Without this, `docker::exec`'s doc comment claim that
// the dialog is open. Without this, the uploader's doc comment claim that
// the destination "has already been confirmed" would be true only of a
// moment that had passed.
resolve_container_dir(container_id, "Upload", &container_dir).await?;
resolve_container_dir(&target, "Upload", &container_dir).await?;
// Once for the whole selection, not once per file — see
// `upload_host_file_with_ids`.
let ids = crate::docker::exec::container_user_ids(container_id).await;
let ids = target.user_ids().await;
let mut outcome = UploadOutcome {
uploaded: Vec::new(),
@@ -1946,7 +1917,7 @@ pub async fn upload_files_to_container(
continue;
}
};
match upload_one(container_id, &path, &container_dir, ids).await {
match upload_one(&target, &path, &container_dir, ids).await {
Ok(dest) => {
log::info!("Uploaded {} into project {} at {}", path, project_id, dest);
outcome.uploaded.push(dest);
@@ -1961,7 +1932,7 @@ pub async fn upload_files_to_container(
/// naming the file, because the caller may be reporting several at once and
/// "is a folder" on its own does not say which one.
async fn upload_one(
container_id: &str,
target: &ContainerTarget,
host_path: &str,
container_dir: &str,
ids: (u64, u64),
@@ -1985,7 +1956,7 @@ async fn upload_one(
// The ceiling is enforced against the open descriptor inside the uploader;
// this copy of it exists so the refusal arrives as a sentence instead of
// after a 300 MB read.
use crate::docker::exec::MAX_DROP_BYTES;
use crate::host::MAX_DROP_BYTES;
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"{} is too large to upload ({:.0} MB; limit {} MB). Mount it into the project instead.",
@@ -1995,14 +1966,9 @@ async fn upload_one(
));
}
crate::docker::exec::upload_host_file_with_ids(
container_id,
&resolved,
container_dir,
&name,
ids,
)
.await
target
.upload_host_file_with_ids(&resolved, container_dir, &name, ids)
.await
}
/// Container-authored diagnostic text, cut down to something safe to quote.
@@ -2011,7 +1977,7 @@ async fn upload_one(
/// call site for why each of those three matters; the short version is that
/// this text ends up inside a toast that renders above every modal, and its
/// author is the container.
fn clip_container_text(text: &str) -> String {
pub(crate) fn clip_container_text(text: &str) -> String {
const MAX: usize = 200;
let flattened: String = text
.trim()
@@ -2269,14 +2235,10 @@ pub async fn download_container_backup(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
let target = ContainerTarget::for_project(&project)
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
let docker = get_docker()?;
require_running(container_id, "backing up").await?;
require_running(&target, "backing up").await?;
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
// Read-only source: `tar -C` it, so absoluteness and `..` are what matter.
@@ -2321,33 +2283,22 @@ tar czf - --ignore-failed-read \
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(cmd),
env: Some(vec![
// As `claude`, no TTY, no stdin and no working directory (the image's
// default) — the options this exec has always been created with.
let exec = target
.attach(
ExecSpec::new(cmd)
.env(vec![
"HOME=/home/claude".to_string(),
format!("TC_BACKUP_SRC={}", path),
]),
user: Some("claude".to_string()),
..Default::default()
},
])
.working_dir(None)
.no_stdin(),
)
.await
.map_err(|e| format!("Failed to create backup exec: {}", e))?;
let result = docker
.start_exec(&exec.id, None)
.await
.map_err(|e| format!("Failed to start backup exec: {}", e))?;
let mut output = match result {
StartExecResults::Attached { output, .. } => output,
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
};
.map_err(|e| format!("Failed to start backup: {}", e))?;
let exec_id = exec.exec_id;
let mut output = exec.output;
use tokio::io::AsyncWriteExt;
let partial = partial_download_path(&dest)?;
@@ -2396,14 +2347,14 @@ tar czf - --ignore-failed-read \
while let Some(msg) = output.next().await {
match msg {
Ok(LogOutput::StdOut { message }) => {
Ok(ExecChunk::StdOut(message)) => {
if let Err(e) = writer.write_all(&message).await {
stream_err = Some(format!("Failed to write backup file: {}", e));
break;
}
total += message.len() as u64;
}
Ok(LogOutput::StdErr { message }) => {
Ok(ExecChunk::StdErr(message)) => {
push_capped(&mut stderr_text, &message);
}
Ok(_) => {}
@@ -2425,7 +2376,7 @@ tar czf - --ignore-failed-read \
// when `total > 0`. Poll until the exec actually reports finished so the
// exit code is reliably populated; if it can't be determined we fall back to
// the `total == 0` check below.
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
let exit_code = target.wait_exec_exit(&exec_id).await;
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
stream_err = Some(format!(
@@ -3559,10 +3510,11 @@ mod tests {
// `/workspace/escape` is under a write root as a string and is `/etc` as
// a location.
let escaped = resolve_container_dir(&name, "Folder", "/workspace/escape").await;
let target = ContainerTarget::docker(name.clone());
let escaped = resolve_container_dir(&target, "Folder", "/workspace/escape").await;
println!("container escape: {:?}", escaped);
assert!(escaped.is_err(), "a symlink out of /workspace was accepted");
resolve_container_dir(&name, "Folder", "/workspace")
resolve_container_dir(&target, "Folder", "/workspace")
.await
.expect("/workspace itself");
@@ -3570,7 +3522,7 @@ mod tests {
// reporting itself empty — `-H` follows the start point, and only the
// start point.
let (records, diagnostics, code) =
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace/link"), Vec::new())
target.exec_oneshot_streams_as("claude", list_argv("/workspace/link"), Vec::new())
.await
.unwrap();
println!("link: code={} diagnostics={:?}", code, diagnostics);
@@ -3583,7 +3535,7 @@ mod tests {
// The link itself is still *marked* as one in its parent's listing, and
// still navigable.
let (records, _, _) =
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace"), Vec::new())
target.exec_oneshot_streams_as("claude", list_argv("/workspace"), Vec::new())
.await
.unwrap();
let parent = parse_find_output("/workspace", &records);
@@ -3602,7 +3554,7 @@ mod tests {
for path in ["/workspace/broken", "/workspace/loop"] {
let started = std::time::Instant::now();
let (records, diagnostics, code) =
exec_oneshot_streams_as(&name, "claude", list_argv(path), Vec::new())
target.exec_oneshot_streams_as("claude", list_argv(path), Vec::new())
.await
.unwrap();
let took = started.elapsed();
@@ -0,0 +1,373 @@
//! IPC for the terminal file viewer. Every command here is gated on the calling
//! window's label and reads its target from the registry — no path, no label, no
//! project id crosses IPC from a viewer window. See spec §6.
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
use crate::commands::file_commands::{
fetch_container_file, not_running_message, require_running, validate_container_write_path, MAX_READ_BYTES,
};
use crate::file_viewer::is_viewer_label;
use crate::file_viewer::poll::{poll_file, ViewerPoll};
use crate::file_viewer::registry::{
Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState,
};
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;
pub const GOTO_EVENT: &str = "file-viewer-goto";
#[derive(Clone, Debug, Serialize)]
pub struct ViewerState {
pub project_id: String,
pub project_name: String,
pub raw_path: String,
pub state: ViewerTargetState,
pub initial: Location,
}
#[derive(Clone, Debug, Serialize)]
pub struct ViewerFile {
pub contents_base64: String,
pub truncated: bool,
pub size: u64,
pub hash: String,
pub editable: bool,
pub readonly_reason: Option<String>,
}
fn require_main(window_label: &str) -> Result<(), String> {
if window_label == "main" {
Ok(())
} else {
Err("Only the main window can open files.".into())
}
}
fn require_viewer(window_label: &str) -> Result<String, String> {
if is_viewer_label(window_label) {
Ok(window_label.to_string())
} else {
Err("This command belongs to a file window.".into())
}
}
fn viewer_state_of(_label: &str, target: ViewerTarget) -> ViewerState {
ViewerState {
project_id: target.project_id,
project_name: target.project_name,
raw_path: target.raw_path,
state: target.state,
initial: target.initial,
}
}
fn window_title(raw_path: &str, project_name: &str) -> String {
let base = raw_path.trim_end_matches('/').rsplit('/').next().unwrap_or(raw_path);
format!("{} — {}", base, project_name)
}
/// Refuses a save payload before decoding it: base64 of at most
/// [`MAX_WRITE_BYTES`] is at most `4 * ceil(MAX_WRITE_BYTES / 3)` characters.
/// `write_file` enforces the cap on the decoded bytes too; this stops a
/// compromised viewer from making the app allocate and decode an arbitrarily
/// large string first.
fn check_encoded_len(encoded_len: usize) -> Result<(), String> {
if encoded_len > MAX_WRITE_BYTES.div_ceil(3) * 4 {
return Err("Files over 1 MiB are read-only in the viewer.".into());
}
Ok(())
}
/// The caller's registry entry, or a sentence.
fn own_target(
window: &tauri::Window,
registry: &ViewerRegistry,
) -> Result<(String, ViewerTarget), String> {
let label = require_viewer(window.label())?;
let target = registry
.get(&label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
Ok((label, target))
}
fn resolved_path(target: &ViewerTarget) -> Result<String, String> {
match &target.state {
ViewerTargetState::Resolved { container_path } => Ok(container_path.clone()),
_ => Err("Choose a file first.".into()),
}
}
/// The one place a viewer command looks up its project (P14).
fn project_of(state: &AppState, project_id: &str) -> Result<Project, String> {
state
.projects_store
.get(project_id)
.ok_or_else(|| "This project no longer exists.".to_string())
}
/// `action` completes "Start the project before …", e.g. "saving this file".
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, action).await?;
Ok(container)
}
/// The container of the project a viewer window belongs to, if it is running.
async fn running_container_for(
state: &AppState,
target: &ViewerTarget,
action: &str,
) -> Result<ContainerTarget, String> {
running_container_of(&project_of(state, &target.project_id)?, action).await
}
/// Raises an existing viewer window and moves it to `location`.
fn focus_viewer(app: &AppHandle, label: &str, location: Location) {
if let Some(existing) = app.get_webview_window(label) {
let _ = existing.unminimize();
let _ = existing.set_focus();
let _ = app.emit_to(label, GOTO_EVENT, location);
}
}
// Nine parameters are fixed by the IPC contract (P10); four injected by Tauri.
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn open_file_viewer(
project_id: String,
path: String,
line: Option<u32>,
col: Option<u32>,
end_line: Option<u32>,
window: tauri::Window,
app: AppHandle,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<(), String> {
require_main(window.label())?;
let project = project_of(&state, &project_id)?;
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, &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, &fallbacks).await?;
candidates.extend(fallbacks);
}
}
let initial = Location { line, col, end_line };
let target_state = match matches.len() {
0 => ViewerTargetState::NotFound { tried: candidates },
1 => ViewerTargetState::Resolved { container_path: matches[0].clone() },
_ => ViewerTargetState::Choose { candidates: matches },
};
let title = window_title(&path, &project.name);
let target = ViewerTarget {
project_id,
project_name: project.name.clone(),
raw_path: path,
state: target_state,
initial: initial.clone(),
};
// Dedupe, stale pruning and the cap are one registry call, so a second click
// while the first window is still being built finds it rather than reading
// its not-yet-existing window as stale.
let label = match registry.reserve(target, |l| app.get_webview_window(l).is_some())? {
Reservation::Reserved(label) => label,
// Still being built: it opens at its own location in a moment.
Reservation::Existing { built: false, .. } => return Ok(()),
Reservation::Existing { label, built: true } => {
focus_viewer(&app, &label, initial);
return Ok(());
}
};
if let Err(e) = open_viewer_window(&app, &label, &title) {
registry.remove(&label);
return Err(e);
}
registry.mark_built(&label);
Ok(())
}
#[tauri::command]
pub async fn viewer_get_state(
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
) -> Result<ViewerState, String> {
let (label, target) = own_target(&window, &registry)?;
Ok(viewer_state_of(&label, target))
}
#[tauri::command]
pub async fn viewer_choose_file(
index: usize,
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
) -> Result<ViewerState, String> {
let (label, target) = own_target(&window, &registry)?;
let chosen = match &target.state {
ViewerTargetState::Choose { candidates } => candidates
.get(index)
.cloned()
.ok_or_else(|| "That choice is no longer available.".to_string())?,
_ => return Err("This window is not choosing a file.".into()),
};
let app = window.app_handle();
match registry.choose(&label, chosen, |l| app.get_webview_window(l).is_some())? {
Choice::Resolved(updated) => Ok(viewer_state_of(&label, updated)),
// Another window already has this file. This window was only ever a
// chooser, so hand over to that one and close this one, as a second
// click on the same path would have. The error is what this window
// shows if the destroy fails.
Choice::AlreadyOpen { label: other, .. } => {
focus_viewer(app, &other, target.initial);
let _ = window.destroy();
Err("This file is already open in another window.".into())
}
}
}
#[tauri::command]
pub async fn viewer_read_file(
max_bytes: u64,
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<ViewerFile, String> {
let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?;
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, &path, cap).await?;
let (editable, readonly_reason) = match validate_container_write_path("File", &path) {
Ok(()) => (true, None),
Err(reason) => (false, Some(reason)),
};
Ok(ViewerFile {
hash: sha256_hex(&fetched.bytes),
contents_base64: BASE64.encode(&fetched.bytes),
truncated: fetched.truncated,
size: fetched.size,
editable,
readonly_reason,
})
}
#[tauri::command]
pub async fn viewer_poll_file(
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<ViewerPoll, String> {
let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?;
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
/// `write::CONFLICT_PREFIX`/`GONE_PREFIX` prefixes and `READ_ONLY_MESSAGE` (TS copies in
/// `app/src/viewer/ipcMessages.ts`), and anything else (a full disk) is already a
/// sentence it shows as is. Success is a `SavedFile`: the new base hash and the hash
/// the disk held right after the swap.
#[tauri::command]
pub async fn viewer_write_file(
contents_base64: String,
base_hash: String,
window: tauri::Window,
registry: State<'_, ViewerRegistry>,
state: State<'_, AppState>,
) -> Result<SavedFile, String> {
let (_label, target) = own_target(&window, &registry)?;
let path = resolved_path(&target)?;
validate_container_write_path("File", &path)?;
check_encoded_len(contents_base64.len())?;
let bytes = BASE64
.decode(contents_base64.as_bytes())
.map_err(|_| "The editor sent malformed content.".to_string())?;
let container = running_container_for(&state, &target, "saving this file").await?;
write_file(&container, &state.exec_manager, &path, &bytes, &base_hash).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_is_main_only_and_viewer_commands_are_viewer_only() {
assert!(require_main("main").is_ok());
assert!(require_main("file-viewer-1").is_err());
assert!(require_main("browser-view-x").is_err());
assert_eq!(require_viewer("file-viewer-7").unwrap(), "file-viewer-7");
assert!(require_viewer("main").is_err());
assert!(require_viewer("file-viewer-").is_err());
}
/// Both "no container" refusals a viewer command can give start with the prefix
/// the viewer reads as "Container not running" (`ipcMessages.ts`).
#[test]
fn not_running_refusals_carry_the_shared_prefix() {
use crate::commands::file_commands::NOT_RUNNING_PREFIX;
let m = not_running_message("checking this file for changes", "files live in its container");
assert_eq!(m, "Start the project before checking this file for changes — files live in its container.");
assert!(m.starts_with(NOT_RUNNING_PREFIX));
}
#[test]
fn a_saved_file_serialises_both_hashes() {
let json = serde_json::to_value(SavedFile { hash: "a".into(), disk_hash: "b".into() }).unwrap();
assert_eq!(json, serde_json::json!({ "hash": "a", "disk_hash": "b" }));
}
#[test]
fn the_title_is_basename_then_project() {
assert_eq!(window_title("app/src/lib/urlRelay.ts", "Triple-C"), "urlRelay.ts — Triple-C");
assert_eq!(window_title("/workspace/x/README.md", "x"), "README.md — x");
assert_eq!(window_title("Makefile", "p"), "Makefile — p");
}
#[test]
fn viewer_state_serialises_the_ipc_shape() {
let target = ViewerTarget {
project_id: "pid".into(),
project_name: "P".into(),
raw_path: "src/a.rs".into(),
state: ViewerTargetState::Resolved { container_path: "/workspace/p/src/a.rs".into() },
initial: Location { line: Some(3), col: Some(2), end_line: None },
};
let json = serde_json::to_value(viewer_state_of("file-viewer-1", target)).unwrap();
assert_eq!(json["project_id"], "pid");
assert_eq!(json["state"]["kind"], "resolved");
assert_eq!(json["state"]["container_path"], "/workspace/p/src/a.rs");
assert_eq!(json["initial"]["line"], 3);
assert!(json["initial"]["end_line"].is_null());
}
#[test]
fn the_encoded_length_is_capped_before_decoding() {
let at_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES]);
assert!(check_encoded_len(at_cap.len()).is_ok());
// MAX + 1 and MAX + 2 bytes pad to the same length as MAX; `write_file`'s
// decoded check refuses those. The first size this bound itself refuses:
let over_cap = BASE64.encode(vec![0u8; MAX_WRITE_BYTES + 3]);
assert!(check_encoded_len(over_cap.len()).is_err());
assert!(check_encoded_len(at_cap.len() + 1).is_err());
assert!(check_encoded_len(0).is_ok());
}
}
+560
View File
@@ -0,0 +1,560 @@
//! 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_files, 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
}
pub(crate) 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
}
/// One policy file of a tier: its file name under `aws/policies/` and its
/// JSON, verbatim.
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct PolicyFile {
pub name: String,
pub json: String,
}
/// Every policy file `tier` needs, in order; all of them are required (D7).
#[tauri::command]
pub fn aws_policy_document(tier: Tier) -> Vec<PolicyFile> {
policy_files(tier)
.iter()
.map(|(name, json)| PolicyFile {
name: name.to_string(),
json: json.to_string(),
})
.collect()
}
#[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());
}
#[test]
fn the_policy_document_is_every_file_of_the_tier() {
let admin = aws_policy_document(Tier::EnvironmentAdmin);
let names: Vec<&str> = admin.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["environment-admin.json", "environment-admin-network.json"]);
for f in &admin {
serde_json::from_str::<serde_json::Value>(&f.json).expect("policy JSON");
}
let ws = aws_policy_document(Tier::WorkspaceUser);
let names: Vec<&str> = ws.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["workspace-user.json", "workspace-user-tasks.json"]);
for f in &ws {
serde_json::from_str::<serde_json::Value>(&f.json).expect("policy JSON");
}
assert_eq!(
serde_json::to_value(&ws[0]).unwrap()["name"],
"workspace-user.json"
);
}
}
+63 -115
View File
@@ -12,8 +12,9 @@
//!
//! ## Container access
//!
//! All work happens inside the container via the existing `docker exec`
//! plumbing in [`crate::docker::exec`] — no second mechanism is introduced.
//! All work happens inside the container via the existing exec plumbing,
//! reached through [`crate::host::ContainerTarget`] — no second mechanism is
//! introduced.
//! The heavy lifting (walking dirs, grepping transcripts, parsing JSON with
//! `jq`) runs *in* the container and only a small JSON summary crosses the
//! wire, so multi-megabyte transcripts are never streamed back.
@@ -56,13 +57,11 @@
//! commands fail loudly, since they cannot do anything useful without a
//! running container.
use bollard::exec::{CreateExecOptions, StartExecOptions};
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::docker::client::get_docker;
use crate::docker::container::is_container_running;
use crate::docker::exec::{exec_oneshot_env, exec_oneshot_env_status};
use crate::docker::CONTAINER_HOME;
use crate::host::{ContainerTarget, ExecSpec};
use crate::AppState;
/// Newest N session transcripts to inspect. Caps the work done inside the
@@ -72,8 +71,6 @@ const MAX_SESSIONS: usize = 50;
/// Newest N scheduler notifications to return.
const MAX_NOTIFICATIONS: usize = 50;
const CONTAINER_HOME: &str = "/home/claude";
/// Caps on the free-text fields of a scheduled task. They exist to keep a
/// runaway paste out of the container's task JSON and out of the `docker exec`
/// payload; they are generous enough for a real prompt.
@@ -193,7 +190,7 @@ pub struct SchedulerNotification {
// Shared helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Resolve a project to a *running* container id.
/// Resolve a project to its *running* container.
///
/// `Ok(None)` means "there is nothing to inspect" — no container recorded, or
/// the container exists but is stopped. Callers that are read-only turn that
@@ -202,19 +199,19 @@ pub struct SchedulerNotification {
async fn running_container_for(
project_id: &str,
state: &State<'_, AppState>,
) -> Result<Option<String>, String> {
) -> Result<Option<ContainerTarget>, String> {
let project = state
.projects_store
.get(project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = match project.container_id {
Some(id) => id,
let target = match ContainerTarget::for_project(&project) {
Some(t) => t,
None => return Ok(None),
};
if is_container_running(&container_id).await.unwrap_or(false) {
Ok(Some(container_id))
if target.is_running().await.unwrap_or(false) {
Ok(Some(target))
} else {
Ok(None)
}
@@ -225,7 +222,7 @@ async fn running_container_for(
async fn require_running_container(
project_id: &str,
state: &State<'_, AppState>,
) -> Result<String, String> {
) -> Result<ContainerTarget, String> {
running_container_for(project_id, state).await?.ok_or_else(|| {
"Container is not running — start the project first.".to_string()
})
@@ -241,13 +238,13 @@ fn home_env() -> Vec<String> {
/// the first line) so the combined stream `exec_oneshot_env` returns is pure
/// stdout and stays parseable as JSON. A non-zero exit therefore surfaces as
/// empty output, which the callers treat as "nothing to report".
async fn run_script(container_id: &str, script: impl Into<String>) -> Result<String, String> {
exec_oneshot_env(
container_id,
vec!["sh".to_string(), "-c".to_string(), script.into()],
home_env(),
)
.await
async fn run_script(target: &ContainerTarget, script: impl Into<String>) -> Result<String, String> {
target
.exec_oneshot_env(
vec!["sh".to_string(), "-c".to_string(), script.into()],
home_env(),
)
.await
}
/// Parse script output as JSON, degrading to a default value (and a log line)
@@ -301,7 +298,7 @@ fn validate_task_id(id: &str) -> Result<(), String> {
}
/// Strict allowlist for session ids (Claude Code uses UUIDs).
fn validate_session_id(id: &str) -> Result<(), String> {
pub(crate) fn validate_session_id(id: &str) -> Result<(), String> {
let valid = !id.is_empty()
&& id.len() <= 64
&& id.starts_with(|c: char| c.is_ascii_alphanumeric())
@@ -316,11 +313,11 @@ fn validate_session_id(id: &str) -> Result<(), String> {
/// Run `triple-c-scheduler <args…>` as a bare argv vector — no shell is
/// involved, so caller-supplied ids cannot be interpreted as shell syntax.
/// Returns the combined output, erroring with it on a non-zero exit.
async fn run_scheduler(container_id: &str, args: Vec<String>) -> Result<String, String> {
async fn run_scheduler(target: &ContainerTarget, args: Vec<String>) -> Result<String, String> {
let mut cmd = vec!["triple-c-scheduler".to_string()];
cmd.extend(args);
let (output, exit_code) = exec_oneshot_env_status(container_id, cmd, home_env()).await?;
let (output, exit_code) = target.exec_oneshot_env_status(cmd, home_env()).await?;
if exit_code != 0 {
let detail = output.trim();
return Err(if detail.is_empty() {
@@ -396,8 +393,8 @@ pub async fn list_claude_sessions(
project_id: String,
state: State<'_, AppState>,
) -> Result<Vec<ClaudeSession>, String> {
let container_id = match running_container_for(&project_id, &state).await? {
Some(id) => id,
let target = match running_container_for(&project_id, &state).await? {
Some(t) => t,
None => return Ok(Vec::new()),
};
@@ -405,7 +402,7 @@ pub async fn list_claude_sessions(
// a const usize — never from the caller.
let script = SESSIONS_SCRIPT.replace("__MAX__", &MAX_SESSIONS.to_string());
let raw = run_script(&container_id, script).await?;
let raw = run_script(&target, script).await?;
let sessions: Vec<RawSession> = parse_or_default(&raw, "session list");
Ok(sessions
@@ -422,36 +419,6 @@ pub async fn list_claude_sessions(
.collect())
}
/// Build the shell command line that resumes a session, for the frontend to
/// drop into a terminal.
///
/// The flag spelling was checked against the CLI in the container image:
/// `claude --resume <session-id>` (short form `-r`).
///
/// The project's permission mode is folded in so the resumed session behaves
/// like a freshly opened one. The session id is validated first, and the
/// returned string contains only allowlisted characters.
#[tauri::command]
pub async fn resume_session_command(
project_id: String,
session_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
validate_session_id(&session_id)?;
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let mut parts = vec!["claude".to_string()];
parts.extend(project.effective_permission_mode().cli_args());
parts.push("--resume".to_string());
parts.push(session_id);
Ok(parts.join(" "))
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Capabilities
// ─────────────────────────────────────────────────────────────────────────────
@@ -597,12 +564,12 @@ pub async fn list_container_capabilities(
project_id: String,
state: State<'_, AppState>,
) -> Result<ContainerCapabilities, String> {
let container_id = match running_container_for(&project_id, &state).await? {
Some(id) => id,
let target = match running_container_for(&project_id, &state).await? {
Some(t) => t,
None => return Ok(ContainerCapabilities::default()),
};
let raw = run_script(&container_id, CAPABILITIES_SCRIPT).await?;
let raw = run_script(&target, CAPABILITIES_SCRIPT).await?;
Ok(parse_or_default(&raw, "container capabilities"))
}
@@ -712,12 +679,12 @@ pub async fn list_scheduled_tasks(
project_id: String,
state: State<'_, AppState>,
) -> Result<Vec<ScheduledTask>, String> {
let container_id = match running_container_for(&project_id, &state).await? {
Some(id) => id,
let target = match running_container_for(&project_id, &state).await? {
Some(t) => t,
None => return Ok(Vec::new()),
};
let raw = run_script(&container_id, SCHEDULER_LIST_SCRIPT).await?;
let raw = run_script(&target, SCHEDULER_LIST_SCRIPT).await?;
let tasks: Vec<RawScheduledTask> = parse_or_default(&raw, "scheduled tasks");
Ok(tasks
@@ -762,13 +729,13 @@ pub async fn get_scheduled_task_log(
// Clamped, and an integer by type — cannot carry shell syntax.
let tail = tail_lines.unwrap_or(200).clamp(1, 5000);
let container_id = match running_container_for(&project_id, &state).await? {
Some(id) => id,
let target = match running_container_for(&project_id, &state).await? {
Some(t) => t,
None => return Ok(String::new()),
};
run_scheduler(
&container_id,
&target,
vec![
"logs".to_string(),
"--id".to_string(),
@@ -787,15 +754,15 @@ pub async fn get_scheduler_notifications(
project_id: String,
state: State<'_, AppState>,
) -> Result<Vec<SchedulerNotification>, String> {
let container_id = match running_container_for(&project_id, &state).await? {
Some(id) => id,
let target = match running_container_for(&project_id, &state).await? {
Some(t) => t,
None => return Ok(Vec::new()),
};
let script =
SCHEDULER_NOTIFICATIONS_SCRIPT.replace("__MAX__", &MAX_NOTIFICATIONS.to_string());
let raw = run_script(&container_id, script).await?;
let raw = run_script(&target, script).await?;
let notifications: Vec<RawNotification> = parse_or_default(&raw, "scheduler notifications");
Ok(notifications
@@ -1171,9 +1138,9 @@ pub async fn add_scheduled_task(
&schedule,
working_dir.as_deref(),
)?;
let container_id = require_running_container(&project_id, &state).await?;
let target = require_running_container(&project_id, &state).await?;
let output = run_scheduler(&container_id, input.add_args()).await?;
let output = run_scheduler(&target, input.add_args()).await?;
let task_id = parse_created_task_id(&output).ok_or_else(|| {
format!(
"The scheduler did not report a task id. Its output was: {}",
@@ -1231,9 +1198,9 @@ pub async fn update_scheduled_task(
&schedule,
working_dir.as_deref(),
)?;
let container_id = require_running_container(&project_id, &state).await?;
let target = require_running_container(&project_id, &state).await?;
let output = run_scheduler(&container_id, input.add_args()).await?;
let output = run_scheduler(&target, input.add_args()).await?;
let new_id = parse_created_task_id(&output).ok_or_else(|| {
format!(
"The scheduler did not report a task id, so the original task was left in place. Its output was: {}",
@@ -1242,7 +1209,7 @@ pub async fn update_scheduled_task(
})?;
run_scheduler(
&container_id,
&target,
vec!["remove".to_string(), "--id".to_string(), task_id.clone()],
)
.await
@@ -1255,7 +1222,7 @@ pub async fn update_scheduled_task(
if enabled == Some(false) {
if let Err(e) = run_scheduler(
&container_id,
&target,
vec!["disable".to_string(), "--id".to_string(), new_id.clone()],
)
.await
@@ -1284,11 +1251,11 @@ pub async fn set_scheduled_task_enabled(
state: State<'_, AppState>,
) -> Result<String, String> {
validate_task_id(&task_id)?;
let container_id = require_running_container(&project_id, &state).await?;
let target = require_running_container(&project_id, &state).await?;
let subcommand = if enabled { "enable" } else { "disable" };
let output = run_scheduler(
&container_id,
&target,
vec![subcommand.to_string(), "--id".to_string(), task_id],
)
.await?;
@@ -1308,39 +1275,20 @@ pub async fn run_scheduled_task_now(
state: State<'_, AppState>,
) -> Result<String, String> {
validate_task_id(&task_id)?;
let container_id = require_running_container(&project_id, &state).await?;
let target = require_running_container(&project_id, &state).await?;
let docker = get_docker()?;
let exec = docker
.create_exec(
&container_id,
CreateExecOptions {
attach_stdout: Some(false),
attach_stderr: Some(false),
// Argv vector — no shell, so `task_id` is inert as data.
cmd: Some(vec![
"triple-c-scheduler".to_string(),
"run".to_string(),
"--id".to_string(),
task_id.clone(),
]),
env: Some(home_env()),
user: Some("claude".to_string()),
working_dir: Some("/workspace".to_string()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create exec: {}", e))?;
docker
.start_exec(
&exec.id,
Some(StartExecOptions {
detach: true,
..Default::default()
}),
)
// Argv vector — no shell, so `task_id` is inert as data. Stdin, stdout and
// stderr are all left unattached; the exec is started detached.
let spec = ExecSpec::new(vec![
"triple-c-scheduler".to_string(),
"run".to_string(),
"--id".to_string(),
task_id.clone(),
])
.env(home_env())
.no_stdin();
let exec_id = target
.exec_detached(spec)
.await
.map_err(|e| format!("Failed to start task: {}", e))?;
@@ -1348,7 +1296,7 @@ pub async fn run_scheduled_task_now(
"Triggered scheduler task {} in project {} (detached exec {})",
task_id,
project_id,
exec.id
exec_id
);
Ok(format!("Task {} started.", task_id))
}
@@ -1361,10 +1309,10 @@ pub async fn remove_scheduled_task(
state: State<'_, AppState>,
) -> Result<String, String> {
validate_task_id(&task_id)?;
let container_id = require_running_container(&project_id, &state).await?;
let target = require_running_container(&project_id, &state).await?;
let output = run_scheduler(
&container_id,
&target,
vec!["remove".to_string(), "--id".to_string(), task_id],
)
.await?;
@@ -1377,9 +1325,9 @@ pub async fn clear_scheduler_notifications(
project_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let container_id = require_running_container(&project_id, &state).await?;
let target = require_running_container(&project_id, &state).await?;
run_scheduler(
&container_id,
&target,
vec!["notifications".to_string(), "--clear".to_string()],
)
.await?;
File diff suppressed because it is too large. Load diff
+758 -28
View File
@@ -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::{
@@ -92,8 +93,336 @@ fn pick_recorded_lineage(
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
}
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
/// be called on demand, not polled.
/// Reported as `probe_error` when there is genuinely nothing to read: no
/// container, stopped or otherwise, and no snapshot image.
///
/// It used to be reported for a *stopped* container too, which was simply
/// untrue — the container was sitting right there — and it disabled Update on
/// exactly the long-lived projects that had never been recreated and so had no
/// snapshot to fall back on.
const NOTHING_TO_PROBE: &str = "This project has no container or snapshot image yet, so there is nothing to compare against the base image.";
/// The project's container, as the daemon reported it.
///
/// `running` lives *inside* `Present` because it is only ever read about a
/// container that was found: `is_container_running` needs an id. Keeping the
/// two in one variant makes "running, but no container" unrepresentable rather
/// than merely unreached, which is what [`pick_probe_source`] relies on when it
/// hands a container id to the container probe arms.
#[derive(Debug, PartialEq, Eq)]
enum ContainerState {
/// The project genuinely has no container — an answer, not a failure to
/// look.
Absent,
Present {
id: String,
running: bool,
},
}
impl ContainerState {
fn id(&self) -> Option<&str> {
match self {
ContainerState::Absent => None,
ContainerState::Present { id, .. } => Some(id),
}
}
}
/// Where [`get_container_staleness`] reads the project's *current* filesystem
/// from, in descending order of how current the answer is.
///
/// The container variants carry the id they will be probed with, so that
/// "there is a container to read" and "here is which one" cannot come apart
/// downstream.
#[derive(Debug, PartialEq, Eq)]
enum ProbeSource<'a> {
/// `docker exec` into the live container. The only source that includes
/// everything installed since the last commit *in this session*.
RunningContainer(&'a str),
/// Commit the stopped container's writable layer to a throwaway image and
/// probe that. Exactly as current as the container, which is what makes it
/// preferable to the snapshot — see below.
StoppedContainer(&'a str),
/// A throwaway container from `triple-c-snapshot-<id>:latest`.
Snapshot,
/// Nothing to read: no container, no snapshot.
Nothing,
}
/// Pick the probe source, or report the one reading this decision needed and
/// did not get.
///
/// **A stopped container outranks the snapshot.** The snapshot image is not a
/// checkpoint — `commit_container_snapshot` runs only before a removal (a
/// config-change recreate) or inside a migration, so a project that has never
/// hit either has *no snapshot at all*, however long it has been in use, and
/// one that has is stale by everything installed since. The container's
/// writable layer is the truth in both cases. This is the same argument
/// [`mig::manifest_from_container`] already makes for the running case; it does
/// not stop applying when the container is stopped.
///
/// Getting this wrong is what made a stopped, never-recreated project report
/// "no container or snapshot image yet" — with its container sitting right
/// there — and left Update disabled on the projects that most needed it.
///
/// **`snapshot_exists` is consulted only where it decides something.** When a
/// container answered, the snapshot is not part of this decision at all, so a
/// failed `image_exists` is passed over rather than surfaced: destroying a
/// report the running container could have supplied in full would be the same
/// mistake, in the other direction, as reading an unreachable daemon as an
/// absent container. It is load-bearing only with no container at all, and
/// there its failure *is* the answer this function cannot give.
fn pick_probe_source<'a>(
container: &'a ContainerState,
snapshot_exists: &Result<bool, String>,
) -> Result<ProbeSource<'a>, String> {
match container {
ContainerState::Present { id, running: true } => Ok(ProbeSource::RunningContainer(id)),
// The stopped path may still want the snapshot, but only as a fallback
// it can do without — see `stopped_probe_policy` and the commit-failure
// arm in `get_container_staleness`, which each handle an unreadable
// snapshot themselves.
ContainerState::Present { id, running: false } => Ok(ProbeSource::StoppedContainer(id)),
ContainerState::Absent => match snapshot_exists {
Ok(true) => Ok(ProbeSource::Snapshot),
Ok(false) => Ok(ProbeSource::Nothing),
Err(e) => Err(probe_failed(e)),
},
}
}
/// Reported as `probe_error` when another operation owns the project and there
/// is no snapshot image to read instead. Deliberately not a claim about the
/// container: nothing is wrong with it, the answer is simply not safe to take
/// right now. See [`stopped_probe_policy`].
const PROJECT_BUSY: &str = "Another operation is running on this project, so its contents could not be inspected. Try again once it finishes.";
/// What to do about a stopped container, whose probe is the expensive one: it
/// commits the writable layer before it can read anything.
#[derive(Debug, PartialEq, Eq)]
enum StoppedProbe {
/// Commit and probe. The current answer, and the default.
Commit,
/// Probe the snapshot image instead. Less current — it lags the container by
/// everything installed since the last commit — but it allocates nothing and
/// touches nothing, which is what makes it the right answer while another
/// operation owns the container.
SnapshotInstead,
/// Report rather than guess, with the message to report.
Defer(String),
}
/// Pick what to do about a stopped container.
///
/// **Never commits while the project is claimed.** `get_container_staleness`
/// takes no [`crate::project_lock`] claim of its own, by design, so a commit
/// here can overlap a Recreate or Reset — and the collision is not symmetric.
/// The probe losing is harmless: a surfaced `probe_error` the user retries. The
/// *recreate* losing is not, because `start_project_container` removes the old
/// container with a hard `?`, so a non-404 from a remove that raced this commit
/// fails the whole Start with an opaque "Failed to remove container". Reading
/// the claim costs nothing and takes that failure off the table.
///
/// `snapshot_exists` matters only once the project is busy, because that is the
/// only state in which the snapshot is the alternative to committing. An
/// unreadable snapshot there leaves nothing to fall back *to*, so its error is
/// what gets reported: "try again once it finishes" alone would be a claim that
/// waiting is all that stands in the way, which a failed `image_exists` has not
/// established.
fn stopped_probe_policy(
project_is_busy: bool,
snapshot_exists: &Result<bool, String>,
) -> StoppedProbe {
match (project_is_busy, snapshot_exists) {
(false, _) => StoppedProbe::Commit,
(true, Ok(true)) => StoppedProbe::SnapshotInstead,
(true, Ok(false)) => StoppedProbe::Defer(PROJECT_BUSY.to_string()),
(true, Err(e)) => StoppedProbe::Defer(probe_failed(e)),
}
}
/// Reported as `probe_error` when a probe input could not be read at all.
///
/// Deliberately distinct from [`NOTHING_TO_PROBE`]: a failed reading is not
/// evidence that the project has no container, and saying "no container or
/// snapshot image yet" on a transient fault was confidently wrong about a
/// project that may well have both.
///
/// Deliberately *neutral about the cause*, too. Only one of the four readings
/// implies an unreachable daemon: `mig::image_id` maps a 404 to `Ok(None)` and
/// returns `Err` for any other status, and `find_existing_container` /
/// `image_exists` wrap every list failure the same way — all of which a daemon
/// that answered perfectly well can produce. The base image name comes from
/// user settings, so a malformed reference alone reaches here, and telling that
/// user to go fix a running daemon would be the same unestablished claim about
/// a cause that this whole probe path exists to stop making.
///
/// The underlying error is carried through verbatim, because "Docker is not
/// running" and "permission denied on /var/run/docker.sock" call for different
/// fixes from the user.
///
/// The sentence names *the check*, not the container, because only two of the
/// four readings are about the container at all — the other two are the base
/// image and the snapshot image. Saying "this project's container could not be
/// inspected" for a malformed base image name in settings would point the user
/// at the wrong object, which is the same mistake one size down.
fn probe_failed(e: &str) -> String {
format!("This project could not be checked against its base image: {}", e)
}
/// The four daemon readings [`get_container_staleness`] takes before it can
/// choose a probe source, each still carrying whether it is an answer.
///
/// **Absence and unreachability are different answers, and only one of them is
/// an answer.** All four callees already draw that line — `image_id` maps a 404
/// to `Ok(None)`, `find_existing_container` and `image_exists` return `Ok` with
/// an empty filtered list — so a call site that writes `.unwrap_or(None)` /
/// `.unwrap_or(false)` is not defaulting, it is *discarding a distinction the
/// callee went to the trouble of making*. That is what let an unreachable
/// daemon reach [`pick_probe_source`] as "no container, no snapshot" and report
/// [`NOTHING_TO_PROBE`] — a confident claim about a project nothing had
/// actually looked at.
///
/// The `Result` fields are the guard against that returning: the call site
/// hands over what the daemon said, unmodified, and an `.unwrap_or` there no
/// longer type-checks.
#[derive(Debug)]
struct ProbeReadings {
/// `docker::find_existing_container`.
container_id: Result<Option<String>, String>,
/// `docker::is_container_running`, and `None` when there was no container
/// to ask about — not a swallowed error.
container_running: Option<Result<bool, String>>,
/// `mig::image_id` for the configured base image.
base_image_id: Result<Option<String>, String>,
/// `docker::image_exists` for the project's snapshot image.
snapshot_exists: Result<bool, String>,
}
/// The readings [`get_container_staleness`] carries past the point where a
/// missing one would have stopped it.
#[derive(Debug)]
struct ProbeInputs {
/// The current base image's ID, or `None` when it is not pulled locally —
/// which [`mig::image_id`] reports as `Ok(None)`, not an error.
current_base_image_id: Option<String>,
container: ContainerState,
/// Still a `Result`, because whether it is load-bearing depends on the
/// container: see [`pick_probe_source`].
snapshot_exists: Result<bool, String>,
}
/// What [`get_container_staleness`] does next, once the readings are in.
#[derive(Debug)]
enum ProbeStart {
/// Go ahead, with these inputs.
Inputs(Box<ProbeInputs>),
/// Stop, and hand the user this report.
///
/// **Reported, not returned.** The hook's `catch` sets `staleness` to
/// `null`, and `ContainerMigrationBanner` renders nothing at all for a null
/// staleness — so an `Err` out of the command would make the banner vanish
/// at exactly the moment it has something to say. A `probe_error` on an
/// otherwise-default report keeps it on screen, reading "Container base
/// could not be checked". Carrying a `ContainerStaleness` rather than an
/// error string is what keeps that decision here, where it is tested,
/// instead of in the `?` someone adds at the call site later.
Report(Box<ContainerStaleness>),
}
/// Decide whether the collected readings are enough to probe with.
///
/// Only the readings this decision actually rests on can stop it:
///
/// * `container_id` selects the probe source outright, so a failure to read it
/// leaves nothing to choose between. Fatal.
/// * `base_image_id` is fatal too, and deliberately so: it is the right-hand
/// side of the staleness comparison, where `None` ("not pulled locally", an
/// answer) and `Err` ("could not ask") both otherwise collapse into
/// `stale: false`. Reporting a project as up to date because the base image
/// could not be read is exactly the #56 mistake, one field over.
/// * `container_running` is asked only about a container that was found, and
/// decides between two live probe sources. Fatal when present.
/// * `snapshot_exists` is *not* fatal here, because it is load-bearing in only
/// two of the downstream states — no container at all, and a stopped
/// container on a busy project. It travels as a `Result` so each of those can
/// surface it, and the states that never consult it are not punished for it.
///
/// The first error wins, because when the daemon is unreachable they fail
/// together and the user needs the reason once, not three times. The order is
/// `container_id`, then `base_image_id`, then `container_running` — chosen
/// priority, deliberately *not* the order the daemon was called in, so that the
/// reported error is most often the one that stopped the probe rather than
/// whichever reading happened to run first. (It is at most three, not four:
/// `container_running` is only attempted when `container_id` answered with a
/// container.)
///
/// **A caveat this cannot fix here.** `docker::is_container_running` swallows
/// `inspect_container` failures into `Ok(false)` itself and errors only when the
/// client cannot be built, so a daemon that dies between the list and the
/// inspect still reads as "stopped" rather than as an error. That is a fix
/// inside that function, not at this call site; threading its `Result` through
/// at least stops *this* layer from adding a second swallow on top.
fn start_probe(readings: ProbeReadings) -> ProbeStart {
match collect_probe_inputs(readings) {
Ok(inputs) => ProbeStart::Inputs(Box::new(inputs)),
Err(e) => ProbeStart::Report(Box::new(ContainerStaleness {
probe_error: Some(e),
..Default::default()
})),
}
}
fn collect_probe_inputs(readings: ProbeReadings) -> Result<ProbeInputs, String> {
let ProbeReadings {
container_id,
container_running,
base_image_id,
snapshot_exists,
} = readings;
let container_id = container_id.map_err(|e| probe_failed(&e))?;
let current_base_image_id = base_image_id.map_err(|e| probe_failed(&e))?;
let container_running = container_running
.transpose()
.map_err(|e| probe_failed(&e))?;
let container = match (container_id, container_running) {
(Some(id), Some(running)) => ContainerState::Present { id, running },
// No container: whatever `container_running` says is about nothing, and
// the caller only produces `None` here anyway.
(None, _) => ContainerState::Absent,
// A container was found but nobody asked whether it was running. The
// caller cannot produce this, and guessing "stopped" would cost a
// running project the only probe source that sees this session's
// installs — so say what happened instead.
(Some(_), None) => return Err(probe_failed("the container's state was not read")),
};
Ok(ProbeInputs {
current_base_image_id,
container,
snapshot_exists,
})
}
/// Runs two filesystem probes (~3 s each) and is therefore meant to be called
/// on demand, not polled.
///
/// **Not read-only, despite only reporting.** The stopped-container path commits
/// a throwaway image and force-removes it, which makes this a writer of a
/// `triple-c-probe-*` image and puts it in the class of thing
/// [`crate::project_lock`] exists for — and it takes no claim. That is
/// deliberate: this is what the migration banner calls to decide whether to
/// offer an update, including while a migration is in flight, so refusing it
/// under a claim would blank the banner exactly when it has the most to say.
/// The exposure is bounded to a surfaced error — a concurrent Recreate, Reset or
/// migration can remove the container out from under the commit, and the result
/// is a `probe_error` the user can retry, never a damaged container or a
/// mislabelled image. Two overlapping probes cannot collide either, because
/// probe image names are unique per call; see
/// [`crate::docker::container::get_probe_image_name`].
#[tauri::command]
pub async fn get_container_staleness(
project_id: String,
@@ -103,6 +432,11 @@ pub async fn get_container_staleness(
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
// An AWS task has no local container or snapshot to compare: no claim
// either way ("unknown"), and nothing is probed.
if project.is_aws() {
return Ok(ContainerStaleness::default());
}
let settings = state.settings_store.get();
let base_image = crate::models::container_config::resolve_image_name(
&settings.image_source,
@@ -111,7 +445,35 @@ pub async fn get_container_staleness(
let snapshot_image = docker::get_snapshot_image_name(&project);
let mut out = ContainerStaleness::default();
out.current_base_image_id = mig::image_id(&base_image).await.unwrap_or(None);
// Every reading the daemon owes us, taken up front and handed on exactly as
// it came back, so that "could not ask" stays distinguishable from "asked,
// and the answer is no". [`start_probe`] is where that distinction is acted
// on; nothing between here and there may collapse one into the other, and
// the `Result` fields of [`ProbeReadings`] are what stop it being possible.
let container_id_result = docker::find_existing_container(&project).await;
let container_running_result = match &container_id_result {
Ok(Some(id)) => Some(docker::is_container_running(id).await),
// No container, or no usable reading of one: nothing to inspect, and
// the container lookup's own error is what gets reported.
_ => None,
};
let readings = ProbeReadings {
container_id: container_id_result,
container_running: container_running_result,
base_image_id: mig::image_id(&base_image).await,
snapshot_exists: docker::image_exists(&snapshot_image).await,
};
let inputs = match start_probe(readings) {
ProbeStart::Inputs(inputs) => *inputs,
// Reported, not returned — see [`ProbeStart::Report`].
ProbeStart::Report(report) => return Ok(*report),
};
let container = inputs.container;
let container_id = container.id();
out.current_base_image_id = inputs.current_base_image_id;
out.snapshot_created_at = mig::image_created(&snapshot_image).await;
// Lineage, most authoritative source first: the live container's label,
@@ -125,8 +487,7 @@ pub async fn get_container_staleness(
// as an answer and skip the snapshot entirely, so a snapshot that *did*
// record a lineage was never consulted and the project reported "unknown"
// with the information sitting one lookup away.
let container_id = docker::find_existing_container(&project).await.unwrap_or(None);
let from_container = match &container_id {
let from_container = match container_id {
Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await,
None => None,
};
@@ -145,16 +506,70 @@ pub async fn get_container_staleness(
};
// ── Probes ───────────────────────────────────────────────────────────
let running = match &container_id {
Some(id) => docker::is_container_running(id).await.unwrap_or(false),
None => false,
let snapshot_exists = &inputs.snapshot_exists;
let source = match pick_probe_source(&container, snapshot_exists) {
Ok(source) => source,
// The only reading this decision needed and did not get — see
// [`ProbeStart::Report`] for why this is a report and not an `Err`.
Err(e) => {
out.probe_error = Some(e);
return Ok(out);
}
};
let from_manifest = if running {
mig::manifest_from_container(container_id.as_ref().unwrap()).await
} else if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
mig::manifest_from_image(&snapshot_image).await
} else {
Err("This project has no container or snapshot image yet, so there is nothing to compare against the base image.".to_string())
let from_manifest = match source {
ProbeSource::RunningContainer(id) => mig::manifest_from_container(id).await,
ProbeSource::StoppedContainer(id) => {
let busy = crate::project_lock::held(&project_id).is_some();
match stopped_probe_policy(busy, snapshot_exists) {
StoppedProbe::Commit => {
match mig::manifest_from_stopped_container_cached(id).await {
Ok(m) => Ok(m),
// **Never let a failed commit cost an answer the
// snapshot could have given.** Before stopped
// containers were readable at all, a stopped project
// fell straight through to its snapshot, so surfacing
// this error where the snapshot exists would make the
// banner *worse* than it was — and the ways this fails
// are the ones where the fallback matters most: a full
// disk (the commit has to allocate the whole writable
// layer; the snapshot probe allocates nothing) and a
// 409 from an operation that claimed the project after
// the check above.
// `Ok(true)` specifically: an `image_exists` that
// failed has not established that there is anything to
// fall back to, and probing a snapshot that may not
// exist would replace the commit's real error with a
// confusing one.
Err(e) if matches!(snapshot_exists, Ok(true)) => {
log::warn!(
"Probing the stopped container for project {} failed ({}) — \
falling back to its snapshot image, which may lag it",
project_id,
e
);
mig::manifest_from_image(&snapshot_image).await
}
Err(e) => Err(e),
}
}
StoppedProbe::SnapshotInstead => {
log::info!(
"Project {} is claimed by another operation — probing its snapshot image \
rather than committing the container",
project_id
);
mig::manifest_from_image(&snapshot_image).await
}
StoppedProbe::Defer(message) => Err(message),
}
}
ProbeSource::Snapshot => mig::manifest_from_image(&snapshot_image).await,
// Reached only when there is genuinely neither a container nor a
// snapshot: `ProbeSource` carries the container id in its container
// variants, so a container that exists can no longer fall through to
// here — which is the bug this arm used to hide, swallowing every
// stopped container.
ProbeSource::Nothing => Err(NOTHING_TO_PROBE.to_string()),
};
let (from_manifest, base_manifest) = match from_manifest {
@@ -298,6 +713,9 @@ pub async fn migrate_project_to_base(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<MigrationReport, String> {
if let Some(p) = state.projects_store.get(&project_id) {
crate::commands::project_commands::refuse_aws(&p, "Updating to the current base image")?;
}
let _guard = match ActiveGuard::acquire(&project_id) {
Ok(guard) => guard,
Err(busy) => return Ok(MigrationReport::failed_preflight(&busy)),
@@ -1401,14 +1819,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;
@@ -1451,7 +1861,7 @@ async fn stage_payload(
project_id: &str,
verbatim: &[String],
) -> Result<String, String> {
use bollard::container::LogOutput;
use crate::host::ExecChunk;
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
@@ -1504,14 +1914,14 @@ async fn stage_payload(
while let Some(msg) = output.next().await {
match msg {
Ok(LogOutput::StdOut { message }) => {
Ok(ExecChunk::StdOut(message)) => {
if let Err(e) = writer.write_all(&message).await {
stream_err = Some(format!("Failed to write the staging file: {}", e));
break;
}
total += message.len() as u64;
}
Ok(LogOutput::StdErr { message }) => {
Ok(ExecChunk::StdErr(message)) => {
stderr_text.push_str(&String::from_utf8_lossy(&message));
}
Ok(_) => {}
@@ -1570,7 +1980,7 @@ async fn stage_payload(
/// anything already present in the base, so collisions should be rare either
/// way.)
async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), String> {
use bollard::container::LogOutput;
use crate::host::ExecChunk;
use futures_util::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -1598,7 +2008,7 @@ async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), Stri
let mut text = String::new();
while let Some(msg) = output.next().await {
match msg {
Ok(LogOutput::StdErr { message }) | Ok(LogOutput::StdOut { message }) => {
Ok(ExecChunk::StdErr(message)) | Ok(ExecChunk::StdOut(message)) => {
text.push_str(&String::from_utf8_lossy(&message))
}
Ok(_) => {}
@@ -1964,6 +2374,326 @@ mod tests {
assert_eq!(pick_recorded_lineage(some(""), None), None);
}
/// The readings as the daemon answered them, all four healthy: no
/// container, nothing pulled, no snapshot. Tests override the one reading
/// they are about, which keeps it obvious which reading each case is
/// actually exercising.
fn readings() -> ProbeReadings {
ProbeReadings {
container_id: Ok(None),
container_running: None,
base_image_id: Ok(None),
snapshot_exists: Ok(false),
}
}
fn present(running: bool) -> ContainerState {
ContainerState::Present {
id: "c1".to_string(),
running,
}
}
/// What every one of the four readings looks like when the socket is gone:
/// generic over what it was going to return.
fn daemon<T>() -> Result<T, String> {
Err("Failed to list containers: connection refused".to_string())
}
#[test]
fn a_stopped_container_is_probed_rather_than_reported_missing() {
// The regression: a container that exists but is stopped, with no
// snapshot ever taken, read as "nothing to compare against".
assert_eq!(
pick_probe_source(&present(false), &Ok(false)),
Ok(ProbeSource::StoppedContainer("c1"))
);
}
#[test]
fn the_container_outranks_the_snapshot_whether_or_not_it_is_running() {
// The snapshot lags the container by everything installed since the
// last commit, in both states.
assert_eq!(
pick_probe_source(&present(true), &Ok(true)),
Ok(ProbeSource::RunningContainer("c1"))
);
assert_eq!(
pick_probe_source(&present(false), &Ok(true)),
Ok(ProbeSource::StoppedContainer("c1"))
);
}
#[test]
fn the_snapshot_is_the_fallback_only_once_the_container_is_gone() {
assert_eq!(
pick_probe_source(&ContainerState::Absent, &Ok(true)),
Ok(ProbeSource::Snapshot)
);
}
#[test]
fn nothing_to_probe_is_reserved_for_no_container_and_no_snapshot() {
// The one case the "no container or snapshot image yet" message may
// still describe.
assert_eq!(
pick_probe_source(&ContainerState::Absent, &Ok(false)),
Ok(ProbeSource::Nothing)
);
}
#[test]
fn an_unreadable_snapshot_only_costs_the_report_where_the_snapshot_is_the_answer() {
// A container answered, so `image_exists` decides nothing: its failure
// must not cost a report the container can supply in full. Treating it
// as fatal turned "running container, one flaky `image_exists`" into a
// bare probe_error with Update disabled.
assert_eq!(
pick_probe_source(&present(true), &daemon()),
Ok(ProbeSource::RunningContainer("c1"))
);
assert_eq!(
pick_probe_source(&present(false), &daemon()),
Ok(ProbeSource::StoppedContainer("c1"))
);
// With no container, the snapshot is the whole decision, so its failure
// is reported — and never as "no container or snapshot image yet",
// which nothing has established.
let e = pick_probe_source(&ContainerState::Absent, &daemon()).unwrap_err();
assert!(e.contains("connection refused"), "{}", e);
assert_ne!(e, NOTHING_TO_PROBE);
}
#[test]
fn a_stopped_container_is_committed_only_when_nothing_else_owns_the_project() {
assert_eq!(
stopped_probe_policy(false, &Ok(false)),
StoppedProbe::Commit
);
assert_eq!(stopped_probe_policy(false, &Ok(true)), StoppedProbe::Commit);
// Not the snapshot's business either way when the project is free: an
// unreadable `image_exists` does not stop the commit that would not
// have consulted it.
assert_eq!(stopped_probe_policy(false, &daemon()), StoppedProbe::Commit);
}
#[test]
fn a_busy_project_falls_back_rather_than_racing_a_recreate() {
// The snapshot lags, but a stale answer beats failing someone's Start.
assert_eq!(
stopped_probe_policy(true, &Ok(true)),
StoppedProbe::SnapshotInstead
);
// Nothing to fall back to: say so instead of committing anyway.
assert_eq!(
stopped_probe_policy(true, &Ok(false)),
StoppedProbe::Defer(PROJECT_BUSY.to_string())
);
// Busy *and* the fallback could not be read: "try again once it
// finishes" would promise that waiting is all that stands in the way,
// which the failed reading has not established. Report what happened.
match stopped_probe_policy(true, &daemon()) {
StoppedProbe::Defer(message) => {
assert!(message.contains("connection refused"), "{}", message);
assert_ne!(message, PROJECT_BUSY);
}
other => panic!("expected Defer, got {:?}", other),
}
}
#[test]
fn an_unreachable_daemon_is_never_read_as_an_absent_container() {
// The bug: every one of these used to be flattened to "no" by an
// `unwrap_or`, which reached `pick_probe_source` as "no container, no
// snapshot" and reported "no container or snapshot image yet" about a
// project nobody had managed to look at.
let e = collect_probe_inputs(ProbeReadings {
container_id: daemon(),
..readings()
})
.unwrap_err();
assert!(e.contains("connection refused"), "{}", e);
assert_ne!(e, NOTHING_TO_PROBE);
let e = collect_probe_inputs(ProbeReadings {
base_image_id: daemon(),
..readings()
})
.unwrap_err();
assert!(e.contains("connection refused"), "{}", e);
let e = collect_probe_inputs(ProbeReadings {
container_id: Ok(Some("c1".into())),
container_running: Some(daemon()),
..readings()
})
.unwrap_err();
assert!(e.contains("connection refused"), "{}", e);
// The fourth reading is not fatal here — see
// `an_unreadable_snapshot_only_costs_the_report_where_the_snapshot_is_the_answer`
// — but it must still arrive as an error rather than as "no snapshot".
let inputs = collect_probe_inputs(ProbeReadings {
snapshot_exists: daemon(),
..readings()
})
.unwrap();
assert!(inputs.snapshot_exists.is_err());
let e = pick_probe_source(&inputs.container, &inputs.snapshot_exists).unwrap_err();
assert_ne!(e, NOTHING_TO_PROBE);
}
#[test]
fn a_base_image_that_could_not_be_read_is_never_reported_as_up_to_date() {
// `image_id` answers `Ok(None)` for "not pulled locally", which is a
// legitimate `stale: false`. An `Err` is not: it is the right-hand side
// of the comparison missing, and letting it through as `None` would
// report the project up to date on the strength of a reading nobody
// got. This is #56 one field over, so it is fatal on purpose.
let e = collect_probe_inputs(ProbeReadings {
base_image_id: Err("invalid reference format".into()),
container_id: Ok(Some("c1".into())),
container_running: Some(Ok(true)),
snapshot_exists: Ok(true),
})
.unwrap_err();
assert!(e.contains("invalid reference format"), "{}", e);
}
#[test]
fn a_failed_reading_is_not_blamed_on_a_daemon_that_answered() {
// Three of the four readings return `Err` from a daemon that replied
// perfectly well: `image_id` maps only a 404 to `Ok(None)`, and the two
// list-based readings wrap any failure. The base image name is
// user-supplied, so a typo in settings lands here — and used to be
// reported as "Docker could not be reached", sending the user to fix a
// daemon that was running.
// The payload is the shape bollard really produces for this case, and
// it contains the word "Docker" itself — so asserting the *message*
// lacks that word would pass here only because a synthetic payload was
// chosen. What must be true is that nothing *we* add claims the daemon
// was unreachable, or names the container when the reading was about
// the base image.
let raw = "Docker responded with status code 400: invalid reference format";
let e = collect_probe_inputs(ProbeReadings {
base_image_id: Err(raw.into()),
..readings()
})
.unwrap_err();
assert!(!e.contains("could not be reached"), "{}", e);
assert!(!e.contains("container"), "{}", e);
// The cause still comes through verbatim: "Docker isn't running" and
// "permission denied on the socket" need different fixes and must stay
// distinguishable.
assert!(e.contains(raw), "{}", e);
}
#[test]
fn the_first_daemon_error_is_the_one_reported() {
// When the daemon is down these fail together, and the user needs the
// reason once rather than three times. Call order wins, and
// `container_id` leads because it is what selects the probe source.
//
// At most three fail, not four: `container_running` is only attempted
// when `container_id` answered with a container, so the caller cannot
// produce an `Err` container id alongside a `Some(..)` running reading.
let e = collect_probe_inputs(ProbeReadings {
container_id: Err("first".into()),
container_running: None,
base_image_id: Err("second".into()),
snapshot_exists: Err("third".into()),
})
.unwrap_err();
assert!(e.ends_with("first"), "{}", e);
let e = collect_probe_inputs(ProbeReadings {
container_id: Ok(Some("c1".into())),
container_running: Some(Err("third".into())),
base_image_id: Err("second".into()),
snapshot_exists: Err("fourth".into()),
})
.unwrap_err();
assert!(e.ends_with("second"), "{}", e);
}
#[test]
fn a_container_id_cannot_arrive_without_a_reading_of_its_state() {
// `ContainerState` makes "running, but no container" unrepresentable;
// this is the other half — a container found, but never asked about.
// The caller cannot produce it, and guessing "stopped" would cost a
// running project the only probe source that sees this session's
// installs.
let e = collect_probe_inputs(ProbeReadings {
container_id: Ok(Some("c1".into())),
container_running: None,
..readings()
})
.unwrap_err();
assert!(e.contains("state was not read"), "{}", e);
}
#[test]
fn a_daemon_that_answers_no_is_an_answer_and_passes_through() {
// No container, no snapshot, base image not pulled: all the readings
// are `Ok`, and the "nothing to probe" path downstream is then
// genuinely earned.
let inputs = collect_probe_inputs(readings()).unwrap();
assert_eq!(inputs.current_base_image_id, None);
assert_eq!(inputs.container, ContainerState::Absent);
assert_eq!(inputs.snapshot_exists, Ok(false));
assert_eq!(
pick_probe_source(&inputs.container, &inputs.snapshot_exists),
Ok(ProbeSource::Nothing)
);
// And the fully populated reading survives intact.
let inputs = collect_probe_inputs(ProbeReadings {
container_id: Ok(Some("c1".into())),
container_running: Some(Ok(true)),
base_image_id: Ok(Some("sha256:base".into())),
snapshot_exists: Ok(true),
})
.unwrap();
assert_eq!(inputs.current_base_image_id.as_deref(), Some("sha256:base"));
assert_eq!(inputs.container, present(true));
assert_eq!(inputs.snapshot_exists, Ok(true));
}
#[test]
fn a_failed_reading_keeps_the_banner_on_screen_instead_of_erroring() {
// The load-bearing design decision of this path: a failed reading is a
// report with `probe_error` set, never an `Err` out of the command. An
// `Err` reaches the hook's `catch`, which nulls `staleness`, and
// `ContainerMigrationBanner` renders nothing at all for a null one — so
// the banner would vanish at exactly the moment it has something to say.
match start_probe(ProbeReadings {
container_id: daemon(),
..readings()
}) {
ProbeStart::Report(report) => {
let message = report.probe_error.clone().expect("probe_error");
assert!(message.contains("connection refused"), "{}", message);
// Everything else at its default: a field being empty means
// "nothing found", and nothing was found because nothing was
// read. `stale: false` here is the absence of a claim, which is
// only honest because `probe_error` is carrying the reason.
assert_eq!(
*report,
ContainerStaleness {
probe_error: Some(message),
..Default::default()
}
);
}
ProbeStart::Inputs(_) => panic!("a failed reading must not be probed on"),
}
// And a healthy set of readings still goes on to probe.
assert!(matches!(start_probe(readings()), ProbeStart::Inputs(_)));
}
#[test]
fn byte_sizes_read_the_way_a_disk_warning_should() {
assert_eq!(human_bytes(512), "512 B");
+5
View File
@@ -2,15 +2,20 @@ pub mod auth_bridge_commands;
pub mod auth_token_commands;
pub mod aws_commands;
pub mod docker_commands;
pub mod environment_commands;
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;
pub mod migration_commands;
pub mod notes_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod stop_guard;
pub mod settings_export_commands;
pub mod stt_commands;
pub mod terminal_commands;
File diff suppressed because it is too large. Load diff
@@ -36,7 +36,7 @@ pub fn validate_settings_update(
// fallback for **every** project without an override
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
// then merges `/tmp/.host-ssh` into `~/.ssh`, recursively copying it into the
// home volume this release exists to bound.
//
// Grandfathered the same way project paths are: a value carried over
@@ -67,14 +67,34 @@ pub fn validate_settings_update(
Ok(())
}
/// Marketplace state is written only by the marketplace commands
/// (`commands/marketplace_commands.rs`), each of which returns fresh settings.
/// Every other settings save posts the frontend's copy back whole, and that
/// copy can predate an install made a moment ago, so what is stored wins.
/// `apply_settings_import` is the one caller that replaces it, explicitly.
pub(crate) fn restore_marketplace_fields(incoming: &mut AppSettings, stored: &AppSettings) {
incoming.marketplace_accounts = stored.marketplace_accounts.clone();
incoming.marketplaces = stored.marketplaces.clone();
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(
settings: AppSettings,
mut settings: AppSettings,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let before = state.settings_store.get();
validate_settings_update(&before, &settings)?;
restore_marketplace_fields(&mut settings, &before);
restore_host_fields(&mut settings, &before);
let saved = state.settings_store.update(settings)?;
@@ -430,4 +450,51 @@ mod tests {
});
assert_eq!(gateway_action(&before, &half_typed), GatewayAction::None);
}
#[test]
fn a_stale_settings_save_cannot_overwrite_marketplace_state() {
use crate::models::marketplace::Marketplace;
let mut stored = AppSettings::default();
stored.marketplaces.push(Marketplace {
id: "m1".into(),
name: "Team".into(),
url: "https://example.invalid/r.git".into(),
branch: None,
account_id: None,
});
// The frontend's copy predates the marketplace being added.
let mut incoming = AppSettings::default();
incoming.auto_check_updates = false;
restore_marketplace_fields(&mut incoming, &stored);
assert_eq!(incoming.marketplaces, stored.marketplaces);
assert!(
!incoming.auto_check_updates,
"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);
}
}
@@ -41,6 +41,10 @@ use tauri::State;
use tauri_plugin_dialog::DialogExt;
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,
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
@@ -131,11 +135,158 @@ fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSec
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
web_terminal_access_token,
marketplace_account_tokens: exported_marketplace_tokens(
&settings.marketplace_accounts,
secure::get_marketplace_token,
),
aws_connection_keys: exported_aws_connection_keys(&settings.hosts, secure::get_aws_keys),
};
(settings, secrets)
}
/// The stored token of every marketplace account that has one, by account
/// id. A `GhHost` account stores none (its token is asked of the host's `gh`
/// each time), so it is not read. A missing or unreadable token is left out,
/// like the other keychain secrets above.
fn exported_marketplace_tokens(
accounts: &[MarketplaceAccount],
get: impl Fn(&str) -> Result<Option<String>, String>,
) -> BTreeMap<String, String> {
accounts
.iter()
.filter(|a| a.method != AccountMethod::GhHost)
.filter_map(|a| {
let token = non_blank(get(&a.id).unwrap_or_default())?;
Some((a.id.clone(), token))
})
.collect()
}
/// Write each imported marketplace token to the keychain, returning a
/// warning (never containing the token) for each one that could not be.
fn restore_marketplace_tokens(
tokens: &BTreeMap<String, String>,
mut store: impl FnMut(&str, &str) -> Result<(), String>,
) -> Vec<String> {
let mut warnings = Vec::new();
for (account_id, token) in tokens {
if let Err(e) = store(account_id, token) {
log::warn!(
"Settings import: could not restore the token of marketplace account {}: {}",
account_id,
e
);
warnings.push(format!(
"Could not restore a marketplace account's token ({}); sign that account in again.",
e
));
}
}
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
@@ -212,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,
@@ -320,6 +472,16 @@ pub async fn apply_settings_import(
.or_else(|| current.web_terminal.access_token.clone());
crate::commands::settings_commands::validate_settings_update(&current, &settings)?;
// The marketplace half, with the commands' own rules and normalisation,
// also before anything is written (pre-flight F10).
let marketplace_tokens = payload.secrets.marketplace_account_tokens;
crate::commands::marketplace_commands::validate_imported_marketplace_state(
&mut settings,
&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;
@@ -363,8 +525,32 @@ pub async fn apply_settings_import(
}
}
secret_restore_warnings.extend(restore_marketplace_tokens(
&marketplace_tokens,
secure::store_marketplace_token,
));
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 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;
for id in mc::dropped_marketplace_ids(&current, &saved) {
mc::remove_cache(&state, &id).await;
}
mc::refresh_pins(&state).await;
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
// *shape* — port, provider, base URL, models — because that's what's
@@ -398,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())
}
@@ -651,4 +865,234 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
fn account(id: &str, method: AccountMethod) -> MarketplaceAccount {
MarketplaceAccount {
id: id.to_string(),
label: format!("Account {id}"),
host: "github.com".to_string(),
method,
username: None,
}
}
#[test]
fn export_carries_stored_tokens_of_token_and_container_accounts_only() {
let accounts = vec![
account("a-token", AccountMethod::Token),
account("a-container", AccountMethod::GhContainer),
account("a-host", AccountMethod::GhHost),
account("a-missing", AccountMethod::Token),
account("a-broken", AccountMethod::Token),
];
let tokens = exported_marketplace_tokens(&accounts, |id| match id {
"a-token" => Ok(Some("test-token-not-real-1".to_string())),
"a-container" => Ok(Some("test-token-not-real-2".to_string())),
"a-host" => panic!("a gh-host account stores no token, so none is read"),
"a-missing" => Ok(None),
_ => Err("keychain locked".to_string()),
});
assert_eq!(
tokens,
BTreeMap::from([
("a-container".to_string(), "test-token-not-real-2".to_string()),
("a-token".to_string(), "test-token-not-real-1".to_string()),
])
);
}
#[test]
fn marketplace_tokens_round_trip_through_an_export_and_validate_on_import() {
use crate::models::marketplace::Marketplace;
let id = "0f8fad5b-d9cb-469f-a165-70867728950e";
let mut payload = sample_payload(SETTINGS_EXPORT_FORMAT_VERSION);
payload
.settings
.marketplace_accounts
.push(account(id, AccountMethod::Token));
payload.settings.marketplaces.push(Marketplace {
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7".into(),
name: "Team".into(),
url: "https://github.com/org/repo.git".into(),
branch: None,
account_id: Some(id.into()),
});
payload.secrets.marketplace_account_tokens =
BTreeMap::from([(id.to_string(), "test-token-not-real".to_string())]);
let dir = temp_dir("marketplace-round-trip");
let path = write_export(&dir, "x.triplec", &payload, "password123");
let mut back = read_and_decrypt(&path, "password123").unwrap();
assert_eq!(
back.secrets.marketplace_account_tokens,
payload.secrets.marketplace_account_tokens
);
assert_eq!(back.settings.marketplaces, payload.settings.marketplaces);
crate::commands::marketplace_commands::validate_imported_marketplace_state(
&mut back.settings,
&back.secrets.marketplace_account_tokens,
)
.unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_marketplace_token_that_fails_to_restore_is_reported_without_its_value() {
let tokens = BTreeMap::from([
("a1".to_string(), "test-token-not-real-1".to_string()),
("a2".to_string(), "test-token-not-real-2".to_string()),
]);
let mut stored = Vec::new();
let warnings = restore_marketplace_tokens(&tokens, |id, token| {
if id == "a2" {
return Err("keychain locked".to_string());
}
stored.push((id.to_string(), token.to_string()));
Ok(())
});
assert_eq!(
stored,
vec![("a1".to_string(), "test-token-not-real-1".to_string())]
);
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]);
}
}
+458
View File
@@ -0,0 +1,458 @@
//! The AWS stop guard: what `triple-c-wip-push` says about a workspace's
//! unsaved git work, and the push a Stop may run first.
//!
//! The decisions are made over [`WipRunner`] so a fake target exercises them;
//! [`ContainerTarget`] runs the script through the agent as `claude` (the
//! script needs the user's credential helper and `~/.ssh`).
use std::future::Future;
use std::time::Duration;
use crate::host::ContainerTarget;
use crate::models::{RepoState, UnsavedWork, WipChoice};
/// The script, baked into the image (`container/triple-c-wip-push`).
pub const WIP_PUSH: &str = "/usr/local/bin/triple-c-wip-push";
/// A dry run only reads `git status`; it should be quick.
pub const DRY_RUN_TIMEOUT: Duration = Duration::from_secs(30);
/// A push goes over the network, once per dirty repo.
pub const PUSH_TIMEOUT: Duration = Duration::from_secs(300);
/// Cap on the push output carried into an error.
const MAX_OUTPUT: usize = 4000;
/// How much sooner than the client's timeout the in-container `timeout`
/// ends the script (its TERM, then 5 s to the KILL): an exec the client gives
/// up on is not killed by the agent, so the container bounds it itself.
const IN_CONTAINER_MARGIN: Duration = Duration::from_secs(10);
/// Where the entrypoint's stop hook looks for the app's decision (Task 7's
/// file, which the idle watcher also writes `idle` to).
pub const STOP_REASON_FILE: &str = "/run/triple-c/stop-reason";
/// Recorded after the guard's push succeeded: the hook does not push again.
pub const MARKER_PUSHED: &str = "pushed";
/// Recorded for "Stop without pushing": the hook does not push.
pub const MARKER_DISCARD: &str = "discard";
/// The workspace, as the stop guard uses it.
pub trait WipRunner: Sync {
/// Run the WIP script as `claude`: `(combined output, exit code)`. The
/// caller waits at most `limit`; the run must not outlive it.
fn run(
&self,
args: Vec<String>,
limit: Duration,
) -> impl Future<Output = Result<(String, i64), String>> + Send;
/// Record `marker` in [`STOP_REASON_FILE`] (as root: the directory is root's).
fn write_stop_reason(&self, marker: &str) -> impl Future<Output = Result<(), String>> + Send;
/// Remove [`STOP_REASON_FILE`] (as root), so the hook pushes as configured again.
fn clear_stop_reason(&self) -> impl Future<Output = Result<(), String>> + Send;
}
impl WipRunner for ContainerTarget {
async fn run(&self, args: Vec<String>, limit: Duration) -> Result<(String, i64), String> {
self.exec_oneshot_as("claude", wip_command(args, limit), Vec::new()).await
}
async fn write_stop_reason(&self, marker: &str) -> Result<(), String> {
let (out, code) = self
.exec_oneshot_as("root", stop_reason_command(marker), Vec::new())
.await?;
if code == 0 {
Ok(())
} else {
Err(format!("exit {code}: {}", cap(&out)))
}
}
async fn clear_stop_reason(&self) -> Result<(), String> {
let (out, code) = self
.exec_oneshot_as("root", clear_stop_reason_command(), Vec::new())
.await?;
if code == 0 {
Ok(())
} else {
Err(format!("exit {code}: {}", cap(&out)))
}
}
}
/// The script's argv under coreutils `timeout`, ending it
/// [`IN_CONTAINER_MARGIN`] before the client's `limit` (KILL 5 s after the
/// TERM), so a push the app gave up on is not left running in the workspace.
pub fn wip_command(args: Vec<String>, limit: Duration) -> Vec<String> {
let secs = limit.saturating_sub(IN_CONTAINER_MARGIN).as_secs().max(1);
let mut cmd = vec![
"/usr/bin/timeout".to_string(),
"-k".to_string(),
"5".to_string(),
secs.to_string(),
WIP_PUSH.to_string(),
];
cmd.extend(args);
cmd
}
/// The root command that forgets a recorded marker (a fixed argv).
pub fn clear_stop_reason_command() -> Vec<String> {
vec!["rm".into(), "-f".into(), STOP_REASON_FILE.into()]
}
/// After StopTask failed: the workspace keeps running, so a marker this stop
/// recorded must not suppress the push of a later stop (Spot, ECS, idle) the
/// user never chose for. Best effort; logged.
pub async fn forget_choice<R: WipRunner>(runner: Option<&R>, choice: Option<WipChoice>) {
if choice.is_none() {
return;
}
let Some(r) = runner else {
log::warn!("Stop failed and the workspace is unreachable; its stop marker may remain");
return;
};
match tokio::time::timeout(DRY_RUN_TIMEOUT, r.clear_stop_reason()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => log::warn!("Could not remove the stop marker after a failed stop: {e}"),
Err(_) => log::warn!("Removing the stop marker after a failed stop timed out"),
}
}
/// The root command that records `marker`; the value travels as `$1`, never
/// inside the script.
pub fn stop_reason_command(marker: &str) -> Vec<String> {
vec![
"sh".into(),
"-c".into(),
format!(
"umask 077 && mkdir -p /run/triple-c && chmod 700 /run/triple-c && printf '%s\\n' \"$1\" > {STOP_REASON_FILE}"
),
"sh".into(),
marker.into(),
]
}
/// The repository lines of a dry run. Lines that are not JSON objects (a
/// warning on stderr) are skipped; a JSON line of the wrong shape is an error.
pub fn parse_dry_run(output: &str) -> Result<Vec<RepoState>, String> {
output
.lines()
.map(str::trim)
.filter(|l| l.starts_with('{'))
.map(|l| {
serde_json::from_str::<RepoState>(l)
.map_err(|e| format!("Unexpected output from the WIP check: {e}"))
})
.collect()
}
fn unchecked(reason: String) -> UnsavedWork {
UnsavedWork { checked: false, repos: Vec::new(), error: Some(reason) }
}
/// Ask the workspace what a stop would lose. `runner` is `None` when the
/// workspace's agent is not reachable (no tunnel).
pub async fn unsaved_work<R: WipRunner>(runner: Option<&R>, timeout: Duration) -> UnsavedWork {
let Some(runner) = runner else {
return unchecked(
"Triple-C is not connected to the workspace, so it can't check for unsaved work."
.into(),
);
};
let res = tokio::time::timeout(timeout, runner.run(vec!["--dry-run".into()], timeout)).await;
match res {
Err(_) => unchecked("The workspace did not answer the check for unsaved work in time.".into()),
Ok(Err(e)) => unchecked(format!("The workspace could not be asked about unsaved work: {e}")),
Ok(Ok((out, code))) if code != 0 => unchecked(format!(
"The check for unsaved work failed (exit {code}): {}",
cap(&out)
)),
Ok(Ok((out, _))) => match parse_dry_run(&out) {
Ok(repos) => UnsavedWork { checked: true, repos, error: None },
Err(e) => unchecked(e),
},
}
}
fn cap(s: &str) -> String {
let t = s.trim();
if t.len() <= MAX_OUTPUT {
return t.to_string();
}
let mut end = t.len() - MAX_OUTPUT;
while !t.is_char_boundary(end) {
end += 1;
}
format!("…{}", &t[end..])
}
/// What Stop does before stopping, given the user's choice. `Push` runs the
/// push and fails (aborting the stop) with its output when it does not
/// succeed; `Discard` and no choice do nothing.
pub async fn before_stop<R: WipRunner>(
runner: Option<&R>,
choice: Option<WipChoice>,
timeout: Duration,
) -> Result<(), String> {
if choice != Some(WipChoice::Push) {
return Ok(());
}
let runner = runner.ok_or_else(|| {
"Triple-C is not connected to the workspace, so it can't push unsaved work. The \
workspace was not stopped."
.to_string()
})?;
let res =
tokio::time::timeout(timeout, runner.run(vec!["--reason".into(), "stop".into()], timeout)).await;
match res {
Err(_) => Err("Pushing unsaved work did not finish in time. The workspace was not stopped.".into()),
Ok(Err(e)) => Err(format!("Could not push unsaved work: {e}. The workspace was not stopped.")),
Ok(Ok((out, 0))) => {
log::info!("WIP push before stop: {}", cap(&out));
Ok(())
}
Ok(Ok((out, code))) => Err(format!(
"Pushing unsaved work failed (exit {code}), so the workspace was not stopped:\n{}",
cap(&out)
)),
}
}
/// The note a stop returns when the hook's own push may still run.
pub const MARKER_NOT_RECORDED: &str = "Triple-C could not tell the workspace what you chose, so its own \
push of work in progress on shutdown may still run (if it is turned on).";
/// Everything the guard does before StopTask: `Push` pushes (a failure is an
/// `Err` and the stop must not happen), then records `pushed`; `Discard`
/// records `discard`; no choice does nothing. A marker that can't be
/// written does not stop the stop: `Ok(Some(note))` says the hook may push.
pub async fn prepare_stop<R: WipRunner>(
runner: Option<&R>,
choice: Option<WipChoice>,
push_timeout: Duration,
) -> Result<Option<String>, String> {
let marker = match choice {
None => return Ok(None),
Some(WipChoice::Push) => {
before_stop(runner, choice, push_timeout).await?;
MARKER_PUSHED
}
Some(WipChoice::Discard) => MARKER_DISCARD,
};
let written = match runner {
Some(r) => tokio::time::timeout(DRY_RUN_TIMEOUT, r.write_stop_reason(marker))
.await
.unwrap_or_else(|_| Err("timed out".into())),
None => Err("not connected to the workspace".into()),
};
match written {
Ok(()) => Ok(None),
Err(e) => {
log::warn!("Could not record the stop guard's choice ({marker}): {e}");
Ok(Some(MARKER_NOT_RECORDED.to_string()))
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::sync::Mutex;
pub(crate) struct Fake {
pub reply: Result<(String, i64), String>,
pub calls: Mutex<Vec<Vec<String>>>,
pub hang: bool,
pub markers: Mutex<Vec<String>>,
pub marker_fails: bool,
}
impl Fake {
pub fn new(reply: Result<(String, i64), String>) -> Self {
Fake { reply, calls: Mutex::new(Vec::new()), hang: false, markers: Mutex::new(Vec::new()), marker_fails: false }
}
fn markers(&self) -> Vec<String> {
self.markers.lock().unwrap().clone()
}
fn calls(&self) -> Vec<Vec<String>> {
self.calls.lock().unwrap().clone()
}
}
impl WipRunner for Fake {
async fn run(&self, args: Vec<String>, _limit: Duration) -> Result<(String, i64), String> {
self.calls.lock().unwrap().push(args);
if self.hang {
std::future::pending::<()>().await;
}
self.reply.clone()
}
async fn write_stop_reason(&self, marker: &str) -> Result<(), String> {
if self.marker_fails {
return Err("agent unreachable".into());
}
self.markers.lock().unwrap().push(marker.to_string());
Ok(())
}
async fn clear_stop_reason(&self) -> Result<(), String> {
self.markers.lock().unwrap().push("<cleared>".to_string());
Ok(())
}
}
const T: Duration = Duration::from_secs(5);
#[test]
fn a_dry_run_is_parsed_skipping_non_json_lines() {
let out = "warning: something\n{\"folder\":\"api\",\"dirty\":true,\"unpushed\":0,\"branch\":\"main\"}\n\
{\"folder\":\"web\",\"dirty\":false,\"unpushed\":2,\"branch\":\"HEAD\"}\n";
let repos = parse_dry_run(out).unwrap();
assert_eq!(repos.len(), 2);
assert_eq!(repos[0], RepoState { folder: "api".into(), dirty: true, unpushed: 0, branch: "main".into(), cloning: false });
assert_eq!(repos[1].unpushed, 2);
assert!(parse_dry_run("{\"folder\":1}").is_err());
assert!(parse_dry_run("").unwrap().is_empty());
}
#[tokio::test]
async fn the_check_runs_a_dry_run_and_reports_the_repos() {
let fake = Fake::new(Ok(("{\"folder\":\"a\",\"dirty\":false,\"unpushed\":0,\"branch\":\"main\"}".into(), 0)));
let w = unsaved_work(Some(&fake), T).await;
assert!(w.checked && w.error.is_none());
assert_eq!(w.repos.len(), 1);
assert_eq!(fake.calls(), vec![vec!["--dry-run".to_string()]]);
}
#[tokio::test]
async fn an_unreachable_workspace_is_unchecked_with_a_reason() {
let w = unsaved_work::<Fake>(None, T).await;
assert!(!w.checked);
assert!(w.error.as_deref().unwrap().contains("not connected"));
let failing = Fake::new(Err("connection refused".into()));
let w = unsaved_work(Some(&failing), T).await;
assert!(!w.checked && w.error.unwrap().contains("connection refused"));
let nonzero = Fake::new(Ok(("boom".into(), 2)));
let w = unsaved_work(Some(&nonzero), T).await;
assert!(!w.checked && w.error.unwrap().contains("exit 2"));
}
#[tokio::test(start_paused = true)]
async fn a_check_that_hangs_times_out_unchecked() {
let mut fake = Fake::new(Ok((String::new(), 0)));
fake.hang = true;
let w = unsaved_work(Some(&fake), Duration::from_secs(1)).await;
assert!(!w.checked && w.error.unwrap().contains("in time"));
}
#[tokio::test]
async fn push_runs_the_script_with_reason_stop() {
let fake = Fake::new(Ok(("pushed".into(), 0)));
before_stop(Some(&fake), Some(WipChoice::Push), T).await.unwrap();
assert_eq!(fake.calls(), vec![vec!["--reason".to_string(), "stop".to_string()]]);
}
#[tokio::test]
async fn a_failed_push_aborts_with_its_output() {
let fake = Fake::new(Ok(("triple-c-wip-push: api: push to origin failed: denied".into(), 1)));
let e = before_stop(Some(&fake), Some(WipChoice::Push), T).await.unwrap_err();
assert!(e.contains("push to origin failed: denied"), "{e}");
assert!(e.contains("not stopped"));
let gone = Fake::new(Err("tunnel closed".into()));
assert!(before_stop(Some(&gone), Some(WipChoice::Push), T).await.unwrap_err().contains("tunnel closed"));
assert!(before_stop::<Fake>(None, Some(WipChoice::Push), T).await.is_err());
}
#[tokio::test]
async fn discard_or_no_choice_runs_nothing() {
let fake = Fake::new(Err("must not run".into()));
before_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap();
before_stop(Some(&fake), None, T).await.unwrap();
before_stop::<Fake>(None, None, T).await.unwrap();
assert!(fake.calls().is_empty());
}
#[test]
fn the_script_is_bounded_inside_the_workspace_before_the_client_gives_up() {
let cmd = wip_command(vec!["--reason".into(), "stop".into()], PUSH_TIMEOUT);
assert_eq!(cmd, vec!["/usr/bin/timeout", "-k", "5", "290", WIP_PUSH, "--reason", "stop"]);
let secs: u64 = cmd[3].parse().unwrap();
assert!(secs + 5 < PUSH_TIMEOUT.as_secs(), "TERM and KILL land before the client's timeout");
let dry = wip_command(vec!["--dry-run".into()], DRY_RUN_TIMEOUT);
assert_eq!(&dry[3..], &["20", WIP_PUSH, "--dry-run"]);
assert_eq!(wip_command(Vec::new(), Duration::from_secs(3))[3], "1", "never 0 (no limit)");
// The image has coreutils' timeout where the argv says.
let df = include_str!("../../../../container/Dockerfile");
assert!(df.contains("ubuntu:24.04") || df.contains("ubuntu:noble"), "coreutils at /usr/bin");
}
#[test]
fn a_cloning_repo_parses_with_its_flag_and_older_lines_without_it() {
let out = "{\"folder\":\"half\",\"dirty\":false,\"unpushed\":0,\"branch\":\"\",\"cloning\":true}\n\
{\"folder\":\"a\",\"dirty\":true,\"unpushed\":0,\"branch\":\"main\"}";
let repos = parse_dry_run(out).unwrap();
assert!(repos[0].cloning && !repos[1].cloning);
}
#[test]
fn long_output_keeps_its_end() {
let s = format!("{}END", "x".repeat(10_000));
let c = cap(&s);
assert!(c.ends_with("END") && c.len() < 4100);
}
#[tokio::test]
async fn a_successful_push_records_pushed_and_stop_without_pushing_records_discard() {
let fake = Fake::new(Ok(("pushed".into(), 0)));
assert_eq!(prepare_stop(Some(&fake), Some(WipChoice::Push), T).await.unwrap(), None);
assert_eq!(fake.markers(), vec![MARKER_PUSHED]);
let fake = Fake::new(Err("must not run".into()));
assert_eq!(prepare_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap(), None);
assert!(fake.calls().is_empty());
assert_eq!(fake.markers(), vec![MARKER_DISCARD]);
assert_eq!(prepare_stop(Some(&fake), None, T).await.unwrap(), None);
assert_eq!(fake.markers().len(), 1, "no choice records nothing");
}
#[tokio::test]
async fn a_failed_push_records_nothing() {
let fake = Fake::new(Ok(("denied".into(), 1)));
assert!(prepare_stop(Some(&fake), Some(WipChoice::Push), T).await.is_err());
assert!(fake.markers().is_empty());
}
#[tokio::test]
async fn an_unwritable_marker_still_stops_and_says_the_hook_may_push() {
let mut fake = Fake::new(Ok((String::new(), 0)));
fake.marker_fails = true;
let note = prepare_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap();
assert!(note.unwrap().contains("may still run"));
let note = prepare_stop::<Fake>(None, Some(WipChoice::Discard), T).await.unwrap();
assert!(note.is_some());
}
#[test]
fn the_marker_is_written_as_an_argument_to_the_file_the_hook_reads() {
let cmd = stop_reason_command("pushed");
assert_eq!(&cmd[..2], &["sh".to_string(), "-c".to_string()]);
assert!(cmd[2].contains("> /run/triple-c/stop-reason") && cmd[2].contains("\"$1\""));
assert_eq!(cmd.last().unwrap(), "pushed");
// The entrypoint matches exactly these values.
let ep = include_str!("../../../../container/entrypoint.sh");
assert!(ep.contains("pushed|discard)") && ep.contains(STOP_REASON_FILE));
}
#[tokio::test]
async fn a_failed_stop_forgets_the_recorded_choice() {
let fake = Fake::new(Ok((String::new(), 0)));
prepare_stop(Some(&fake), Some(WipChoice::Discard), T).await.unwrap();
forget_choice(Some(&fake), Some(WipChoice::Discard)).await;
assert_eq!(fake.markers(), vec![MARKER_DISCARD, "<cleared>"]);
// An unguarded stop recorded nothing, so there is nothing to forget.
let fake = Fake::new(Ok((String::new(), 0)));
forget_choice(Some(&fake), None).await;
assert!(fake.markers().is_empty());
forget_choice::<Fake>(None, Some(WipChoice::Push)).await;
assert_eq!(clear_stop_reason_command(), vec!["rm", "-f", "/run/triple-c/stop-reason"]);
}
}
+280 -52
View File
@@ -1,15 +1,76 @@
use tauri::{AppHandle, Emitter, State};
use crate::commands::aws_commands;
use crate::host::ContainerTarget;
use crate::models::{Backend, BedrockAuthMethod, Project};
use crate::AppState;
/// Build the command to run in the container terminal.
///
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`]
/// before `exec claude`. For Bedrock Profile projects the script additionally
/// validates the AWS session first, and runs `aws sso login` if it has expired
/// so the user can re-authenticate (the URL is clickable via xterm.js
/// WebLinksAddon).
fn build_terminal_cmd(
project: &Project,
state: &AppState,
session_name: Option<&str>,
resume_session_id: Option<&str>,
) -> Vec<String> {
let settings = state.settings_store.get();
build_claude_terminal_cmd(
project,
settings.global_aws.aws_profile.as_deref(),
session_name,
resume_session_id,
)
}
/// Shell line run immediately before `exec claude` in every Claude terminal
/// session.
///
/// `container/entrypoint.sh` already runs `claude update` when the container
/// starts, but containers here use a stop/start (and often just keep running)
/// model, so a long-lived container's CLI goes stale between restarts. Running
/// it per session is what keeps a week-old container current.
///
/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no
/// network, npm registry down) so a session always opens, and `timeout 60`
/// bounds how long a user waits for a terminal.
///
/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against
/// the entrypoint's own `claude update`, and the entrypoint prints "container
/// ready" only *after* its copy finishes — so "start the project, open a tab"
/// races two updaters against the same `~/.claude/bin` install, as does
/// opening two tabs at once. `|| echo` would then hide a half-written install
/// behind a friendly message and the very next line (`exec claude`) would run
/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather
/// than failing the wait, and `-E 0` makes losing the race a success: the
/// other holder just updated, so there is nothing left to do.
pub(crate) const UPDATE_PRELUDE: &str = concat!(
"flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ",
r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#,
);
/// Single-quote one argument for interpolation into a shell script string.
fn shell_quote_arg(arg: &str) -> String {
format!(" '{}'", arg.replace('\'', "'\\''"))
}
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
/// profile rather than the whole [`AppState`].
///
/// `resume_session_id` adds `--resume <id>` (flag spelling checked against the
/// CLI in the container image) and drops `-n`: the resumed conversation keeps
/// the name it already has, and the tab label the caller passes as
/// `session_name` — often just "resume" — must not overwrite it.
fn build_claude_terminal_cmd(
project: &Project,
global_aws_profile: Option<&str>,
session_name: Option<&str>,
resume_session_id: Option<&str>,
) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
.bedrock_config
@@ -19,36 +80,30 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
let permission_args = project.effective_permission_mode().cli_args();
// The args are interpolated into a shell script string, so single-quote
// each one.
let session_flags = match resume_session_id {
Some(id) => format!(" --resume{}", shell_quote_arg(id)),
None => session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n{}", shell_quote_arg(n)))
.unwrap_or_default(),
};
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, session_flags);
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
cmd.extend(permission_args);
if let Some(name) = session_name {
if !name.is_empty() {
cmd.push("-n".to_string());
cmd.push(name.to_string());
}
}
return cmd;
return vec![
"bash".to_string(),
"-c".to_string(),
format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd),
];
}
let profile = aws_commands::resolve_profile_for_project(
project,
state.settings_store.get().global_aws.aws_profile.as_deref(),
);
let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile);
// Build a bash wrapper that validates credentials, re-auths if needed,
// then exec's into claude.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
.unwrap_or_default();
// The args are interpolated into a shell script string, so single-quote
// each one (same escaping style as name_flag above).
let permission_flags: String = permission_args
.iter()
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
.collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
let script = format!(
r#"
@@ -75,9 +130,11 @@ else
echo ""
fi
fi
{update_prelude}
{claude_cmd}
"#,
profile = profile,
update_prelude = UPDATE_PRELUDE,
claude_cmd = claude_cmd
);
@@ -94,6 +151,7 @@ pub async fn open_terminal_session(
session_id: String,
session_type: Option<String>,
session_name: Option<String>,
resume_session_id: Option<String>,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
@@ -102,14 +160,26 @@ pub async fn open_terminal_session(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
// Waits while an AWS project's tunnel is being re-opened after a restart.
let target = ContainerTarget::for_project_waiting(&project)
.await
.ok_or_else(|| "Container not running".to_string())?;
if let Some(id) = resume_session_id.as_deref() {
crate::commands::inspect_commands::validate_session_id(id)?;
}
let cmd = match session_type.as_deref() {
Some("bash") if resume_session_id.is_some() => {
return Err("Only a Claude session can resume a conversation".to_string());
}
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
_ => build_terminal_cmd(
&project,
&state,
session_name.as_deref(),
resume_session_id.as_deref(),
),
};
let output_event = format!("terminal-output-{}", session_id);
@@ -120,7 +190,7 @@ pub async fn open_terminal_session(
state
.exec_manager
.create_session(
container_id,
&target,
&session_id,
cmd,
move |data| {
@@ -171,7 +241,7 @@ pub async fn paste_image_to_terminal(
image_data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let target = state.exec_manager.get_target(&session_id).await?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -181,7 +251,7 @@ pub async fn paste_image_to_terminal(
state
.exec_manager
.write_file_to_container(&container_id, &file_name, &image_data)
.write_file_to_container(&target, &file_name, &image_data)
.await
}
@@ -214,7 +284,7 @@ pub async fn upload_host_file_to_terminal(
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let target = state.exec_manager.get_target(&session_id).await?;
let meta = tokio::fs::metadata(&host_path)
.await
@@ -244,7 +314,7 @@ pub async fn upload_host_file_to_terminal(
// with the code that does the reading, which re-applies it to the open
// descriptor — this check is here only so the refusal reads like a sentence
// instead of arriving after a 300 MB read.
use crate::docker::exec::MAX_DROP_BYTES;
use crate::host::MAX_DROP_BYTES;
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
@@ -257,20 +327,18 @@ pub async fn upload_host_file_to_terminal(
// Ensure the destination directory exists rather than relying on Docker's
// archive extractor to create the parent for the uploaded tar entry.
crate::docker::exec::exec_oneshot(
&container_id,
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
)
.await?;
target
.exec_oneshot(vec![
"mkdir".to_string(),
"-p".to_string(),
"/tmp/triple-c-drops".to_string(),
])
.await?;
let file_name = format!("triple-c-drops/{}", base);
crate::docker::exec::upload_host_file_to_container(
&container_id,
&host_path,
"/tmp",
&file_name,
)
.await
target
.upload_host_file(&host_path, "/tmp", &file_name)
.await
}
#[tauri::command]
@@ -278,8 +346,8 @@ pub async fn start_audio_bridge(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
// Get container_id from the terminal session
let container_id = state.exec_manager.get_container_id(&session_id).await?;
// Get the container from the terminal session
let target = state.exec_manager.get_target(&session_id).await?;
// Create audio bridge exec session with ID "audio-{session_id}"
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
@@ -293,7 +361,7 @@ pub async fn start_audio_bridge(
state
.exec_manager
.create_session_with_tty(
&container_id,
&target,
&audio_session_id,
cmd,
false,
@@ -325,6 +393,9 @@ pub async fn stop_audio_bridge(
#[cfg(test)]
mod tests {
use super::{build_claude_terminal_cmd, UPDATE_PRELUDE};
use crate::models::Project;
/// A dropped file must be named the way the *user* named it.
///
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
@@ -338,6 +409,163 @@ mod tests {
/// answer comes from the spelling, and a path that does not name a file is
/// refused rather than silently substituted (it used to fall back to
/// `"dropped-file"`).
/// A `Project` with only the fields these tests care about set; the rest
/// come through serde so the test does not have to track every field.
fn project(backend: &str, bedrock_config: serde_json::Value) -> Project {
serde_json::from_value(serde_json::json!({
"id": "p1",
"name": "Test",
"paths": [],
"container_id": null,
"status": "running",
"backend": backend,
"bedrock_config": bedrock_config,
"ollama_config": null,
"openai_compatible_config": null,
"allow_docker_access": false,
"full_permissions": false,
"ssh_key_path": null,
"git_user_name": null,
"git_user_email": null,
"created_at": "now",
"updated_at": "now"
}))
.expect("test project deserializes")
}
/// Every Claude session updates the CLI before launching it.
///
/// `container/entrypoint.sh` only updates at container *start*, and these
/// containers are long-lived, so a stale CLI is the normal case without
/// this. The plain (non-Bedrock) path therefore has to be a `bash -c`
/// wrapper rather than a bare `claude` argv.
#[test]
fn build_terminal_cmd_updates_before_launching_claude() {
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None, None);
assert_eq!(cmd[0], "bash");
assert_eq!(cmd[1], "-c");
assert!(
cmd[2].contains(UPDATE_PRELUDE),
"plain path must run the update prelude: {}",
cmd[2]
);
assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]);
// The update has to happen *before* the exec, which never returns.
assert!(
cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(),
"prelude must precede the exec: {}",
cmd[2]
);
assert!(
UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"),
"the update must stay time-bounded and non-fatal"
);
}
/// The session name is interpolated into a shell script, so a quote in it
/// must not break out of its single-quoted argument.
#[test]
fn build_terminal_cmd_escapes_a_quoted_session_name() {
let cmd = build_claude_terminal_cmd(
&project("anthropic", serde_json::Value::Null),
None,
Some("Bob's tab; rm -rf /"),
None,
);
assert!(
cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#),
"session name must be single-quote escaped: {}",
cmd[2]
);
}
/// Permission flags travel the same escaped path, and an empty name adds
/// no `-n` at all.
#[test]
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
let mut p = project("anthropic", serde_json::Value::Null);
p.full_permissions = true;
let cmd = build_claude_terminal_cmd(&p, None, Some(""), None);
assert!(
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
"got: {}",
cmd[2]
);
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
}
/// Auto mode is passed as a `--permission-mode` value, not its own flag.
#[test]
fn build_terminal_cmd_passes_auto_permission_mode() {
let mut p = project("anthropic", serde_json::Value::Null);
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
let cmd = build_claude_terminal_cmd(&p, None, None, None);
assert!(
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
"got: {}",
cmd[2]
);
}
/// The Bedrock-profile path keeps its AWS validation *and* gains the
/// prelude, immediately before the exec.
#[test]
fn build_terminal_cmd_bedrock_validates_aws_and_updates() {
let cmd = build_claude_terminal_cmd(
&project("bedrock", serde_json::json!({
"auth_method": "profile",
"aws_region": "us-east-1",
"aws_profile": "acme",
"model_id": null,
"disable_prompt_caching": false
})),
None,
Some("it's fine"),
None,
);
assert_eq!(cmd[0], "bash");
let script = &cmd[2];
assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script);
assert!(script.contains("triple-c-sso-refresh"), "got: {}", script);
assert!(script.contains(UPDATE_PRELUDE), "got: {}", script);
assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script);
assert!(
script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(),
"prelude must precede the exec: {}",
script
);
}
/// Resume goes through the same launch as a new session — prelude,
/// permission flags — and passes the id instead of the tab label, so a
/// label of "resume" does not rename the conversation.
#[test]
fn build_terminal_cmd_resumes_without_renaming() {
let mut p = project("anthropic", serde_json::Value::Null);
p.full_permissions = true;
let cmd = build_claude_terminal_cmd(
&p,
None,
Some("resume"),
Some("e13d312d-2f38-4cf6-b0e0-1db60208a74c"),
);
assert!(cmd[2].contains(UPDATE_PRELUDE), "got: {}", cmd[2]);
assert!(
cmd[2].contains(
"exec claude '--dangerously-skip-permissions' --resume 'e13d312d-2f38-4cf6-b0e0-1db60208a74c'\n"
),
"got: {}",
cmd[2]
);
assert!(!cmd[2].contains(" -n "), "resume must not rename: {}", cmd[2]);
}
#[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() {
use crate::commands::file_commands::host_upload_name;
+459
View File
@@ -0,0 +1,459 @@
//! Notices a project container that stopped without the app asking.
//!
//! Until this existed the app only ever learned a container's state from its
//! own commands and from `reconcile_project_statuses`, which the frontend runs
//! once, when Docker first answers. A container that died underneath a running
//! app — the entrypoint's keepalive killed, an OOM, `docker stop` from a shell,
//! a daemon restart — left its project at Running indefinitely: every terminal
//! disconnected, while the sidebar, Start/Stop and Project Home all still
//! described a live container.
//!
//! 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
//! project a command owns and refuses if the store changed while the inspect
//! was in flight. The frontend hears it on [`CONTAINER_EXITED_EVENT`].
//!
//! The stream drops whenever the daemon does. Each (re)subscription asks for
//! events since just before it, and first re-checks every Running project once —
//! 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::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tokio::sync::watch;
use crate::aws::tasks::StopKind;
use crate::host::watch::ExitSource;
use crate::host::ContainerTarget;
use crate::models::{Project, ProjectStatus};
use crate::storage::projects_store::{ExitMark, ProjectsStore};
/// Emitted as a [`ContainerExited`] whenever this module flips a project to
/// Stopped.
pub const CONTAINER_EXITED_EVENT: &str = "project-container-exited";
const RETRY_MIN: Duration = Duration::from_secs(5);
const RETRY_MAX: Duration = Duration::from_secs(60);
/// A stream that stayed up this long (or delivered anything) was a working
/// connection, so its loss starts the backoff over. Anything shorter — the
/// daemon answers ping but the events call fails — keeps backing off, or it
/// would re-sweep and re-log every five seconds forever.
const STABLE_AFTER: Duration = Duration::from_secs(60);
/// How often `settle` re-confirms when the store keeps changing under it.
const SETTLE_ATTEMPTS: usize = 3;
/// How the app found out, which decides whether it is news to the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ExitReason {
/// The container died while this app was running and watching — any
/// sessions it had were just cut off.
Exited,
/// Found already stopped on the first look of this run: it went down
/// while the app was closed (a host reboot, typically). Nothing the user
/// can see was interrupted, so it is not news.
FoundStopped,
}
#[derive(Debug, Clone, Serialize)]
pub struct ContainerExited<'a> {
pub project: &'a Project,
pub reason: ExitReason,
/// AWS: why ECS says the task stopped (P8). `None` on Docker, or when
/// the watch has no reason on record.
pub stop: Option<StopNote>,
}
/// Why an AWS task stopped, for the UI's toast.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct StopNote {
/// `spot`, `idle`, `failed` (the project goes to Error), `user` (someone
/// stopped it: no toast) or `exited` (on its own).
pub kind: &'static str,
/// ECS's stopped reason, or a fixed text.
pub message: Option<String>,
}
impl StopNote {
pub fn of(kind: &StopKind) -> StopNote {
let (kind, message) = match kind {
StopKind::Spot => ("spot", Some("Spot interruption".to_string())),
StopKind::Idle => ("idle", None),
StopKind::FailedToStart(r) => ("failed", Some(r.clone())),
StopKind::UserInitiated => ("user", None),
StopKind::Exited(r) => ("exited", r.clone()),
};
StopNote { kind, message }
}
}
fn project_busy(project_id: &str) -> bool {
crate::project_lock::held(project_id).is_some()
}
/// Confirm `container` really stopped and, if a project was Running on it,
/// flip that project. Returns what changed.
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)?;
// 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 the host was answering — possibly a stop and
// start of this very container. The inspect is stale; ask again.
ExitMark::Changed => continue,
}
}
None
}
/// Re-check every Running project once. Used on each (re)subscription.
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(container) = ContainerTarget::for_project(&p) else { continue };
if let Some(c) = settle(store, &container).await {
changed.push(c);
}
}
changed
}
/// 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 {
let doublings = consecutive_failures.saturating_sub(1).min(8);
(RETRY_MIN * 2u32.pow(doublings)).min(RETRY_MAX)
}
/// `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>(
cancel: &mut watch::Receiver<bool>,
fut: impl Future<Output = T>,
) -> Option<T> {
if *cancel.borrow() {
return None;
}
tokio::select! {
// `Err` means the sender is gone, i.e. so is the app.
_ = cancel.changed() => None,
v = fut => Some(v),
}
}
/// One connection: ping, sweep, then follow the event stream until it ends.
/// `None` when cancelled; otherwise whether the connection was a working one
/// (see [`STABLE_AFTER`]).
async fn watch_once(
store: &ProjectsStore,
source: &ExitSource,
cancel: &mut watch::Receiver<bool>,
sweep_reason: ExitReason,
on_change: &impl Fn(&Project, ExitReason),
) -> Option<bool> {
if !unless_cancelled(cancel, source.reachable()).await? {
return Some(false);
}
// 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;
for p in unless_cancelled(cancel, sweep(store)).await? {
on_change(&p, sweep_reason);
}
let mut deaths = source.deaths(since);
let connected = Instant::now();
let mut delivered = false;
loop {
match unless_cancelled(cancel, deaths.next()).await? {
Some(Ok(container)) => {
delivered = true;
if let Some(p) = unless_cancelled(cancel, settle(store, &container)).await? {
on_change(&p, ExitReason::Exited);
}
}
Some(Err(e)) => {
log::info!("Container watch: event stream ended ({e}) — reconnecting");
break;
}
None => {
log::info!("Container watch: event stream closed — reconnecting");
break;
}
}
}
Some(delivered || connected.elapsed() >= STABLE_AFTER)
}
/// Watch until `cancel` flips, calling `on_change` for every project whose
/// container is found to have stopped on its own. Separate from [`spawn`] so a
/// test can drive it without a Tauri app.
pub(crate) async fn run(
store: Arc<ProjectsStore>,
source: ExitSource,
mut cancel: watch::Receiver<bool>,
on_change: impl Fn(&Project, ExitReason),
) {
// Only the first look of the run finds containers that stopped while the
// app was closed. A sweep after a reconnect finds ones that died while we
// were running — the daemon restarting is the usual cause — and those
// cut off live sessions just as a `die` event would.
let mut sweep_reason = ExitReason::FoundStopped;
let mut failures: u32 = 0;
loop {
let Some(healthy) = watch_once(&store, &source, &mut cancel, sweep_reason, &on_change).await else {
return;
};
sweep_reason = ExitReason::Exited;
failures = if healthy { 1 } else { failures + 1 };
unless_cancelled(&mut cancel, tokio::time::sleep(retry_delay(failures))).await;
if *cancel.borrow() {
return;
}
}
}
/// Start a watcher on `source` for the life of the app. One per host kind:
/// Docker's event stream, and (Task 6) the AWS task poll.
pub fn spawn(
app: AppHandle,
store: Arc<ProjectsStore>,
source: ExitSource,
cancel: watch::Receiver<bool>,
) -> tauri::async_runtime::JoinHandle<()> {
tauri::async_runtime::spawn(async move {
let store_for_aws = store.clone();
run(store, source, cancel, move |p, reason| {
log::warn!(
"Project '{}' ({}): its container stopped without the app asking ({:?}) — marking Stopped",
p.name,
p.id,
reason
);
// Why ECS stopped it, read before the tunnel (and with it the
// registry's record) goes.
let kind = p
.is_aws()
.then(|| p.container_id.as_deref().and_then(crate::host::tunnels::stop_kind))
.flatten();
let mut shown = p.clone();
// A task that never got going is an error, not a stop (P8).
if let Some(StopKind::FailedToStart(_)) = kind {
if store_for_aws.update_status(&p.id, ProjectStatus::Error).is_ok() {
shown.status = ProjectStatus::Error;
}
}
// An AWS task that stopped: its tunnel leads nowhere now, and its
// task ARN is history (the data's access point stays).
if p.is_aws() {
if let Some(a) = p.aws.as_ref() {
let _ = store_for_aws.set_aws_runtime(
&p.id,
None,
a.access_point_id.clone(),
a.task_definition_arn.clone(),
);
}
let id = p.id.clone();
if let Some(arn) = p.aws.as_ref().and_then(|a| a.task_arn.clone()) {
tauri::async_runtime::spawn(async move {
crate::host::tunnels::close_if_task(&id, &arn).await
});
}
}
if let Some(store_p) = store_for_aws.get(&p.id) {
shown = store_p;
}
let stop = kind.as_ref().map(StopNote::of);
let _ = app.emit(CONTAINER_EXITED_EVENT, ContainerExited { project: &shown, reason, stop });
})
.await;
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retries_back_off_to_a_minute_and_stay_there() {
let secs: Vec<u64> = (1..=7).map(|n| retry_delay(n).as_secs()).collect();
assert_eq!(secs, vec![5, 10, 20, 40, 60, 60, 60]);
assert_eq!(retry_delay(0), RETRY_MIN);
assert_eq!(retry_delay(u32::MAX), RETRY_MAX);
}
#[test]
fn the_event_says_why_so_the_ui_can_tell_news_from_housekeeping() {
let p = Project::new("demo".to_string(), Vec::new());
let v = serde_json::to_value(ContainerExited { project: &p, reason: ExitReason::Exited, stop: None })
.unwrap();
assert_eq!(v["reason"], "exited");
assert_eq!(v["project"]["name"], "demo");
assert!(v["stop"].is_null());
let v = serde_json::to_value(ContainerExited {
project: &p,
reason: ExitReason::FoundStopped,
stop: Some(StopNote::of(&StopKind::Spot)),
})
.unwrap();
assert_eq!(v["reason"], "found_stopped");
assert_eq!(v["stop"], serde_json::json!({ "kind": "spot", "message": "Spot interruption" }));
assert_eq!(CONTAINER_EXITED_EVENT, "project-container-exited");
}
#[test]
fn every_stop_kind_has_a_note_for_the_ui() {
let cases = [
(StopKind::Spot, "spot", Some("Spot interruption")),
(StopKind::Idle, "idle", None),
(StopKind::FailedToStart("CannotPull".into()), "failed", Some("CannotPull")),
(StopKind::UserInitiated, "user", None),
(StopKind::Exited(Some("OOM".into())), "exited", Some("OOM")),
(StopKind::Exited(None), "exited", None),
];
for (k, kind, message) in cases {
let n = StopNote::of(&k);
assert_eq!((n.kind, n.message.as_deref()), (kind, message), "{k:?}");
}
}
/// 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() {
let (tx, mut rx) = watch::channel(false);
let canceller = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let _ = tx.send(true);
});
let hung = std::future::pending::<()>();
let out = tokio::time::timeout(Duration::from_secs(2), unless_cancelled(&mut rx, hung)).await;
assert_eq!(out.expect("returned promptly"), None);
canceller.await.unwrap();
// Already cancelled: it does not even start the call.
assert_eq!(unless_cancelled(&mut rx, async { 1 }).await, None);
}
/// The AWS source drives the same loop: it is "reachable" without a
/// ping, polls its lookup every interval, and gives way to cancellation.
/// No project is Running, so nothing is confirmed (and nothing reaches
/// Docker or AWS).
#[tokio::test(start_paused = true)]
async fn an_aws_source_polls_its_lookup_until_cancelled() {
use crate::host::watch::{AwsTaskLookup, AwsWatch, TaskGroup, WatchedTask};
use futures_util::future::BoxFuture;
use std::sync::atomic::{AtomicUsize, Ordering};
struct Lookup(AtomicUsize);
impl AwsTaskLookup for Lookup {
fn running_tasks(&self) -> Vec<WatchedTask> {
self.0.fetch_add(1, Ordering::SeqCst);
Vec::new()
}
fn stopped<'a>(
&'a self,
_: &'a TaskGroup,
_: &'a [String],
) -> BoxFuture<'a, Result<Vec<String>, String>> {
Box::pin(async { Ok(Vec::new()) })
}
}
let dir = std::env::temp_dir().join(format!("triple-c-watch-aws-{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let store = Arc::new(ProjectsStore::over_file_for_test(dir.join("projects.json"), Vec::new()));
let lookup = Arc::new(Lookup(AtomicUsize::new(0)));
let source = ExitSource::Aws(AwsWatch::every(lookup.clone(), Duration::from_secs(15)));
let (cancel_tx, cancel_rx) = watch::channel(false);
let watcher = tokio::spawn(run(store, source, cancel_rx, |_, _| panic!("nothing stopped")));
tokio::time::sleep(Duration::from_secs(46)).await;
assert_eq!(lookup.0.load(Ordering::SeqCst), 3);
let _ = cancel_tx.send(true);
tokio::time::timeout(Duration::from_secs(1), watcher)
.await
.expect("stops at once")
.unwrap();
std::fs::remove_dir_all(&dir).ok();
}
/// End to end against a real daemon: a Running project whose container is
/// killed from outside the app is flipped to Stopped and reported as a live
/// exit; one found already stopped on the first look is reported as such;
/// and one whose container is still up is left alone.
#[tokio::test]
#[ignore = "needs a Docker daemon; creates and removes throwaway containers"]
async fn a_container_killed_outside_the_app_flips_its_project_to_stopped() {
use std::process::Command;
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()
}
let tag = uuid::Uuid::new_v4().simple().to_string();
let doomed = docker_cli(&["run", "-d", "--name", &format!("tc-watch-a-{tag}"), "alpine:3", "sleep", "300"]);
let alive = docker_cli(&["run", "-d", "--name", &format!("tc-watch-b-{tag}"), "alpine:3", "sleep", "300"]);
let already = docker_cli(&["create", "--name", &format!("tc-watch-c-{tag}"), "alpine:3", "true"]);
let dir = std::env::temp_dir().join(format!("triple-c-watch-{tag}"));
std::fs::create_dir_all(&dir).unwrap();
let running = |name: &str, cid: &str| {
let mut p = Project::new(name.to_string(), Vec::new());
p.container_id = Some(cid.to_string());
p.status = ProjectStatus::Running;
p
};
let (a, b, c) = (running("a", &doomed), running("b", &alive), running("c", &already));
let (a_id, b_id, c_id) = (a.id.clone(), b.id.clone(), c.id.clone());
let store = Arc::new(ProjectsStore::over_file_for_test(dir.join("projects.json"), vec![a, b, c]));
let (cancel_tx, cancel_rx) = watch::channel(false);
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel::<(String, ExitReason)>();
let watcher = tokio::spawn(run(store.clone(), ExitSource::Docker, cancel_rx, move |p, why| {
let _ = seen_tx.send((p.id.clone(), why));
}));
let first = tokio::time::timeout(Duration::from_secs(10), seen_rx.recv()).await;
// Subscribed by now (the sweep precedes it, and `since` overlaps);
// kill from "outside".
docker_cli(&["kill", &doomed]);
let second = tokio::time::timeout(Duration::from_secs(15), seen_rx.recv()).await;
let t0 = Instant::now();
let _ = cancel_tx.send(true);
let _ = watcher.await;
let shutdown = t0.elapsed();
let statuses: Vec<ProjectStatus> =
[&a_id, &b_id, &c_id].iter().map(|id| store.get(id).unwrap().status).collect();
docker_cli(&["rm", "-f", &doomed, &alive, &already]);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(first.expect("sweep within 10s"), Some((c_id, ExitReason::FoundStopped)));
assert_eq!(second.expect("die within 15s"), Some((a_id, ExitReason::Exited)));
assert_eq!(statuses, vec![ProjectStatus::Stopped, ProjectStatus::Running, ProjectStatus::Stopped]);
assert!(shutdown < Duration::from_secs(1), "stopped watching in {shutdown:?}");
}
}
File diff suppressed because it is too large. Load diff
+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()
}
}
+297 -338
View File
@@ -9,6 +9,8 @@ use sha2::{Sha256, Digest};
use super::ca_certs;
use super::client::get_docker;
use super::workspace_env::{workspace_env, WorkspaceEnvInputs};
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
@@ -124,11 +126,21 @@ Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it
on every container start. To turn sandbox off, stop the container in
Triple-C, flip the "Sandbox mode" switch off, then start the container."#;
/// The Triple-C terminal opens a printed path on click (Shift/Option+click while
/// a program holds the mouse — `opensOnClick` in TerminalView) by looking for it
/// in the project, then `~` and `/tmp`. A relative name for a file elsewhere
/// (Claude Code's scratchpad under `/tmp/claude-<uid>/...` above all) cannot
/// be found, so ask for the whole path. Part of the instructions fingerprint,
/// so adding or changing it recreates each container once on its next start.
const FILE_LINK_INSTRUCTIONS: &str = r#"## Referring to files
The user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened."#;
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
/// instructions, appending port mapping docs, and appending scheduler docs.
/// Used by both create_container() and container_needs_recreation() to ensure
/// the same value is produced in both paths.
fn build_claude_instructions(
pub(super) fn build_claude_instructions(
global_instructions: Option<&str>,
project_instructions: Option<&str>,
port_mappings: &[PortMapping],
@@ -163,6 +175,11 @@ fn build_claude_instructions(
None => SCHEDULER_INSTRUCTIONS.to_string(),
});
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, FILE_LINK_INSTRUCTIONS),
None => FILE_LINK_INSTRUCTIONS.to_string(),
});
if sandbox_enabled {
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS),
@@ -228,7 +245,11 @@ pub(crate) const LABEL_SCRUB: &str = "triple-c.scrub";
/// cannot travel onto a snapshot. See the `LABEL` block in the Dockerfile.
pub(crate) const LABEL_BASE: &str = "triple-c.base";
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
/// `TRIPLEC_` is the AWS workspace's own namespace (`TRIPLEC_HOST`,
/// `TRIPLEC_FILES`, `TRIPLEC_AGENT_TOKEN`, … — see `container/entrypoint.sh`):
/// a custom var there could switch a local container into AWS mode or forge
/// the inputs the entrypoint trusts.
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_", "TRIPLEC_"];
/// Exact env var names Triple-C manages itself. Not covered by
/// [`RESERVED_ENV_PREFIXES`] because they don't share those prefixes.
@@ -340,7 +361,7 @@ fn model_alias_fingerprint_part(
}
/// Whether `key` is an env var name Triple-C reserves for itself.
fn is_reserved_env_key(key: &str) -> bool {
pub(super) fn is_reserved_env_key(key: &str) -> bool {
let upper = key.to_uppercase();
RESERVED_ENV_PREFIXES.iter().any(|p| upper.starts_with(p))
|| RESERVED_ENV_EXACT.iter().any(|e| upper == *e)
@@ -359,7 +380,7 @@ fn is_reserved_env_key(key: &str) -> bool {
///
/// Empty stays empty rather than becoming the hash of the empty string: an empty
/// label is how every other `triple-c.*` key says "nothing configured".
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
pub(super) fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
let mut parts: Vec<String> = Vec::new();
for env_var in custom_env_vars {
let key = env_var.key.trim();
@@ -385,7 +406,7 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
///
/// The returned token is never logged and never leaves this module except as
/// the env var value handed to Docker.
fn shared_claude_auth(project: &Project) -> Option<(String, String)> {
pub(super) fn shared_claude_auth(project: &Project) -> Option<(String, String)> {
if project.backend != Backend::Anthropic || !project.use_shared_auth_token {
return None;
}
@@ -416,7 +437,7 @@ fn claude_token_label(project: &Project) -> String {
/// Merge global and per-project custom environment variables.
/// Per-project variables override global variables with the same key.
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
pub(super) fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
let mut merged: std::collections::HashMap<String, EnvVar> = std::collections::HashMap::new();
for ev in global {
let key = ev.key.trim().to_string();
@@ -478,7 +499,7 @@ fn sha256_hex(input: &str) -> String {
/// Resolve a per-project string value with a global fallback. Returns `None`
/// when both are blank, otherwise the per-project value if set, else the global.
fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> {
pub(super) fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> {
let project_val = per_project.map(str::trim).filter(|s| !s.is_empty());
if project_val.is_some() {
return project_val;
@@ -643,7 +664,7 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
/// to the global set and never turn a globally-enabled setting off. `None` at
/// project level means "inherit", which is now a state the project can
/// actually be in rather than the only state an off switch could produce.
fn merge_claude_code_settings(
pub(super) fn merge_claude_code_settings(
global: Option<&ClaudeCodeSettings>,
project: Option<&ClaudeCodeSettings>,
) -> Option<ClaudeCodeSettings> {
@@ -743,7 +764,7 @@ fn compute_claude_code_settings_fingerprint(
///
/// The other two are documented as "set to `1` to …" with no meaning attached
/// to `0`, so `0` is unambiguously neutral and is stated outright.
fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
pub(super) fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
let owned;
let s = match settings {
Some(s) => s,
@@ -806,7 +827,7 @@ fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
///
/// Returns a `String` rather than an `Option<String>`: there is no longer any
/// input for which this produces nothing to say.
fn build_claude_code_settings_json(
pub(super) fn build_claude_code_settings_json(
settings: Option<&ClaudeCodeSettings>,
sandbox_enabled: bool,
) -> String {
@@ -1064,7 +1085,7 @@ type VpnHostConfigParts = (
/// `MISSION_CONTROL_ENABLED` twenty lines above shows the failure this avoids —
/// it is pushed only when true, so a snapshot's baked `=1` survives the toggle
/// going off.
fn vpn_env_var(enabled: bool) -> String {
pub(super) fn vpn_env_var(enabled: bool) -> String {
format!("VPN_SUPPORT_ENABLED={}", u8::from(enabled))
}
@@ -1250,212 +1271,24 @@ pub async fn create_container(
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
}
if let Some(ref token) = project.git_token {
env_vars.push(format!("GIT_TOKEN={}", token));
}
// Per-project git user overrides global defaults
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
if let Some(name) = effective_git_name {
env_vars.push(format!("GIT_USER_NAME={}", name));
}
if let Some(email) = effective_git_email {
env_vars.push(format!("GIT_USER_EMAIL={}", email));
}
// Bedrock configuration
if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config {
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
// AWS region: per-project overrides global
let region = if !bedrock.aws_region.is_empty() {
Some(bedrock.aws_region.clone())
} else {
global_aws.aws_region.clone()
};
if let Some(ref r) = region {
env_vars.push(format!("AWS_REGION={}", r));
}
match bedrock.auth_method {
BedrockAuthMethod::StaticCredentials => {
// Static/session credentials are NOT injected as env vars.
// They are written to ~/.aws/credentials by
// sync_bedrock_credentials() on every container
// start, so rotated/updated keys are picked up without a
// full container recreation (and never get baked into the
// snapshot image). The empty values set by the
// MANAGED_AUTH_KEYS neutralization pass below are ignored by
// the AWS SDK, which falls through to the credentials file.
}
BedrockAuthMethod::Profile => {
// Per-project profile overrides global
let profile = bedrock.aws_profile.as_ref()
.or(global_aws.aws_profile.as_ref());
if let Some(p) = profile {
env_vars.push(format!("AWS_PROFILE={}", p));
}
env_vars.push("AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh".to_string());
}
BedrockAuthMethod::BearerToken => {
if let Some(ref token) = bedrock.aws_bearer_token {
env_vars.push(format!("AWS_BEARER_TOKEN_BEDROCK={}", token));
}
}
}
if let Some(model) = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
if bedrock.disable_prompt_caching {
env_vars.push("DISABLE_PROMPT_CACHING=1".to_string());
}
if let Some(ref tier) = bedrock.service_tier {
let trimmed = tier.trim();
if !trimmed.is_empty() {
env_vars.push(format!("ANTHROPIC_BEDROCK_SERVICE_TIER={}", trimmed));
}
}
}
}
// ── Custom-endpoint backends ─────────────────────────────────────────────
// Ollama, llama.cpp and the OpenAI-Compatible gateway all point Claude Code
// at a non-Anthropic server via ANTHROPIC_BASE_URL. Each resolves its model
// id here; the model-alias vars are emitted once below, from
// `alias_model` / `alias_haiku`, so the three backends cannot drift apart.
let mut alias_model: Option<String> = None;
let mut alias_haiku: Option<String> = None;
// Ollama configuration
if project.backend == Backend::Ollama {
if let Some(ref ollama) = project.ollama_config {
if let Some(url) = resolve_with_global(
Some(&ollama.base_url),
global_ollama.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
if let Some(model) = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
ollama.haiku_model_id.as_deref(),
global_ollama.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// llama.cpp (llama-server) configuration
if project.backend == Backend::LlamaCpp {
if let Some(ref cfg) = project.llamacpp_config {
if let Some(url) = resolve_with_global(
Some(&cfg.base_url),
global_llamacpp.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
// llama-server only enforces an Authorization header when it was
// started with `--api-key` (default: none), so the value here is
// ignored in the common case. Claude Code still refuses to run
// against a custom base URL with no credential at all, so a
// placeholder is always sent — same trick as the Ollama branch
// above, which sends the literal "ollama".
env_vars.push("ANTHROPIC_AUTH_TOKEN=llama.cpp".to_string());
if let Some(model) = resolve_with_global(
cfg.model_id.as_deref(),
global_llamacpp.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
cfg.haiku_model_id.as_deref(),
global_llamacpp.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// OpenAI Compatible configuration
if project.backend == Backend::OpenAiCompatible {
if let Some(ref config) = project.openai_compatible_config {
if let Some(url) = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
if let Some(ref key) = config.api_key {
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
}
if let Some(model) = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
config.haiku_model_id.as_deref(),
global_openai_compatible.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// Model aliases — the fix for background Claude Code calls against a local
// server. Only for backends that talk to a custom endpoint: Anthropic and
// Bedrock reach servers that really do host the Anthropic model ids, so
// they keep Claude Code's own defaults. Anything not emitted here is
// blanked by the MANAGED_AUTH_KEYS pass below, so switching *away* from a
// custom endpoint clears the aliases out of the snapshot image too.
if project.backend.uses_custom_endpoint() {
for (key, value) in
compute_model_aliases(alias_model.as_deref(), alias_haiku.as_deref())
{
env_vars.push(format!("{}={}", key, value));
}
}
// Shared Claude Code OAuth token (Anthropic backend only, opt-out per
// project). Injected *before* the neutralization pass below so that pass
// sees it as already-set; when it is absent the pass actively blanks the
// variable instead of leaving a stale one baked into the snapshot image.
// project). Read here — it lives in the keychain — and handed to
// `workspace_env`, which injects it before the neutralization pass.
let shared_claude = shared_claude_auth(project);
if let Some((ref token, _)) = shared_claude {
env_vars.push(format!("{}={}", CLAUDE_OAUTH_TOKEN_ENV, token));
if shared_claude.is_some() {
log::info!(
"Injecting the shared Claude authentication token into the container for project {}",
project.id
);
}
// ── Corporate CA certificates ───────────────────────────────────────────
// Resolved here (rather than down with the mounts) so the env vars land
// *before* the neutralization pass below and are seen as already-set.
// Corporate CA certificates, resolved here (not in `workspace_env`) because
// the mount below needs the same resolution.
//
// A bad path is a hard error, not a warning: behind a TLS-terminating
// proxy a container without the CA fails every HTTPS call — npm, pip, git,
// and Claude Code's own API requests — each in its own confusing way. One
// message naming the path is far kinder.
//
// The values are set here rather than exported by the entrypoint because a
// terminal session is a `docker exec`, which sees the container's
// configured env and nothing the entrypoint exported. Same lesson as
// `$BROWSER` and the URL relay shim.
let effective_ca_path =
resolve_with_global(project.ca_cert_path.as_deref(), default_ca_cert_path);
let resolved_ca = ca_certs::resolve(effective_ca_path)?;
@@ -1467,110 +1300,40 @@ pub async fn create_container(
project.id
);
}
for (key, value) in ca_certs::ca_env_vars(resolved_ca.as_ref()) {
env_vars.push(format!("{}={}", key, value));
}
// ── Neutralize stale backend auth env vars ──────────────────────────────
// When a project switches backends (e.g. Bedrock → Anthropic) the container
// is recreated *from a snapshot image* committed off the previous container,
// and `docker commit` copies that container's full ENV into the image. So
// any auth var set under the old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1,
// AWS_PROFILE, a model alias) survives in the image ENV and stays active
// unless we explicitly override it at create time.
//
// This pass is about *staleness*, not secrecy. It fixes the container it is
// building and does nothing to the image, so it is not — and never was —
// a defence against a credential baked into a snapshot. That is
// `commit_container_snapshot`'s job, via SECRET_ENV_KEYS.
// Create-time env takes precedence over image ENV, so we set every managed
// auth key the *current* backend did NOT set to an empty value, clearing the
// stale baked-in one.
const MANAGED_AUTH_KEYS: &[&str] = &[
"CLAUDE_CODE_USE_BEDROCK",
"AWS_REGION",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_PROFILE",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_SSO_AUTH_REFRESH_CMD",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_MODEL",
"DISABLE_PROMPT_CACHING",
"ANTHROPIC_BEDROCK_SERVICE_TIER",
// Switching from a custom-endpoint backend to Anthropic or Bedrock must
// *clear* the aliases, not merely stop setting them: a stale
// ANTHROPIC_DEFAULT_HAIKU_MODEL baked into the snapshot image would
// keep pointing background calls at a model id the new backend has
// never heard of.
ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL,
ANTHROPIC_DEFAULT_FABLE_MODEL,
// Revoking the shared token, opting a project out, or switching away
// from the Anthropic backend must *clear* this, not merely stop setting
// it — otherwise the value committed into the snapshot image keeps
// authenticating the container with a credential the user removed.
CLAUDE_OAUTH_TOKEN_ENV,
];
// Same reasoning for the CA vars — `ca_env_vars` already emits them empty
// when no CA is configured, so this list is belt-and-braces for a snapshot
// committed by a build that predates the feature.
let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS
.iter()
.copied()
.chain(ca_certs::CA_ENV_KEYS.iter().copied())
.collect();
let already_set: std::collections::HashSet<String> = env_vars
.iter()
.filter_map(|e| e.split('=').next().map(|k| k.to_string()))
.collect();
for key in &managed_keys {
if !already_set.contains(*key) {
env_vars.push(format!("{}=", key));
}
}
// Everything project-shaped (backend auth, CA vars, custom env, instructions,
// Claude Code settings, ...) comes from the one host-neutral builder, so the
// AWS host can put the same pairs in its secret.
env_vars.extend(
workspace_env(
project,
&WorkspaceEnvInputs {
global_aws,
global_ollama,
global_llamacpp,
global_openai_compatible,
global_claude_instructions,
global_custom_env_vars,
timezone,
global_claude_code_settings,
default_git_user_name,
default_git_user_email,
resolved_ca: resolved_ca.as_ref(),
shared_claude_token: shared_claude.as_ref().map(|(token, _)| token.as_str()),
},
)
.into_iter()
.map(|(key, value)| format!("{}={}", key, value)),
);
// Custom environment variables (global + per-project, project overrides global for same key)
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
for env_var in &merged_env {
let key = env_var.key.trim();
if key.is_empty() {
continue;
}
if is_reserved_env_key(key) {
log::warn!("Skipping reserved env var: {}", key);
continue;
}
env_vars.push(format!("{}={}", key, env_var.value));
}
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
// Container timezone
if let Some(tz) = timezone {
if !tz.is_empty() {
env_vars.push(format!("TZ={}", tz));
}
}
// Mission Control env var
if project.mission_control_enabled {
env_vars.push("MISSION_CONTROL_ENABLED=1".to_string());
}
env_vars.push(vpn_env_var(project.vpn_support_enabled));
// Permission mode — read by triple-c-task-runner for scheduled (headless)
// Claude Code runs. Interactive terminals get the flags directly instead.
env_vars.push(format!(
"TRIPLE_C_PERMISSION_MODE={}",
project.effective_permission_mode().as_env_value()
// The labels below fingerprint the inputs that went into the env; derive
// them with the same helpers `workspace_env` used, so both always agree.
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
let custom_env_fingerprint = compute_env_fingerprint(&merge_custom_env_vars(
global_custom_env_vars,
&project.custom_env_vars,
));
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
let combined_instructions = build_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
@@ -1578,34 +1341,10 @@ pub async fn create_container(
project.mission_control_enabled,
project.sandbox_mode_enabled,
);
if let Some(ref instructions) = combined_instructions {
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
}
// Claude Code settings (global + per-project merged)
let merged_cc_settings = merge_claude_code_settings(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
// Env-var-based settings, read directly by Claude Code. Extracted and unit
// tested for the `vpn_host_config` reason: a container is created once, by
// a very long function, and a variable emitted with the wrong value here is
// invisible until someone wonders why a switch does nothing.
env_vars.extend(claude_code_env_vars(merged_cc_settings.as_ref()));
// settings.json-based settings (applied by the entrypoint). Always emitted,
// even with no `ClaudeCodeSettings` struct present: the payload asserts the
// *whole* managed key set, so "no settings" still has to be stated over a
// settings.json left behind on the config volume by a previous config.
// Split so the payload is safe under an older entrypoint that has never
// heard of the null-means-delete convention — see
// `split_claude_code_settings_payload`.
let (cc_settings_set, cc_settings_clear) = split_claude_code_settings_payload(
&build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled),
);
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", cc_settings_set));
env_vars.push(format!("CLAUDE_CODE_SETTINGS_CLEAR={}", cc_settings_clear));
let mut mounts: Vec<Mount> = Vec::new();
@@ -2000,7 +1739,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 {
@@ -2023,10 +1762,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
);
}
@@ -2078,7 +1817,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 {
@@ -2089,7 +1828,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(())
}
@@ -3052,6 +2791,118 @@ fn blanked_secret_env() -> Vec<String> {
.collect()
}
/// Image-name prefix for the throwaway commit a staleness probe of a stopped
/// container makes. The reaper's only handle on a leftover — see
/// [`crate::docker::migration::reap_probe_images`] — so nothing else may use it.
pub const PROBE_IMAGE_PREFIX: &str = "triple-c-probe-";
/// The throwaway image a staleness probe of a **stopped** container commits to.
///
/// **Unique per call**, and both halves of the name earn their place: the
/// container id prefix makes a leftover traceable in `docker images`, and the
/// counter makes two overlapping probes independent.
///
/// An earlier version of this was deliberately *stable* per container, on the
/// theory that the next probe would move the tag off an abandoned image and
/// leave it dangling for [`sweep_orphaned_snapshots`]. That was wrong twice
/// over. A container id does not survive a recreate, so for most leftovers
/// there is no "next probe of the same container" and the image was stranded
/// permanently; and a stable name made two concurrent probes fight over one
/// tag, where whichever finished first force-removed the image the other was
/// still reading and turned a healthy project into a bogus `probe_error`.
/// Uniqueness fixes both, and [`crate::docker::migration::reap_probe_images`]
/// is what collects the leftovers instead.
pub fn get_probe_image_name(container_id: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let short: String = container_id.chars().take(12).collect();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!(
"{}{}-{}-{}:latest",
PROBE_IMAGE_PREFIX,
short,
nanos,
SEQ.fetch_add(1, Ordering::Relaxed)
)
}
/// Commit a **stopped** container's filesystem to a throwaway image, returning
/// its name. The caller owns the image and must remove it.
///
/// This exists so a stopped project can be read at all. `docker exec` needs a
/// running container and the snapshot image is not a checkpoint — see
/// [`crate::commands::migration_commands`]'s probe-source pick — so without
/// this there is no way to see inside a project that is merely stopped.
///
/// ## Why it is tagged at all
///
/// An untagged commit would be tidier: untagged plus the `triple-c.managed=true`
/// that `docker commit` copies off the container is exactly the pair
/// [`sweep_orphaned_snapshots`] already collects, so a leftover would self-heal
/// with no new machinery. **It is not available.** `bollard`'s `Commit` response
/// model deserialises `"ID"` while the daemon sends `"Id"`, so
/// `commit_container` hands back `id: None` every time and there is no
/// reference left to probe. Neither existing commit site notices, because both
/// discard the response. Verified against Engine 29.6, bollard 0.18.1.
///
/// So the image needs a name, a tagged image is not dangling, and the sweep
/// therefore cannot be the safety net. [`crate::docker::migration::reap_probe_images`]
/// is, and [`get_probe_image_name`] carries the rest of that argument.
///
/// ## What is in the image, and what is not
///
/// `pause: false` because nothing is running — pausing a stopped container is
/// an error, the same reason [`recommit_without_secrets`]'s scratch commit
/// passes `false`.
///
/// Secrets are blanked from the env for the same reason
/// [`commit_container_snapshot`] blanks them: the commit bakes the container's
/// full ENV into the image, and "it only lives a few seconds" is not a property
/// this function can promise after a crash.
///
/// **The writable layer is committed unscrubbed, and that is unavoidable here.**
/// [`commit_container_snapshot`] runs [`scrub_writable_layer`] first precisely
/// because a commit stacks a layer and never rewrites one — but that scrub is a
/// `docker exec`, which is exactly what a stopped container cannot serve, and
/// scrubbing is not wanted anyway: the probe's whole job is to report the
/// filesystem as it actually is. What makes it acceptable is that this copies
/// bytes that are *already on this disk* in the container's own writable layer,
/// into an image that is never pushed, never created from, and reaped — so it
/// duplicates data inside one trust domain rather than widening it. That
/// argument depends on the reaping actually happening; treat
/// [`crate::docker::migration::reap_probe_images`] as load-bearing, not tidying.
pub async fn commit_container_for_probe(container_id: &str) -> Result<String, String> {
let docker = get_docker()?;
let image_name = get_probe_image_name(container_id);
let (repo, tag) = image_name
.rsplit_once(':')
.map(|(r, t)| (r.to_string(), t.to_string()))
.expect("get_probe_image_name always emits a tag");
docker
.commit_container(
CommitContainerOptions {
container: container_id.to_string(),
repo,
tag,
pause: false,
..Default::default()
},
Config::<String> {
env: Some(blanked_secret_env()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to commit stopped container {}: {}", container_id, e))?;
Ok(image_name)
}
/// Whether `env` (an image's `Config.Env`) holds a non-empty value for any
/// name in [`SECRET_ENV_KEYS`].
fn env_holds_a_secret(env: &[String]) -> bool {
@@ -3518,9 +3369,10 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
remove_image_by_name(&get_snapshot_image_name(project)).await
}
/// Remove a Docker image by name/tag, treating "does not exist" as success.
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
/// only has the image name (the project record is already gone by then).
/// Remove a Docker image by name, tag or **id**, treating "does not exist" as
/// success. Shared by [`remove_snapshot_image`], the pending-cleanup retry
/// (which only has the image name — the project record is already gone by
/// then), and the staleness probe's throwaway commit, which has only an id.
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
let docker = get_docker()?;
@@ -3536,7 +3388,7 @@ pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
.await
{
Ok(_) => {
log::info!("Removed snapshot image {}", image_name);
log::info!("Removed image {}", image_name);
Ok(())
}
Err(bollard::errors::Error::DockerResponseServerError {
@@ -4015,6 +3867,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::*;
@@ -4024,6 +3919,31 @@ mod tests {
const HAIKU: &str = ANTHROPIC_DEFAULT_HAIKU_MODEL;
const FABLE: &str = ANTHROPIC_DEFAULT_FABLE_MODEL;
/// The terminal can only open a file it can find: a bare name is looked
/// for in the project, home and /tmp, but a long scratchpad path printed
/// as `scratchpad/notes.md` is nowhere. Every container gets the ask to
/// print the full path, whatever else is configured.
#[test]
fn every_container_is_asked_to_print_full_paths_for_files_outside_the_project() {
for (global, project, sandbox) in [(None, None, false), (Some("g"), Some("p"), true)] {
let s = build_claude_instructions(global, project, &[], false, sandbox).unwrap();
assert!(s.contains(FILE_LINK_INSTRUCTIONS), "{:?}", (global, project, sandbox));
}
assert!(FILE_LINK_INSTRUCTIONS.contains("full absolute path"));
assert!(FILE_LINK_INSTRUCTIONS.contains("/tmp"));
}
/// The gesture is TerminalView's `opensOnClick`: a plain click, unless a
/// program holds the mouse — then xterm's force-selection modifier, which
/// is Shift except on macOS, where it is Option. Naming only Shift sent Mac
/// users to a key that does nothing.
#[test]
fn the_file_link_instructions_name_the_gesture_for_every_platform() {
assert!(FILE_LINK_INSTRUCTIONS.contains("can click a file path"));
assert!(FILE_LINK_INSTRUCTIONS.contains("Shift+click"));
assert!(FILE_LINK_INSTRUCTIONS.contains("Option+click on macOS"));
}
fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> {
compute_model_aliases(model, haiku)
}
@@ -4180,6 +4100,22 @@ mod tests {
assert_eq!(vpn_env_var(false), "VPN_SUPPORT_ENABLED=0");
}
#[test]
fn the_aws_workspace_namespace_is_reserved_from_custom_env() {
for key in ["TRIPLEC_HOST", "TRIPLEC_FILES", "TRIPLEC_AGENT_TOKEN", "triplec_repos", "TRIPLEC_ANYTHING"] {
assert!(is_reserved_env_key(key), "{key}");
}
assert_eq!(
compute_env_fingerprint(&[EnvVar {
key: "TRIPLEC_HOST".to_string(),
value: "aws".to_string(),
}]),
"",
"a reserved key never reaches the container"
);
assert!(!is_reserved_env_key("TRIPLECX"));
}
#[test]
fn the_vpn_skill_flag_is_reserved_from_custom_env() {
// entrypoint.sh installs and removes the pia-vpn skill from this
@@ -4464,6 +4400,29 @@ mod tests {
assert!(env_holds_a_secret(&env));
}
/// The probe image's name must be **unique per call**. A stable name was
/// tried and is wrong twice over: a container id does not survive a
/// recreate, so a crashed probe's leftover would never be reclaimed by "the
/// next probe of the same container"; and two concurrent probes sharing one
/// tag means whichever finishes first force-removes the image the other is
/// still reading. See `commit_container_for_probe` and `reap_probe_images`.
#[test]
fn probe_image_names_are_unique_per_call_and_reapable_by_prefix() {
let id = "75993e6d5e1ab473b029a408c5ff0339";
let a = get_probe_image_name(id);
let b = get_probe_image_name(id);
assert_ne!(a, b, "two probes of one container must not share a tag");
// The prefix is the reaper's only handle on a leftover, so every name
// has to carry it — and it must not be the snapshot namespace, which is
// what a project is rebuilt from.
assert!(a.starts_with(PROBE_IMAGE_PREFIX), "{}", a);
assert!(!a.starts_with("triple-c-snapshot-"), "{}", a);
// Traceable back to its container, which is the point of the prefix.
assert!(a.contains("75993e6d5e1a"), "{}", a);
assert!(a.ends_with(":latest"), "{}", a);
}
#[test]
fn the_scrub_report_only_claims_success_when_nothing_is_left() {
let clean = SnapshotScrubReport {
+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);
}
}
+242 -363
View File
@@ -1,58 +1,44 @@
use bollard::container::{LogOutput, UploadToContainerOptions};
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use futures_util::{Stream, StreamExt};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::{mpsc, Mutex};
use futures_util::StreamExt;
use super::client::get_docker;
use crate::host::{ExecChunk, ExecSpec};
/// A `docker exec` that has been created and started with stdin/stdout/stderr
/// attached — the raw duplex halves, before any policy about what to do with
/// them.
///
/// This is the single place in the codebase that knows how to open an attached
/// exec. Both consumers are built on it:
/// * [`ExecSessionManager`] — interactive terminals and the audio bridge,
/// which pump bytes through mpsc channels and a callback.
/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes
/// straight between a host TCP socket and these halves.
///
/// With `tty = false` the output stream is demultiplexed by Docker, so the
/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That
/// distinction matters for the auth bridge: `socat`'s diagnostics must not be
/// spliced into the proxied byte stream.
pub struct AttachedExec {
pub exec_id: String,
pub output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
pub input: Pin<Box<dyn AsyncWrite + Send>>,
}
// `AttachedExec` lives in `host` now (host-neutral output chunks); this
// module's job is to produce one from Docker. Its consumers are
// `host::sessions::ExecSessionManager` (terminals, audio bridge) and the auth
// bridge's per-connection `socat` tunnels, both through `host::ContainerTarget`.
// With `tty = false` Docker demultiplexes stdout from stderr, which the auth
// bridge relies on: `socat`'s diagnostics must not be spliced into the proxied
// byte stream.
pub use crate::host::AttachedExec;
/// Create and start an exec with stdin + stdout + stderr attached, returning the
/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec
/// this app opens.
pub async fn create_attached_exec(
container_id: &str,
cmd: Vec<String>,
tty: bool,
) -> Result<AttachedExec, String> {
create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await
}
/// [`create_attached_exec`] with the user and working directory spelled out.
/// An attached exec with the user and working directory spelled out.
///
/// Only base-image migration needs this: replaying `apt` and unpacking a
/// payload tar at `/` have to run as **root**, and every other caller wants the
/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It
/// stays the single place an attached exec is opened.
/// Only base-image migration calls this directly: replaying `apt` and unpacking
/// a payload tar at `/` have to run as **root**. Everything else goes through
/// `host::ContainerTarget::attach`.
pub async fn create_attached_exec_as(
container_id: &str,
cmd: Vec<String>,
tty: bool,
user: &str,
working_dir: &str,
) -> Result<AttachedExec, String> {
let spec = ExecSpec::new(cmd)
.tty(tty)
.user(user)
.working_dir(Some(working_dir));
create_attached_exec_spec(container_id, &spec).await
}
/// The single place an attached exec is opened on Docker. Everything else —
/// [`create_attached_exec_as`] and `host::ContainerTarget::attach` — is a thin
/// wrapper over this.
pub async fn create_attached_exec_spec(
container_id: &str,
spec: &ExecSpec,
) -> Result<AttachedExec, String> {
let docker = get_docker()?;
@@ -60,13 +46,14 @@ pub async fn create_attached_exec_as(
.create_exec(
container_id,
CreateExecOptions {
attach_stdin: Some(true),
attach_stdin: Some(spec.attach_stdin),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(tty),
cmd: Some(cmd),
user: Some(user.to_string()),
working_dir: Some(working_dir.to_string()),
tty: Some(spec.tty),
cmd: Some(spec.cmd.clone()),
env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
user: Some(spec.user.clone()),
working_dir: spec.working_dir.clone(),
..Default::default()
},
)
@@ -82,244 +69,87 @@ pub async fn create_attached_exec_as(
{
StartExecResults::Attached { output, input } => Ok(AttachedExec {
exec_id,
output,
output: Box::pin(output.map(|r| {
r.map(log_output_to_chunk)
.map_err(|e| e.to_string())
})),
input,
}),
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
}
}
pub struct ExecSession {
pub exec_id: String,
pub container_id: String,
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
shutdown_tx: mpsc::Sender<()>,
}
impl ExecSession {
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
self.input_tx
.send(data)
.map_err(|e| format!("Failed to send input: {}", e))
}
#[allow(dead_code)]
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
let docker = get_docker()?;
docker
.resize_exec(
&self.exec_id,
ResizeExecOptions {
width: cols,
height: rows,
},
)
.await
.map_err(|e| format!("Failed to resize exec: {}", e))
}
pub fn shutdown(&self) {
let _ = self.shutdown_tx.try_send(());
/// bollard's `LogOutput` → the host-neutral [`ExecChunk`]. `Console` is what
/// Docker calls a TTY stream; it has no stderr, so it is stdout.
pub fn log_output_to_chunk(o: LogOutput) -> ExecChunk {
match o {
LogOutput::StdOut { message } | LogOutput::Console { message } => ExecChunk::StdOut(message),
LogOutput::StdErr { message } => ExecChunk::StdErr(message),
LogOutput::StdIn { message } => ExecChunk::Other(message),
}
}
pub struct ExecSessionManager {
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
/// Start a command with nothing attached and return its exec id without
/// waiting for it.
pub async fn exec_detached(container_id: &str, spec: &ExecSpec) -> Result<String, String> {
let docker = get_docker()?;
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdout: Some(false),
attach_stderr: Some(false),
tty: Some(spec.tty),
cmd: Some(spec.cmd.clone()),
env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
user: Some(spec.user.clone()),
working_dir: spec.working_dir.clone(),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create exec: {}", e))?;
docker
.start_exec(
&exec.id,
Some(bollard::exec::StartExecOptions {
detach: true,
..Default::default()
}),
)
.await
.map_err(|e| format!("Failed to start exec: {}", e))?;
Ok(exec.id)
}
impl ExecSessionManager {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn create_session<F>(
&self,
container_id: &str,
session_id: &str,
cmd: Vec<String>,
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
.await
}
pub async fn create_session_with_tty<F>(
&self,
container_id: &str,
session_id: &str,
cmd: Vec<String>,
tty: bool,
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
let AttachedExec {
pub async fn resize_exec(exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
let docker = get_docker()?;
docker
.resize_exec(
exec_id,
mut output,
mut input,
} = create_attached_exec(container_id, cmd, tty).await?;
ResizeExecOptions {
width: cols,
height: rows,
},
)
.await
.map_err(|e| format!("Failed to resize exec: {}", e))
}
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
// Output reader task
let session_id_clone = session_id.to_string();
let shutdown_tx_clone = shutdown_tx.clone();
tokio::spawn(async move {
loop {
tokio::select! {
msg = output.next() => {
match msg {
Some(Ok(output)) => {
on_output(output.into_bytes().to_vec());
}
Some(Err(e)) => {
log::error!("Exec output error for {}: {}", session_id_clone, e);
break;
}
None => {
log::info!("Exec output stream ended for {}", session_id_clone);
break;
}
}
}
_ = shutdown_rx.recv() => {
log::info!("Exec session {} shutting down", session_id_clone);
break;
}
}
}
on_exit();
let _ = shutdown_tx_clone;
});
// Input writer task
tokio::spawn(async move {
while let Some(data) = input_rx.recv().await {
if let Err(e) = input.write_all(&data).await {
log::error!("Failed to write to exec stdin: {}", e);
break;
}
}
});
let session = ExecSession {
exec_id,
container_id: container_id.to_string(),
input_tx,
shutdown_tx,
};
self.sessions
.lock()
.await
.insert(session_id.to_string(), session);
Ok(())
}
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.send_input(data).await
}
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
// Clone the exec_id under the lock, then drop the lock before the
// async Docker API call to avoid holding the mutex across await.
let exec_id = {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
session.exec_id.clone()
};
let docker = get_docker()?;
docker
.resize_exec(
&exec_id,
ResizeExecOptions {
width: cols,
height: rows,
},
)
.await
.map_err(|e| format!("Failed to resize exec: {}", e))
}
pub async fn close_session(&self, session_id: &str) {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.remove(session_id) {
session.shutdown();
}
}
pub async fn close_sessions_for_container(&self, container_id: &str) {
let mut sessions = self.sessions.lock().await;
let ids_to_close: Vec<String> = sessions
.iter()
.filter(|(_, s)| s.container_id == container_id)
.map(|(id, _)| id.clone())
.collect();
for id in ids_to_close {
if let Some(session) = sessions.remove(&id) {
session.shutdown();
}
}
}
pub async fn close_all_sessions(&self) {
let mut sessions = self.sessions.lock().await;
for (_, session) in sessions.drain() {
session.shutdown();
}
}
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
Ok(session.container_id.clone())
}
pub async fn write_file_to_container(
&self,
container_id: &str,
file_name: &str,
data: &[u8],
) -> Result<String, String> {
let docker = get_docker()?;
// Owned by the container user, stamped now: a default tar header would
// land it as root:root/1970 and Claude Code could not rewrite it.
let (uid, gid) = container_user_ids(container_id).await;
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
docker
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: "/tmp".to_string(),
..Default::default()
}),
tar_buf.into(),
)
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(format!("/tmp/{}", file_name))
}
/// Docker's archive API: `path` as a tar stream. Errors are stringified so the
/// stream is host-neutral.
pub fn download_archive(container_id: &str, path: &str) -> crate::host::ArchiveStream {
let docker = match get_docker() {
Ok(d) => d,
Err(e) => return Box::pin(futures_util::stream::once(async move { Err(e) })),
};
let stream = docker.download_from_container(
container_id,
Some(bollard::container::DownloadFromContainerOptions {
path: path.to_string(),
}),
);
Box::pin(stream.map(|r| r.map_err(|e| e.to_string())))
}
/// Ceiling on one host file packed into a container upload.
@@ -376,31 +206,8 @@ pub async fn upload_host_file_with_ids(
let mtime = now_epoch_secs();
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
// The caller resolved this path (`resolve_host_read_path`); opening it
// is a second trip through the same directories, so the descriptor is
// checked against the path that was validated before its bytes are
// packed into anything. Two paths reach here: the terminal's drop
// target, and the Files pane's upload via `upload_host_file_with_ids`.
// Between them they are how host bytes enter a container.
let file = std::fs::File::open(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
crate::commands::file_commands::verify_opened_path(
&file,
std::path::Path::new(&host_path),
)?;
let mut data = Vec::new();
std::io::Read::read_to_end(
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
&mut data,
)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
if data.len() as u64 > MAX_DROP_BYTES {
return Err(format!(
"File too large to upload (limit {} MB)",
MAX_DROP_BYTES / (1024 * 1024)
));
}
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
let data = read_host_file_for_upload(&host_path)?;
build_single_file_tar(&dest_for_blk, &data[..], HOST_FILE_UPLOAD_MODE, uid, gid, mtime)
})
.await
.map_err(|e| format!("Upload task panicked: {}", e))??;
@@ -421,20 +228,54 @@ 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('/'))
}
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
///
/// For small, generated files — migration uses it for the `tar -T` include
/// list, which can be too long to pass as argv. Anything large should be
/// list, which can be too long to pass as argv, and the marketplace sync for
/// its payload tar and script. Anything large should be
/// streamed through an attached exec's stdin instead, since this buffers the
/// whole payload in memory twice (once raw, once tarred).
pub async fn upload_bytes_to_container(
@@ -443,13 +284,22 @@ pub async fn upload_bytes_to_container(
file_name: &str,
data: &[u8],
mode: u32,
) -> Result<String, String> {
upload_bytes_with_ids(container_id, dest_dir, file_name, data, mode, (0, 0)).await
}
/// [`upload_bytes_to_container`] with the tar header's owner set, stamped now.
pub async fn upload_bytes_with_ids(
container_id: &str,
dest_dir: &str,
file_name: &str,
data: &[u8],
mode: u32,
(uid, gid): (u64, u64),
) -> Result<String, String> {
let docker = get_docker()?;
// Root-owned on purpose: the only caller is migration, whose `tar -T` list
// is read back as root. The mtime still gets stamped so the file doesn't
// read as 1970.
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
let tar_buf = build_single_file_tar(file_name, data, mode, uid, gid, now_epoch_secs())?;
docker
.upload_to_container(
@@ -519,14 +369,29 @@ pub fn now_epoch_secs() -> u64 {
/// container. Falls back to 1000:1000 (the image's build-time ids) if the exec
/// fails, which is strictly better than the 0:0 a default tar header carries.
pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
let out = exec_oneshot_limited(
let (out, _) = exec_oneshot_limited(
container_id,
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
256,
"claude",
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),
@@ -541,9 +406,12 @@ pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
/// caller sees a byte, and what it is reading is *container-controlled* — the
/// scheduler notifications reader `cat`s up to 50 files with no size cap, and
/// the auth bridge reads `/proc/net/tcp` every two seconds. Neither has an
/// upstream bound, so this is where the bound goes. Generous enough that no
/// legitimate reader (the largest is a package manifest of a full image) comes
/// close.
/// upstream bound, so this is where the bound goes. It is the default, not a
/// promise that every reader fits: the migration manifest of a running
/// container did pass it (8.6 MB from a busy `/workspace`), and now runs under
/// its own `migration::MANIFEST_OUTPUT_LIMIT` through [`exec_oneshot_limited`].
/// A reader that can legitimately grow with the user's files should do the
/// same rather than raise this.
pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
/// The auth bridge's per-tick budget. It reads two procfs files whose rows are
@@ -584,16 +452,20 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
exec_oneshot_env(container_id, cmd, Vec::new()).await
}
/// [`exec_oneshot`] with a caller-chosen output ceiling, for readers whose
/// input is fully container-controlled and whose legitimate output is small.
/// A one-shot exec as `user` with a caller-chosen output ceiling, returning the
/// combined output and the exit code.
///
/// For readers whose budget is not [`MAX_ONESHOT_OUTPUT`]: smaller where the
/// input is container-controlled and the legitimate output tiny (the auth
/// bridge's procfs poll, the uid probe), larger where it can genuinely pass
/// 8 MiB (the migration manifest, as root).
pub async fn exec_oneshot_limited(
container_id: &str,
user: &str,
cmd: Vec<String>,
limit: usize,
) -> Result<String, String> {
exec_oneshot_inner(container_id, "claude", cmd, Vec::new(), limit)
.await
.map(|(output, _)| output)
) -> Result<(String, i64), String> {
exec_oneshot_inner(container_id, user, cmd, Vec::new(), limit).await
}
/// Like `exec_oneshot`, but passes additional environment variables to the exec
@@ -647,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]);
@@ -670,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 {
@@ -739,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 {
@@ -748,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)),
}
@@ -775,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.
@@ -814,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) {
@@ -833,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]
@@ -915,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));
}
+441 -78
View File
@@ -21,7 +21,7 @@
//! `docker inspect`.
use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
Config, CreateContainerOptions, ListContainersOptions,
StartContainerOptions, StopContainerOptions, UploadToContainerOptions,
};
use bollard::image::BuildImageOptions;
@@ -45,11 +45,180 @@ const GATEWAY_CONFIG_VOLUME: &str = "triple-c-gateway-config";
/// Upstream LiteLLM, pinned to an exact release.
///
/// LiteLLM 1.82.7 and 1.82.8 shipped credential-harvesting malware on PyPI, so
/// nothing here may float a tag or resolve `litellm` at build time. v1.96.0 is
/// also above the 1.84.0 floor set by the proxy auth-bypass CVEs — see the long
/// comment in `gateway-container/Dockerfile`, and keep the two in lockstep.
const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.96.0";
const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest";
/// nothing here may float a tag or resolve `litellm` at build time. v1.106.6 is
/// also above the floors set by the proxy auth-bypass, SSRF and salt-key CVEs —
/// see the long comment in `gateway-container/Dockerfile`, and keep the two in
/// lockstep.
const GATEWAY_REGISTRY_REPO: &str = "ghcr.io/berriai/litellm";
const GATEWAY_LITELLM_VERSION: &str = "v1.106.6";
/// The digest [`GATEWAY_REGISTRY_REPO`]:[`GATEWAY_LITELLM_VERSION`] is pinned to — the same one the
/// Dockerfile's `FROM` uses. Pulls and lookups go by digest: a tag can be
/// re-pushed upstream, a digest cannot.
const GATEWAY_REGISTRY_DIGEST: &str =
"sha256:37e2dafb088a7723beb998590682957386dfc83ffea1848486f8e0e5cfac0b06";
fn registry_image_ref() -> String {
format!("{}@{}", GATEWAY_REGISTRY_REPO, GATEWAY_REGISTRY_DIGEST)
}
/// Local builds are tagged with the pin plus a hash of the embedded build
/// context (`triple-c-gateway:v1.106.6-1a2b3c4d`), never a floating tag. A
/// build made by an app version with a different pin *or* a different
/// Dockerfile/config then simply doesn't match, instead of quietly winning
/// over the current image the way the old `triple-c-gateway:latest` tag did.
/// Builds tagged `latest` by earlier app versions are no longer used.
const GATEWAY_LOCAL_REPO: &str = "triple-c-gateway";
/// The LiteLLM release this app is pinned to — the tag of
/// [`GATEWAY_REGISTRY_REPO`]:[`GATEWAY_LITELLM_VERSION`], e.g. `v1.106.6`.
fn pinned_litellm_version() -> &'static str {
GATEWAY_LITELLM_VERSION
}
fn local_image_ref() -> String {
static REF: OnceLock<String> = OnceLock::new();
REF.get_or_init(|| {
// config.yaml is hashed whole: a `#` line can sit inside a block
// scalar there, and it is rarely edited.
let context = sha256_hex(&format!(
"{}\0{}",
significant_lines(GATEWAY_DOCKERFILE),
GATEWAY_DEFAULT_CONFIG
));
format!(
"{}:{}-{}",
GATEWAY_LOCAL_REPO,
pinned_litellm_version(),
&context[..8]
)
})
.clone()
}
/// The Dockerfile minus full-line comments and blank lines, so rewording a
/// comment in a later release doesn't make every local build look outdated.
/// Parser directives (`# syntax=`, `# escape=`, `# check=`) look like comments
/// but change the build, so they are kept.
fn significant_lines(text: &str) -> String {
text.lines()
.map(str::trim_end)
.filter(|l| {
let t = l.trim_start();
if t.is_empty() {
return false;
}
match t.strip_prefix('#') {
None => true,
Some(rest) => rest
.split_once('=')
.map(|(key, _)| key.trim())
.is_some_and(|key| matches!(key, "syntax" | "escape" | "check")),
}
})
.collect::<Vec<_>>()
.join("\n")
}
/// The image the gateway container should run, and its content id. The id goes
/// into the container fingerprint so that a newly pulled or rebuilt image
/// recreates the container instead of leaving the old one running.
#[derive(Debug, Clone, PartialEq)]
struct ChosenImage {
reference: String,
id: String,
}
/// Local build first (same precedence as the STT container), then the pinned
/// upstream image. Both references name the pin, so either is current.
fn choose_gateway_image(local_id: Option<String>, registry_id: Option<String>) -> Option<ChosenImage> {
local_id
.map(|id| ChosenImage {
reference: local_image_ref(),
id,
})
.or_else(|| {
registry_id.map(|id| ChosenImage {
reference: registry_image_ref(),
id,
})
})
}
/// The existing gateway container, as far as `ensure` and status need it.
#[derive(Debug, Clone)]
struct GatewayContainer {
id: String,
state: String,
/// The config-fingerprint label it was created with.
fingerprint: String,
image_id: Option<String>,
}
/// Ids of the locally present images that carry the current pin.
#[derive(Debug, Clone, Default, PartialEq)]
struct PinnedImages {
local: Option<String>,
registry: Option<String>,
}
impl PinnedImages {
fn chosen(&self) -> Option<ChosenImage> {
choose_gateway_image(self.local.clone(), self.registry.clone())
}
}
/// A failed lookup of one image doesn't hide the other. An error is returned
/// only when it leaves the answer unknown — so "Docker is unreachable" is not
/// mistaken for "image missing".
async fn pinned_images() -> Result<PinnedImages, String> {
let (local_ref, registry_ref) = (local_image_ref(), registry_image_ref());
let (local, registry) = tokio::join!(
super::migration::image_id(&local_ref),
super::migration::image_id(&registry_ref)
);
match (local, registry) {
(Ok(local), Ok(registry)) => Ok(PinnedImages { local, registry }),
// The local build is preferred, so it alone decides when present.
(Ok(Some(local)), Err(e)) => {
log::warn!("{}", e);
Ok(PinnedImages {
local: Some(local),
registry: None,
})
}
// But a failed lookup of the preferred local build leaves the answer
// unknown even when the registry image is there: choosing the
// registry image would swap a working local-build gateway over.
(Err(e), _) | (_, Err(e)) => Err(e),
}
}
/// Whether the container should be moved onto `pinned` — the image half of
/// [`needs_recreate`]. A container whose image id is unknown counts as not on
/// it, in both places.
fn image_switch_pending(existing_image_id: Option<&str>, pinned: &ChosenImage) -> bool {
existing_image_id != Some(pinned.id.as_str())
}
/// Status's `image_outdated`: the container exists and isn't on a pinned
/// image. With a pinned image present this is exactly [`image_switch_pending`]
/// — what the next start or settings save acts on. With none (`Some(None)`) it
/// is outdated, but `ensure` keeps it as is until one is pulled or built.
/// `None` means the image check failed: unknown is reported as not outdated
/// rather than guessed.
fn container_image_outdated(
container_exists: bool,
container_image_id: Option<&str>,
chosen: Option<Option<&ChosenImage>>,
) -> bool {
match chosen {
_ if !container_exists => false,
Some(Some(pinned)) => image_switch_pending(container_image_id, pinned),
Some(None) => true,
None => false,
}
}
const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile");
const GATEWAY_DEFAULT_CONFIG: &str = include_str!("../../../../gateway-container/config.yaml");
@@ -200,6 +369,27 @@ async fn bridge_gateway_ip(docker: &Docker) -> Option<String> {
.find_map(|c| c.gateway.filter(|g| !g.trim().is_empty()))
}
/// Config and secret version only — the same format earlier versions wrote,
/// so updating the app doesn't by itself recreate every gateway. Image changes
/// are handled separately by [`needs_recreate`].
fn gateway_fingerprint(config_shape: &str, secret_version: &str) -> String {
sha256_hex(&format!("{}|{}", config_shape, secret_version))
}
/// The one rule for replacing an existing container: its config changed, or a
/// pinned image is present and the container runs something else. With no
/// pinned image (`pinned: None`) the container keeps its current image.
/// [`container_image_outdated`] reports the image half of this to the UI.
fn needs_recreate(
existing_fingerprint: &str,
fingerprint: &str,
existing_image_id: Option<&str>,
pinned: Option<&ChosenImage>,
) -> bool {
existing_fingerprint != fingerprint
|| pinned.is_some_and(|p| image_switch_pending(existing_image_id, p))
}
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
@@ -207,23 +397,32 @@ fn sha256_hex(input: &str) -> String {
}
pub async fn get_gateway_status(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
let image_exists = super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
.await
.unwrap_or(false)
|| super::image::image_exists(GATEWAY_LOCAL_IMAGE)
.await
.unwrap_or(false);
let (container_exists, running) = match find_gateway_container().await? {
Some((_, state, _)) => (true, state == "running"),
let (pinned, container) = tokio::join!(pinned_images(), find_gateway_container());
let chosen = match pinned {
Ok(p) => Some(p.chosen()),
Err(e) => {
log::warn!("Gateway image check failed: {}", e);
None
}
};
let image_exists = matches!(chosen, Some(Some(_)));
let container = container?;
let (container_exists, running) = match &container {
Some(c) => (true, c.state == "running"),
None => (false, false),
};
let image_outdated = container_image_outdated(
container_exists,
container.as_ref().and_then(|c| c.image_id.as_deref()),
chosen.as_ref().map(|c| c.as_ref()),
);
Ok(GatewayStatus {
container_exists,
running,
port: settings.port,
image_exists,
image_outdated,
model_count: settings.valid_models().len(),
has_api_key: secure::has_gateway_api_key(),
base_url: gateway_binding().await.base_url(settings.port),
@@ -234,7 +433,7 @@ pub async fn get_gateway_status(settings: &GatewaySettings) -> Result<GatewaySta
/// settings reconcile, which must not start anything the user never started.
pub async fn gateway_container_presence() -> Result<(bool, bool), String> {
Ok(match find_gateway_container().await? {
Some((_, state, _)) => (true, state == "running"),
Some(c) => (true, c.state == "running"),
None => (false, false),
})
}
@@ -251,8 +450,8 @@ fn is_gateway_container(names: Option<&Vec<String>>) -> bool {
names.is_some_and(|names| names.iter().any(|n| n == &expected))
}
/// `(id, state, config fingerprint label)` for the gateway container, if any.
async fn find_gateway_container() -> Result<Option<(String, String, String)>, String> {
/// The gateway container, if any.
async fn find_gateway_container() -> Result<Option<GatewayContainer>, String> {
let docker = get_docker()?;
let filters: HashMap<String, Vec<String>> = HashMap::from([(
@@ -283,7 +482,14 @@ async fn find_gateway_container() -> Result<Option<(String, String, String)>, St
.cloned()
.unwrap_or_default();
return Ok(Some((id, state, fingerprint)));
let image_id = container.image_id.clone().filter(|s| !s.is_empty());
return Ok(Some(GatewayContainer {
id,
state,
fingerprint,
image_id,
}));
}
Ok(None)
@@ -428,28 +634,11 @@ async fn upload_config(container_id: &str, config: &str) -> Result<(), String> {
async fn create_gateway_container(
settings: &GatewaySettings,
binding: &GatewayBinding,
image: &ChosenImage,
fingerprint: &str,
) -> Result<String, String> {
let docker = get_docker()?;
// Local build first, then the pinned upstream image — same precedence as
// the STT container.
let image = if super::image::image_exists(GATEWAY_LOCAL_IMAGE)
.await
.unwrap_or(false)
{
GATEWAY_LOCAL_IMAGE.to_string()
} else if super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
.await
.unwrap_or(false)
{
GATEWAY_REGISTRY_IMAGE.to_string()
} else {
return Err(
"Gateway image not found. Please pull or build the image first.".to_string(),
);
};
let mut port_bindings = HashMap::new();
port_bindings.insert(
format!("{}/tcp", GATEWAY_INTERNAL_PORT),
@@ -490,7 +679,9 @@ async fn create_gateway_container(
);
let config = Config {
image: Some(image),
// The resolved id, not the tag: a build or pull retagging the image
// mid-create can't make the container disagree with its fingerprint.
image: Some(image.id.clone()),
// The upstream entrypoint (`docker/prod_entrypoint.sh`) execs
// `litellm "$@"`. Passed explicitly so the pulled upstream image and
// our locally built one behave identically.
@@ -521,6 +712,20 @@ async fn create_gateway_container(
Ok(response.id)
}
/// Stops a running container gracefully first, with Docker's default grace as
/// before (so in-flight LiteLLM requests can drain; the shorter
/// [`GATEWAY_STOP_GRACE_SECS`] is for app exit), then removes it, tolerating
/// one already gone.
async fn remove_gateway_container(docker: &Docker, id: &str, state: &str) -> Result<(), String> {
if state == "running" {
docker
.stop_container(id, None::<StopContainerOptions>)
.await
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
}
super::container::remove_container(id).await
}
/// Serialises every mutation of the single fixed-name gateway container.
///
/// `ensure_gateway_running` is check-then-act over one container name, so two
@@ -564,14 +769,66 @@ async fn ensure_gateway_running_locked(
// Rotation id, not a hash of either secret — see `storage::secure`.
let secret_version = secure::get_gateway_secret_version()?.unwrap_or_default();
let fingerprint = sha256_hex(&format!(
"{}|{}",
config_shape(settings, &binding),
secret_version
));
let (existing, pinned) = tokio::join!(find_gateway_container(), pinned_images());
let existing = existing?;
// A failed image lookup must not fail a gateway that's fine as it is: it
// takes the same path as "no pinned image" and keeps the current image.
let mut lookup_error = None;
let chosen = match pinned {
Ok(pinned) => pinned.chosen(),
Err(e) => {
log::warn!("Gateway image check failed: {}", e);
lookup_error = Some(e);
None
}
};
let on_pin = chosen.is_some();
let image = match chosen {
Some(image) => image,
// No pinned image yet (after an app update moved the pin, until the
// user pulls or builds). Never tear down a working gateway over that:
// keep it on the image it already runs — recreated with the new
// config if that changed — and let status report it as outdated.
None => match existing.as_ref().and_then(|c| c.image_id.clone()) {
Some(id) => {
log::warn!(
"Gateway image for LiteLLM {} not present; keeping the gateway on its current image {}",
pinned_litellm_version(),
id
);
ChosenImage {
reference: id.clone(),
id,
}
}
None => {
return Err(match lookup_error {
Some(e) => e,
None => format!(
"Gateway image for LiteLLM {} not found. Please pull or build the image first.",
pinned_litellm_version()
),
})
}
},
};
if let Some((id, state, existing_fingerprint)) = find_gateway_container().await? {
if existing_fingerprint == fingerprint {
let fingerprint = gateway_fingerprint(&config_shape(settings, &binding), &secret_version);
if let Some(GatewayContainer {
id,
state,
fingerprint: existing_fingerprint,
image_id: existing_image_id,
}) = existing
{
let pinned = on_pin.then_some(&image);
if !needs_recreate(
&existing_fingerprint,
&fingerprint,
existing_image_id.as_deref(),
pinned,
) {
if state == "running" {
return get_gateway_status(settings).await;
}
@@ -582,41 +839,21 @@ async fn ensure_gateway_running_locked(
return get_gateway_status(settings).await;
}
// Config or a secret changed — recreate so the new config is uploaded.
if state == "running" {
docker
.stop_container(&id, None::<StopContainerOptions>)
.await
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
}
docker
.remove_container(
&id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.map_err(|e| format!("Failed to remove gateway container: {}", e))?;
// Config, a secret or the image changed — recreate so the new config
// is uploaded and the pinned image is used.
remove_gateway_container(&docker, &id, &state).await?;
}
let id = create_gateway_container(settings, &binding, &fingerprint).await?;
let id = create_gateway_container(settings, &binding, &image, &fingerprint).await?;
// Upload before the first start: LiteLLM reads the config once at boot.
let rendered = render_config(settings, &api_key, &master_key);
if let Err(e) = upload_config(&id, &rendered).await {
// Don't leave a half-configured container behind for the next run to
// mistake for a good one.
let _ = docker
.remove_container(
&id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await;
if let Err(cleanup) = super::container::remove_container(&id).await {
log::warn!("{}", cleanup);
}
return Err(e);
}
@@ -626,12 +863,12 @@ async fn ensure_gateway_running_locked(
.map_err(|e| format!("Failed to start gateway container: {}", e))?;
log::info!(
"Model gateway started on {}:{} ({} model(s))",
"Model gateway started on {}:{} ({} model(s), {})",
binding.host_ip,
settings.port,
settings.valid_models().len()
settings.valid_models().len(),
image.reference
);
get_gateway_status(settings).await
}
@@ -645,7 +882,7 @@ pub async fn stop_gateway_container() -> Result<(), String> {
let _guard = gateway_lock().lock().await;
let docker = get_docker()?;
if let Some((id, state, _)) = find_gateway_container().await? {
if let Some(GatewayContainer { id, state, .. }) = find_gateway_container().await? {
if state == "running" {
docker
.stop_container(
@@ -689,7 +926,27 @@ pub async fn pull_gateway_image<F>(on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,
{
super::image::pull_image(GATEWAY_REGISTRY_IMAGE, on_progress).await
super::image::pull_image(&registry_image_ref(), on_progress).await?;
// A pull by digest leaves the image untagged, which `docker image prune`
// treats as dangling and deletes. Tag it locally; lookups still go by
// digest, so a re-pushed upstream tag changes nothing.
// Not fatal: the image is present and will be used either way.
if let Err(e) = get_docker()?
.tag_image(
&registry_image_ref(),
Some(bollard::image::TagImageOptions {
repo: GATEWAY_REGISTRY_REPO,
tag: GATEWAY_LITELLM_VERSION,
}),
)
.await
{
log::warn!(
"Pulled the gateway image but could not tag it (a prune may remove it): {}",
e
);
}
Ok(())
}
pub async fn build_gateway_image<F>(on_progress: F) -> Result<(), String>
@@ -701,8 +958,9 @@ where
let tar_bytes = create_gateway_build_context()
.map_err(|e| format!("Failed to create gateway build context: {}", e))?;
let tag = local_image_ref();
let options = BuildImageOptions {
t: GATEWAY_LOCAL_IMAGE,
t: tag.as_str(),
rm: true,
forcerm: true,
..Default::default()
@@ -784,6 +1042,111 @@ mod tests {
}
}
fn chosen(reference: &str, id: &str) -> ChosenImage {
ChosenImage {
reference: reference.to_string(),
id: id.to_string(),
}
}
#[test]
fn local_builds_are_tagged_with_the_pin_and_context() {
let v = pinned_litellm_version();
assert!(v.starts_with('v') && !v.contains(['/', '@', ':']), "bad pin tag {v:?}");
let tag = local_image_ref();
let suffix = tag
.strip_prefix(&format!("triple-c-gateway:{}-", v))
.expect("tag is repo:pin-hash");
assert_eq!(suffix.len(), 8);
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn registry_lookups_go_by_digest() {
assert_eq!(
registry_image_ref(),
format!("ghcr.io/berriai/litellm@{}", GATEWAY_REGISTRY_DIGEST)
);
}
#[test]
fn dockerfile_from_matches_the_registry_pin() {
// Local builds are tagged with the pin; that is only truthful while the
// Dockerfile builds FROM the same tag.
let from = GATEWAY_DOCKERFILE
.lines()
.find(|l| l.starts_with("FROM "))
.expect("Dockerfile has a FROM line");
let expected = format!(
"FROM {}:{}@{}",
GATEWAY_REGISTRY_REPO, GATEWAY_LITELLM_VERSION, GATEWAY_REGISTRY_DIGEST
);
assert_eq!(from.trim(), expected, "gateway Dockerfile FROM is out of lockstep with the pin");
}
#[test]
fn local_build_wins_then_registry_then_nothing() {
assert_eq!(
choose_gateway_image(Some("sha256:local".into()), Some("sha256:reg".into())),
Some(chosen(&local_image_ref(), "sha256:local"))
);
assert_eq!(
choose_gateway_image(None, Some("sha256:reg".into())),
Some(chosen(&registry_image_ref(), "sha256:reg"))
);
assert_eq!(choose_gateway_image(None, None), None);
}
#[test]
fn outdated_matches_what_ensure_would_recreate() {
let pinned = chosen(&registry_image_ref(), "sha256:new");
assert!(!container_image_outdated(true, Some("sha256:new"), Some(Some(&pinned))));
assert!(container_image_outdated(true, Some("sha256:old"), Some(Some(&pinned))));
// No pinned image at all: whatever runs is not it.
assert!(container_image_outdated(true, Some("sha256:old"), Some(None)));
// No container, or the check failed: don't claim it.
assert!(!container_image_outdated(false, None, Some(Some(&pinned))));
assert!(!container_image_outdated(true, Some("sha256:old"), None));
// A container whose image id is unknown: status and ensure agree
// that it isn't on the pinned image.
assert!(container_image_outdated(true, None, Some(Some(&pinned))));
assert!(needs_recreate("fp", "fp", None, Some(&pinned)));
}
#[test]
fn comment_edits_dont_change_the_local_build_tag() {
let a = "# about\nFROM x\n\n # indented note\nRUN y\n";
let b = "# reworded\nFROM x\nRUN y\n# trailing\n";
assert_eq!(significant_lines(a), significant_lines(b));
assert_ne!(significant_lines(a), significant_lines("FROM x\nRUN z\n"));
// A parser directive changes the build.
assert_ne!(
significant_lines(b),
significant_lines(&format!("# syntax=docker/dockerfile:1.7\n{b}"))
);
}
#[test]
fn fingerprint_format_is_unchanged_from_earlier_versions() {
// What main wrote: sha256 of "shape|secret". Keeping it means the app
// update alone recreates nothing.
assert_eq!(gateway_fingerprint("shape", "1"), sha256_hex("shape|1"));
}
#[test]
fn recreate_on_config_change_or_a_present_new_pinned_image() {
let pinned = chosen(&registry_image_ref(), "sha256:new");
// Unchanged config, already on the pinned image: keep.
assert!(!needs_recreate("fp", "fp", Some("sha256:new"), Some(&pinned)));
// Pinned image present, container on another: recreate.
assert!(needs_recreate("fp", "fp", Some("sha256:old"), Some(&pinned)));
// No pinned image: keep the working gateway on its image.
assert!(!needs_recreate("fp", "fp", Some("sha256:old"), None));
// Config changed: always recreate.
assert!(needs_recreate("old", "fp", Some("sha256:new"), Some(&pinned)));
assert!(needs_recreate("old", "fp", Some("sha256:old"), None));
}
#[test]
fn valid_models_skips_incomplete_rows() {
assert_eq!(settings().valid_models().len(), 1);
+311 -13
View File
@@ -15,8 +15,18 @@ 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");
const WIP_PUSH: &str = include_str!("../../../../container/triple-c-wip-push");
const IDLE_WATCH: &str = include_str!("../../../../container/triple-c-idle-watch");
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()?;
@@ -69,25 +79,32 @@ pub async fn get_local_image_digest(image_name: &str) -> Result<Option<String>,
Ok(None)
}
/// Split an image reference into the `fromImage` and `tag` the pull API takes.
/// A digest reference (`repo@sha256:…`, optionally `repo:tag@sha256:…`) is
/// pulled by digest: the API takes the digest in the `tag` field.
fn split_image_ref(image_name: &str) -> (&str, &str) {
if let Some((name, digest)) = image_name.split_once('@') {
let repo = match name.rfind(':') {
Some(pos) if !name[pos + 1..].contains('/') => &name[..pos],
_ => name,
};
return (repo, digest);
}
match image_name.rfind(':') {
// The colon is part of a port (e.g., host:port/repo), not a tag.
Some(pos) if image_name[pos + 1..].contains('/') => (image_name, "latest"),
Some(pos) => (&image_name[..pos], &image_name[pos + 1..]),
None => (image_name, "latest"),
}
}
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,
{
let docker = get_docker()?;
// Parse image name into from_image and tag
let (from_image, tag) = if let Some(pos) = image_name.rfind(':') {
// Check that the colon is part of a tag, not a port
let after_colon = &image_name[pos + 1..];
if after_colon.contains('/') {
// The colon is part of a port (e.g., host:port/repo)
(image_name, "latest")
} else {
(&image_name[..pos], after_colon)
}
} else {
(image_name, "latest")
};
let (from_image, tag) = split_image_ref(image_name);
let options = CreateImageOptions {
from_image,
@@ -130,10 +147,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()
};
@@ -184,6 +219,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();
{
@@ -196,8 +245,16 @@ 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_file_to_archive(&mut archive, "triple-c-wip-push", WIP_PUSH.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "triple-c-idle-watch", IDLE_WATCH.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()?;
}
@@ -205,3 +262,244 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
let _ = buf.flush();
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;
use std::fs;
use std::path::Path;
use std::process::Command;
/// The `sync_host_ssh` function from `entrypoint.sh`, cut out between its
/// markers so it can be run against temp dirs instead of the real ~/.ssh.
fn sync_fn() -> &'static str {
let start = ENTRYPOINT.find("# >>> sync_host_ssh").expect("start marker");
let end = ENTRYPOINT.find("# <<< sync_host_ssh").expect("end marker");
&ENTRYPOINT[start..end]
}
fn sync(src: &Path, dest: &Path) -> String {
let script = format!("{}\nsync_host_ssh \"$1\" \"$2\"\n", sync_fn());
let out = Command::new("bash")
.arg("-c")
.arg(script)
.arg("sync")
.arg(src)
.arg(dest)
.output()
.expect("run bash");
assert!(out.status.success(), "sync failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn read(p: &Path) -> String {
fs::read_to_string(p).unwrap()
}
#[test]
fn container_created_keys_survive_restart() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::write(src.join("id_host"), "host-key").unwrap();
sync(&src, &dest);
fs::write(dest.join("id_container"), "made-in-container").unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("id_container")), "made-in-container");
assert_eq!(read(&dest.join("id_host")), "host-key");
}
#[test]
fn container_file_wins_over_same_named_host_file() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::write(src.join("id_host"), "host-key").unwrap();
sync(&src, &dest);
fs::write(dest.join("id_ed25519"), "container-key").unwrap();
fs::write(src.join("id_ed25519"), "host-ed25519").unwrap();
let out = sync(&src, &dest);
assert_eq!(read(&dest.join("id_ed25519")), "container-key");
assert!(out.contains("keeping container's"), "{out}");
}
#[test]
fn host_changes_propagate_until_edited_in_container() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::write(src.join("config"), "v1").unwrap();
fs::write(src.join("id_old"), "old").unwrap();
sync(&src, &dest);
fs::write(src.join("config"), "v2").unwrap();
fs::remove_file(src.join("id_old")).unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("config")), "v2");
assert!(!dest.join("id_old").exists(), "host-removed, unedited file is dropped");
fs::write(dest.join("config"), "edited in container").unwrap();
fs::write(src.join("config"), "v3").unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("config")), "edited in container");
fs::remove_file(src.join("config")).unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("config")), "edited in container");
}
#[test]
fn known_hosts_is_unioned() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(src.join("known_hosts"), "hostA key\n").unwrap();
fs::write(dest.join("known_hosts"), "containerB key\n").unwrap();
sync(&src, &dest);
let kh = read(&dest.join("known_hosts"));
assert!(kh.contains("hostA key") && kh.contains("containerB key"), "{kh}");
}
#[test]
fn upgrade_from_wipe_and_copy_backs_up_differing_files() {
// No manifest yet: the state an existing project is in on its first
// start with this entrypoint.
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(src.join("id_rsa"), "host").unwrap();
fs::write(dest.join("id_rsa"), "container").unwrap();
fs::write(dest.join("id_extra"), "extra").unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("id_rsa")), "host");
assert_eq!(read(&dest.join("id_rsa.triple-c-backup")), "container");
assert_eq!(read(&dest.join("id_extra")), "extra");
}
#[test]
fn nested_host_files_are_copied() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(src.join("config.d")).unwrap();
fs::write(src.join("config.d/work"), "Host work").unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("config.d/work")), "Host work");
}
}
#[cfg(test)]
mod apt_refresh_tests {
use super::DOCKERFILE;
const BUILD_WORKFLOW: &str = include_str!("../../../../.gitea/workflows/build.yml");
/// The `RUN` instruction (with its `\` continuations) that contains `needle`.
fn run_containing(needle: &str) -> String {
let mut run = String::new();
let mut current = String::new();
for line in DOCKERFILE.lines() {
if line.starts_with("RUN ") {
current.clear();
}
current.push_str(line);
current.push('\n');
if !line.trim_end().ends_with('\\') {
if current.contains(needle) && current.starts_with("RUN ") {
run = current.clone();
}
current.clear();
}
}
run
}
/// CI builds with `cache-from: type=gha`, so the `apt-get upgrade` layer is
/// only re-run when its cache key changes. An ARG changes a RUN's key only
/// where it is *used*, so the upgrade's RUN must reference it, after an
/// `ARG APT_REFRESH` declared in its stage.
#[test]
fn the_apt_upgrade_layer_is_keyed_on_apt_refresh() {
let arg = DOCKERFILE.find("\nARG APT_REFRESH").expect("ARG APT_REFRESH declared");
let run = run_containing("upgrade -y");
assert!(!run.is_empty(), "no RUN with apt-get upgrade");
assert!(DOCKERFILE.find(&run).unwrap() > arg, "ARG must come before the RUN");
assert!(run.contains("${APT_REFRESH"), "RUN must use APT_REFRESH: {run}");
}
/// …and CI must actually pass a value that changes at least weekly, and
/// rebuild on a schedule so a week with no commits still gets the fixes.
#[test]
fn ci_passes_a_weekly_apt_refresh_and_rebuilds_on_a_schedule() {
assert!(BUILD_WORKFLOW.contains("APT_REFRESH=${{ steps.apt.outputs.stamp }}"));
assert!(BUILD_WORKFLOW.contains("date -u +%G-W%V"));
assert!(BUILD_WORKFLOW.contains("schedule:"));
// A scheduled run must publish, a PR run must not.
assert!(BUILD_WORKFLOW.contains("push: ${{ gitea.event_name != 'pull_request' }}"));
}
}
#[cfg(test)]
mod pull_ref_tests {
use super::split_image_ref;
#[test]
fn split_image_ref_handles_tags_ports_and_digests() {
assert_eq!(split_image_ref("repo:v1"), ("repo", "v1"));
assert_eq!(split_image_ref("repo"), ("repo", "latest"));
assert_eq!(split_image_ref("host:5000/repo"), ("host:5000/repo", "latest"));
assert_eq!(split_image_ref("host:5000/repo:v1"), ("host:5000/repo", "v1"));
assert_eq!(split_image_ref("ghcr.io/a/b@sha256:abc"), ("ghcr.io/a/b", "sha256:abc"));
assert_eq!(split_image_ref("ghcr.io/a/b:v1@sha256:abc"), ("ghcr.io/a/b", "sha256:abc"));
assert_eq!(split_image_ref("host:5000/b@sha256:abc"), ("host:5000/b", "sha256:abc"));
}
}
File diff suppressed because it is too large. Load diff
+7
View File
@@ -1,12 +1,19 @@
pub mod ca_certs;
pub mod client;
pub mod events;
pub mod container;
pub mod workspace_env;
pub mod image;
pub mod exec;
pub mod gateway;
pub mod legacy_cleanup;
pub mod migration;
pub mod stt;
#[cfg(all(test, unix))]
mod aws_mode_tests;
/// The container user `claude`'s home directory, as the image creates it.
pub(crate) const CONTAINER_HOME: &str = "/home/claude";
#[allow(unused_imports)]
pub use gateway::*;
+699
View File
@@ -0,0 +1,699 @@
//! The container environment a project gets, built once for every host.
//!
//! Docker passes these pairs as the container's `Env`; the AWS host stores the
//! very same pairs in a per-project Secrets Manager secret. Both therefore call
//! [`workspace_env`], so a backend, a CA bundle or a custom variable cannot
//! reach one host and not the other.
//!
//! What is *not* here: `COLORTERM`, `HOST_UID` and `HOST_GID` (host-specific,
//! assembled by `create_container`), and anything that needs the host — the
//! corporate CA is resolved from disk and the shared Claude token read from
//! the keychain by the caller, then handed in through [`WorkspaceEnvInputs`].
//! Order is part of the contract: it is the order `docker inspect` shows.
use super::ca_certs::{self, ResolvedCa};
use super::container::{
build_claude_code_settings_json, build_claude_instructions, claude_code_env_vars,
compute_env_fingerprint, compute_model_aliases, is_reserved_env_key, merge_claude_code_settings,
merge_custom_env_vars, resolve_with_global, split_claude_code_settings_payload, vpn_env_var,
ANTHROPIC_DEFAULT_FABLE_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL, CLAUDE_OAUTH_TOKEN_ENV,
};
use crate::models::{
Backend, BedrockAuthMethod, ClaudeCodeSettings, EnvVar, GlobalAwsSettings,
GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, Project,
};
/// Everything besides the project that shapes its environment.
pub(crate) struct WorkspaceEnvInputs<'a> {
pub global_aws: &'a GlobalAwsSettings,
pub global_ollama: &'a GlobalOllamaSettings,
pub global_llamacpp: &'a GlobalLlamaCppSettings,
pub global_openai_compatible: &'a GlobalOpenAiCompatibleSettings,
pub global_claude_instructions: Option<&'a str>,
pub global_custom_env_vars: &'a [EnvVar],
pub timezone: Option<&'a str>,
pub global_claude_code_settings: Option<&'a ClaudeCodeSettings>,
pub default_git_user_name: Option<&'a str>,
pub default_git_user_email: Option<&'a str>,
/// The corporate CA, already resolved from disk (`ca_certs::resolve`).
pub resolved_ca: Option<&'a ResolvedCa>,
/// The shared Claude OAuth token, already read from the keychain and
/// already filtered by `shared_claude_auth` (backend, opt-out, non-blank).
pub shared_claude_token: Option<&'a str>,
}
/// `"KEY=VALUE"` to a pair, splitting at the first `=` (values may contain `=`).
fn split_pair(entry: &str) -> (String, String) {
match entry.split_once('=') {
Some((k, v)) => (k.to_string(), v.to_string()),
None => (entry.to_string(), String::new()),
}
}
/// The `KEY=VALUE` pairs a project's container gets, in order, excluding the
/// host-specific ones. See the module docs.
pub(crate) fn workspace_env(
project: &Project,
inputs: &WorkspaceEnvInputs,
) -> Vec<(String, String)> {
let WorkspaceEnvInputs {
global_aws,
global_ollama,
global_llamacpp,
global_openai_compatible,
global_claude_instructions,
global_custom_env_vars,
timezone,
global_claude_code_settings,
default_git_user_name,
default_git_user_email,
resolved_ca,
shared_claude_token,
} = *inputs;
let mut env: Vec<(String, String)> = Vec::new();
if let Some(ref token) = project.git_token {
env.push(("GIT_TOKEN".to_string(), token.to_string()));
}
// Per-project git user overrides global defaults
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
if let Some(name) = effective_git_name {
env.push(("GIT_USER_NAME".to_string(), name.to_string()));
}
if let Some(email) = effective_git_email {
env.push(("GIT_USER_EMAIL".to_string(), email.to_string()));
}
// Bedrock configuration
if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config {
env.push(("CLAUDE_CODE_USE_BEDROCK".to_string(), "1".to_string()));
// AWS region: per-project overrides global
let region = if !bedrock.aws_region.is_empty() {
Some(bedrock.aws_region.clone())
} else {
global_aws.aws_region.clone()
};
if let Some(ref r) = region {
env.push(("AWS_REGION".to_string(), r.to_string()));
}
match bedrock.auth_method {
BedrockAuthMethod::StaticCredentials => {
// Static/session credentials are NOT injected as env vars.
// They are written to ~/.aws/credentials by
// sync_bedrock_credentials() on every container
// start, so rotated/updated keys are picked up without a
// full container recreation (and never get baked into the
// snapshot image). The empty values set by the
// MANAGED_AUTH_KEYS neutralization pass below are ignored by
// the AWS SDK, which falls through to the credentials file.
}
BedrockAuthMethod::Profile => {
// Per-project profile overrides global
let profile = bedrock.aws_profile.as_ref()
.or(global_aws.aws_profile.as_ref());
if let Some(p) = profile {
env.push(("AWS_PROFILE".to_string(), p.to_string()));
}
env.push(("AWS_SSO_AUTH_REFRESH_CMD".to_string(), "triple-c-sso-refresh".to_string()));
}
BedrockAuthMethod::BearerToken => {
if let Some(ref token) = bedrock.aws_bearer_token {
env.push(("AWS_BEARER_TOKEN_BEDROCK".to_string(), token.to_string()));
}
}
}
if let Some(model) = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
) {
env.push(("ANTHROPIC_MODEL".to_string(), model.to_string()));
}
if bedrock.disable_prompt_caching {
env.push(("DISABLE_PROMPT_CACHING".to_string(), "1".to_string()));
}
if let Some(ref tier) = bedrock.service_tier {
let trimmed = tier.trim();
if !trimmed.is_empty() {
env.push(("ANTHROPIC_BEDROCK_SERVICE_TIER".to_string(), trimmed.to_string()));
}
}
}
}
// ── Custom-endpoint backends ─────────────────────────────────────────────
// Ollama, llama.cpp and the OpenAI-Compatible gateway all point Claude Code
// at a non-Anthropic server via ANTHROPIC_BASE_URL. Each resolves its model
// id here; the model-alias vars are emitted once below, from
// `alias_model` / `alias_haiku`, so the three backends cannot drift apart.
let mut alias_model: Option<String> = None;
let mut alias_haiku: Option<String> = None;
// Ollama configuration
if project.backend == Backend::Ollama {
if let Some(ref ollama) = project.ollama_config {
if let Some(url) = resolve_with_global(
Some(&ollama.base_url),
global_ollama.base_url.as_deref(),
) {
env.push(("ANTHROPIC_BASE_URL".to_string(), url.to_string()));
}
env.push(("ANTHROPIC_AUTH_TOKEN".to_string(), "ollama".to_string()));
if let Some(model) = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
) {
env.push(("ANTHROPIC_MODEL".to_string(), model.to_string()));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
ollama.haiku_model_id.as_deref(),
global_ollama.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// llama.cpp (llama-server) configuration
if project.backend == Backend::LlamaCpp {
if let Some(ref cfg) = project.llamacpp_config {
if let Some(url) = resolve_with_global(
Some(&cfg.base_url),
global_llamacpp.base_url.as_deref(),
) {
env.push(("ANTHROPIC_BASE_URL".to_string(), url.to_string()));
}
// llama-server only enforces an Authorization header when it was
// started with `--api-key` (default: none), so the value here is
// ignored in the common case. Claude Code still refuses to run
// against a custom base URL with no credential at all, so a
// placeholder is always sent — same trick as the Ollama branch
// above, which sends the literal "ollama".
env.push(("ANTHROPIC_AUTH_TOKEN".to_string(), "llama.cpp".to_string()));
if let Some(model) = resolve_with_global(
cfg.model_id.as_deref(),
global_llamacpp.default_model_id.as_deref(),
) {
env.push(("ANTHROPIC_MODEL".to_string(), model.to_string()));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
cfg.haiku_model_id.as_deref(),
global_llamacpp.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// OpenAI Compatible configuration
if project.backend == Backend::OpenAiCompatible {
if let Some(ref config) = project.openai_compatible_config {
if let Some(url) = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
) {
env.push(("ANTHROPIC_BASE_URL".to_string(), url.to_string()));
}
if let Some(ref key) = config.api_key {
env.push(("ANTHROPIC_AUTH_TOKEN".to_string(), key.to_string()));
}
if let Some(model) = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
) {
env.push(("ANTHROPIC_MODEL".to_string(), model.to_string()));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
config.haiku_model_id.as_deref(),
global_openai_compatible.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// Model aliases — the fix for background Claude Code calls against a local
// server. Only for backends that talk to a custom endpoint: Anthropic and
// Bedrock reach servers that really do host the Anthropic model ids, so
// they keep Claude Code's own defaults. Anything not emitted here is
// blanked by the MANAGED_AUTH_KEYS pass below, so switching *away* from a
// custom endpoint clears the aliases out of the snapshot image too.
if project.backend.uses_custom_endpoint() {
for (key, value) in
compute_model_aliases(alias_model.as_deref(), alias_haiku.as_deref())
{
env.push((key.to_string(), value));
}
}
// Shared Claude Code OAuth token (Anthropic backend only, opt-out per
// project). Injected *before* the neutralization pass below so that pass
// sees it as already-set; when it is absent the pass actively blanks the
// variable instead of leaving a stale one baked into the snapshot image.
if let Some(token) = shared_claude_token {
env.push((CLAUDE_OAUTH_TOKEN_ENV.to_string(), token.to_string()));
}
// ── Corporate CA certificates ───────────────────────────────────────────
// Resolved here (rather than down with the mounts) so the env vars land
// *before* the neutralization pass below and are seen as already-set.
//
// A bad path is a hard error, not a warning: behind a TLS-terminating
// proxy a container without the CA fails every HTTPS call — npm, pip, git,
// and Claude Code's own API requests — each in its own confusing way. One
// message naming the path is far kinder.
//
// The values are set here rather than exported by the entrypoint because a
// terminal session is a `docker exec`, which sees the container's
// configured env and nothing the entrypoint exported. Same lesson as
// `$BROWSER` and the URL relay shim.
for (key, value) in ca_certs::ca_env_vars(resolved_ca) {
env.push((key.to_string(), value));
}
// ── Neutralize stale backend auth env vars ──────────────────────────────
// When a project switches backends (e.g. Bedrock → Anthropic) the container
// is recreated *from a snapshot image* committed off the previous container,
// and `docker commit` copies that container's full ENV into the image. So
// any auth var set under the old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1,
// AWS_PROFILE, a model alias) survives in the image ENV and stays active
// unless we explicitly override it at create time.
//
// This pass is about *staleness*, not secrecy. It fixes the container it is
// building and does nothing to the image, so it is not — and never was —
// a defence against a credential baked into a snapshot. That is
// `commit_container_snapshot`'s job, via SECRET_ENV_KEYS.
// Create-time env takes precedence over image ENV, so we set every managed
// auth key the *current* backend did NOT set to an empty value, clearing the
// stale baked-in one.
const MANAGED_AUTH_KEYS: &[&str] = &[
"CLAUDE_CODE_USE_BEDROCK",
"AWS_REGION",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_PROFILE",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_SSO_AUTH_REFRESH_CMD",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_MODEL",
"DISABLE_PROMPT_CACHING",
"ANTHROPIC_BEDROCK_SERVICE_TIER",
// Switching from a custom-endpoint backend to Anthropic or Bedrock must
// *clear* the aliases, not merely stop setting them: a stale
// ANTHROPIC_DEFAULT_HAIKU_MODEL baked into the snapshot image would
// keep pointing background calls at a model id the new backend has
// never heard of.
ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL,
ANTHROPIC_DEFAULT_FABLE_MODEL,
// Revoking the shared token, opting a project out, or switching away
// from the Anthropic backend must *clear* this, not merely stop setting
// it — otherwise the value committed into the snapshot image keeps
// authenticating the container with a credential the user removed.
CLAUDE_OAUTH_TOKEN_ENV,
];
// Same reasoning for the CA vars — `ca_env` already emits them empty
// when no CA is configured, so this list is belt-and-braces for a snapshot
// committed by a build that predates the feature.
let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS
.iter()
.copied()
.chain(ca_certs::CA_ENV_KEYS.iter().copied())
.collect();
let already_set: std::collections::HashSet<String> =
env.iter().map(|(k, _)| k.clone()).collect();
for key in &managed_keys {
if !already_set.contains(*key) {
env.push((key.to_string(), String::new()));
}
}
// Custom environment variables (global + per-project, project overrides global for same key)
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
for env_var in &merged_env {
let key = env_var.key.trim();
if key.is_empty() {
continue;
}
if is_reserved_env_key(key) {
log::warn!("Skipping reserved env var: {}", key);
continue;
}
env.push((key.to_string(), env_var.value.clone()));
}
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
env.push(("TRIPLE_C_CUSTOM_ENV".to_string(), custom_env_fingerprint.to_string()));
// Container timezone
if let Some(tz) = timezone {
if !tz.is_empty() {
env.push(("TZ".to_string(), tz.to_string()));
}
}
// Mission Control env var
if project.mission_control_enabled {
env.push(("MISSION_CONTROL_ENABLED".to_string(), "1".to_string()));
}
env.push(split_pair(&vpn_env_var(project.vpn_support_enabled)));
// Permission mode — read by triple-c-task-runner for scheduled (headless)
// Claude Code runs. Interactive terminals get the flags directly instead.
env.push((
"TRIPLE_C_PERMISSION_MODE".to_string(),
project.effective_permission_mode().as_env_value().to_string(),
));
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
let combined_instructions = build_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
&project.port_mappings,
project.mission_control_enabled,
project.sandbox_mode_enabled,
);
if let Some(ref instructions) = combined_instructions {
env.push(("CLAUDE_INSTRUCTIONS".to_string(), instructions.to_string()));
}
// Claude Code settings (global + per-project merged)
let merged_cc_settings = merge_claude_code_settings(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
// Env-var-based settings, read directly by Claude Code. Extracted and unit
// tested for the `vpn_host_config` reason: a container is created once, by
// a very long function, and a variable emitted with the wrong value here is
// invisible until someone wonders why a switch does nothing.
env.extend(
claude_code_env_vars(merged_cc_settings.as_ref())
.iter()
.map(|entry| split_pair(entry)),
);
// settings.json-based settings (applied by the entrypoint). Always emitted,
// even with no `ClaudeCodeSettings` struct present: the payload asserts the
// *whole* managed key set, so "no settings" still has to be stated over a
// settings.json left behind on the config volume by a previous config.
// Split so the payload is safe under an older entrypoint that has never
// heard of the null-means-delete convention — see
// `split_claude_code_settings_payload`.
let (cc_settings_set, cc_settings_clear) = split_claude_code_settings_payload(
&build_claude_code_settings_json(merged_cc_settings.as_ref(), project.sandbox_mode_enabled),
);
env.push(("CLAUDE_CODE_SETTINGS_JSON".to_string(), cc_settings_set.to_string()));
env.push(("CLAUDE_CODE_SETTINGS_CLEAR".to_string(), cc_settings_clear.to_string()));
env
}
/// [`workspace_env`] with its inputs resolved from `settings` exactly as
/// `create_container` resolves them (the CA from disk, the shared Claude
/// token from the keychain), for a host that does not create a Docker
/// container. Also returns the resolved CA, whose files the caller ships.
pub(crate) fn workspace_env_from_settings(
project: &Project,
settings: &crate::models::AppSettings,
) -> Result<crate::host::aws_host::AwsEnv, String> {
let shared_claude = super::container::shared_claude_auth(project);
let effective_ca_path =
resolve_with_global(project.ca_cert_path.as_deref(), settings.ca_cert_path.as_deref());
let resolved_ca = ca_certs::resolve(effective_ca_path)?;
let pairs = workspace_env(
project,
&WorkspaceEnvInputs {
global_aws: &settings.global_aws,
global_ollama: &settings.global_ollama,
global_llamacpp: &settings.global_llamacpp,
global_openai_compatible: &settings.global_openai_compatible,
global_claude_instructions: settings.global_claude_instructions.as_deref(),
global_custom_env_vars: &settings.global_custom_env_vars,
timezone: settings.timezone.as_deref(),
global_claude_code_settings: settings.global_claude_code_settings.as_ref(),
default_git_user_name: settings.default_git_user_name.as_deref(),
default_git_user_email: settings.default_git_user_email.as_deref(),
resolved_ca: resolved_ca.as_ref(),
shared_claude_token: shared_claude.as_ref().map(|(token, _)| token.as_str()),
},
);
Ok((pairs, resolved_ca))
}
#[cfg(test)]
mod tests {
use super::super::ca_certs::ResolvedCa;
use crate::models::*;
use std::path::PathBuf;
/// Every input the env block reads, owned, so a table row is one value.
pub(crate) struct Case {
pub name: &'static str,
pub project: Project,
pub global_aws: GlobalAwsSettings,
pub global_ollama: GlobalOllamaSettings,
pub global_llamacpp: GlobalLlamaCppSettings,
pub global_openai: GlobalOpenAiCompatibleSettings,
pub global_instructions: Option<&'static str>,
pub global_env: Vec<EnvVar>,
pub timezone: Option<&'static str>,
pub global_cc: Option<ClaudeCodeSettings>,
pub default_git_name: Option<&'static str>,
pub default_git_email: Option<&'static str>,
pub ca: Option<ResolvedCa>,
pub shared_token: Option<(String, String)>,
}
fn case(name: &'static str, project: Project) -> Case {
Case {
name,
project,
global_aws: GlobalAwsSettings::default(),
global_ollama: GlobalOllamaSettings::default(),
global_llamacpp: GlobalLlamaCppSettings::default(),
global_openai: GlobalOpenAiCompatibleSettings::default(),
global_instructions: None,
global_env: Vec::new(),
timezone: None,
global_cc: None,
default_git_name: None,
default_git_email: None,
ca: None,
shared_token: None,
}
}
fn proj(backend: Backend) -> Project {
let mut p = Project::new("demo".to_string(), Vec::new());
p.backend = backend;
p
}
fn bedrock(method: BedrockAuthMethod) -> BedrockConfig {
BedrockConfig {
auth_method: method,
aws_region: "us-east-1".into(),
aws_access_key_id: Some("AKIAFAKE".into()),
aws_secret_access_key: Some("secretfake".into()),
aws_session_token: None,
aws_profile: Some("proj-profile".into()),
aws_bearer_token: Some("bearerfake".into()),
model_id: Some("anthropic.claude-x".into()),
disable_prompt_caching: true,
service_tier: Some(" priority ".into()),
}
}
pub(crate) fn cases() -> Vec<Case> {
let mut v = Vec::new();
let mut c = case("anthropic_shared_token", proj(Backend::Anthropic));
c.shared_token = Some(("sk-shared-token".into(), "v7".into()));
v.push(c);
v.push(case("anthropic_plain", proj(Backend::Anthropic)));
let mut p = proj(Backend::Bedrock);
p.bedrock_config = Some(bedrock(BedrockAuthMethod::StaticCredentials));
let mut c = case("bedrock_static_keys", p);
c.global_aws.default_model_id = Some("global-model".into());
v.push(c);
let mut p = proj(Backend::Bedrock);
let mut b = bedrock(BedrockAuthMethod::Profile);
b.aws_region = String::new();
b.aws_profile = None;
b.model_id = None;
b.disable_prompt_caching = false;
b.service_tier = Some(" ".into());
p.bedrock_config = Some(b);
let mut c = case("bedrock_profile_global_fallbacks", p);
c.global_aws.aws_region = Some("eu-west-1".into());
c.global_aws.aws_profile = Some("global-profile".into());
c.global_aws.default_model_id = Some("global-model".into());
v.push(c);
let mut p = proj(Backend::Bedrock);
p.bedrock_config = Some(bedrock(BedrockAuthMethod::BearerToken));
v.push(case("bedrock_bearer", p));
let mut p = proj(Backend::Ollama);
p.ollama_config = Some(OllamaConfig {
base_url: "http://ollama.local:11434".into(),
model_id: Some("qwen3".into()),
haiku_model_id: None,
});
let mut c = case("ollama", p);
c.global_ollama.default_haiku_model_id = Some("tiny".into());
v.push(c);
let mut p = proj(Backend::LlamaCpp);
p.llamacpp_config = Some(LlamaCppConfig {
base_url: String::new(),
model_id: None,
haiku_model_id: Some("llama-small".into()),
});
let mut c = case("llamacpp_global_url", p);
c.global_llamacpp.base_url = Some("http://llama:8080".into());
c.global_llamacpp.default_model_id = Some("llama-big".into());
v.push(c);
let mut p = proj(Backend::OpenAiCompatible);
p.openai_compatible_config = Some(OpenAiCompatibleConfig {
base_url: "https://gw.example/v1".into(),
api_key: Some("sk-gateway".into()),
model_id: Some("gpt-x".into()),
haiku_model_id: Some("gpt-mini".into()),
});
v.push(case("openai_compatible", p));
let mut p = proj(Backend::Anthropic);
p.custom_env_vars = vec![
EnvVar { key: "FOO".into(), value: "project".into() },
EnvVar { key: " SPACED ".into(), value: "x y".into() },
EnvVar { key: "".into(), value: "dropped".into() },
EnvVar { key: "HOME".into(), value: "reserved".into() },
EnvVar { key: "CLAUDE_CODE_OAUTH_TOKEN".into(), value: "reserved".into() },
];
let mut c = case("custom_env", p);
c.global_env = vec![
EnvVar { key: "FOO".into(), value: "global".into() },
EnvVar { key: "BAR".into(), value: "b".into() },
];
v.push(c);
let mut p = proj(Backend::Anthropic);
p.claude_instructions = Some("project rules".into());
p.mission_control_enabled = true;
p.sandbox_mode_enabled = true;
p.port_mappings = vec![PortMapping {
host_port: 8080,
container_port: 3000,
protocol: "tcp".into(),
}];
let mut c = case("instructions_and_mission_control", p);
c.global_instructions = Some("global rules\nline two");
v.push(c);
let mut p = proj(Backend::Anthropic);
p.claude_code_settings = Some(ClaudeCodeSettings {
effort: Some("high".into()),
focus_mode: Some(false),
..Default::default()
});
let mut c = case("claude_code_settings", p);
c.global_cc = Some(ClaudeCodeSettings {
tui_mode: Some("fullscreen".into()),
env_scrub: Some(true),
prompt_caching_1h: Some(true),
..Default::default()
});
v.push(c);
let mut p = proj(Backend::Anthropic);
p.vpn_support_enabled = true;
p.git_token = Some("ghp_faketoken".into());
p.git_user_name = Some("Project User".into());
let mut c = case("vpn_git_timezone", p);
c.timezone = Some("Europe/Berlin");
c.default_git_name = Some("Default Name");
c.default_git_email = Some("default@example.com");
v.push(c);
let mut c = case("corporate_ca", proj(Backend::Anthropic));
c.ca = Some(ResolvedCa {
host_path: "/certs".into(),
is_dir: true,
mount_target: "/tmp/.host-ca".into(),
cert_files: vec![PathBuf::from("/certs/a.crt")],
});
v.push(c);
v
}
fn golden_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src/docker/workspace_env_golden")
.join(format!("{name}.txt"))
}
/// `merge_custom_env_vars` collects a `HashMap`, so the custom variables
/// come out in a different order every run (before the extraction as
/// well). Sort that one run — between the last managed blank and the
/// fingerprint — so the rest of the order is still compared exactly.
fn normalise(mut env: Vec<String>) -> Vec<String> {
let end = env.iter().position(|e| e.starts_with("TRIPLE_C_CUSTOM_ENV=")).unwrap();
let start = env.iter().position(|e| e.starts_with("CLAUDE_CODE_OAUTH_TOKEN=")).unwrap() + 1;
env[start..end].sort();
env
}
/// The pre-refactor block's output, recorded before the extraction (see
/// `workspace_env_golden/`), must be reproduced exactly, order included.
#[test]
fn workspace_env_matches_what_create_container_built_before_the_extraction() {
for c in cases() {
let inputs = super::WorkspaceEnvInputs {
global_aws: &c.global_aws,
global_ollama: &c.global_ollama,
global_llamacpp: &c.global_llamacpp,
global_openai_compatible: &c.global_openai,
global_claude_instructions: c.global_instructions,
global_custom_env_vars: &c.global_env,
timezone: c.timezone,
global_claude_code_settings: c.global_cc.as_ref(),
default_git_user_name: c.default_git_name,
default_git_user_email: c.default_git_email,
resolved_ca: c.ca.as_ref(),
shared_claude_token: c.shared_token.as_ref().map(|(t, _)| t.as_str()),
};
let got: Vec<String> = super::workspace_env(&c.project, &inputs)
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
let want = std::fs::read_to_string(golden_path(c.name)).unwrap();
let want: Vec<String> = want
.lines()
.filter(|l| *l != "[" && *l != "]")
.map(|l| {
let l = l.trim().trim_end_matches(',');
serde_json::from_str::<String>(l).unwrap_or_else(|_| panic!("{l}"))
})
.collect();
assert_eq!(normalise(got), normalise(want), "case {}", c.name);
}
}
}
@@ -0,0 +1,33 @@
[
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"ANTHROPIC_MODEL=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,33 @@
[
"CLAUDE_CODE_OAUTH_TOKEN=sk-shared-token",
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"ANTHROPIC_MODEL=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,33 @@
[
"CLAUDE_CODE_USE_BEDROCK=1",
"AWS_REGION=us-east-1",
"AWS_BEARER_TOKEN_BEDROCK=bearerfake",
"ANTHROPIC_MODEL=anthropic.claude-x",
"DISABLE_PROMPT_CACHING=1",
"ANTHROPIC_BEDROCK_SERVICE_TIER=priority",
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,33 @@
[
"CLAUDE_CODE_USE_BEDROCK=1",
"AWS_REGION=eu-west-1",
"AWS_PROFILE=global-profile",
"AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh",
"ANTHROPIC_MODEL=global-model",
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_BEARER_TOKEN_BEDROCK=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,33 @@
[
"CLAUDE_CODE_USE_BEDROCK=1",
"AWS_REGION=us-east-1",
"ANTHROPIC_MODEL=anthropic.claude-x",
"DISABLE_PROMPT_CACHING=1",
"ANTHROPIC_BEDROCK_SERVICE_TIER=priority",
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,33 @@
[
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"ANTHROPIC_MODEL=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=1",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1",
"ENABLE_PROMPT_CACHING_1H=1",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"effortLevel\":\"high\",\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false,\"tui\":\"fullscreen\"}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"viewMode\"]",
]
@@ -0,0 +1,33 @@
[
"NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt",
"REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"ANTHROPIC_MODEL=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,37 @@
[
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"ANTHROPIC_MODEL=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"BAR=b",
"FOO=project",
"SPACED=x y",
"HOME=reserved",
"TRIPLE_C_CUSTOM_ENV=63ce864a8c48a386cb039d4878e3885d5a823d4bb52047ba0e18cb87b9f3035b",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,34 @@
[
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"ANTHROPIC_MODEL=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"MISSION_CONTROL_ENABLED=1",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=global rules\nline two\n\n## Mission Control\n\nThe `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.\n\n### How It Works\n\n- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.\n- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`\n- The methodology docs and project registry live in `/workspace/mission-control/`\n\n### When to Use\n\nWhen working on any project that has a `.flightops/` directory, follow the Flight Control methodology:\n1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage\n2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow\n3. Use Mission Control skills for planning and execution\n\n### Available Skills\n\n| Skill | When to Use |\n|-------|-------------|\n| `/init-project` | Setting up a new project for Flight Control |\n| `/mission` | Defining new work outcomes (days-to-weeks scope) |\n| `/flight` | Creating technical specs from missions (hours-to-days scope) |\n| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |\n| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |\n| `/flight-debrief` | Post-flight analysis after a flight lands |\n| `/mission-debrief` | Post-mission retrospective after completion |\n| `/daily-briefing` | Cross-project status report |\n\n### Key Rules\n\n- **Planning skills produce artifacts only** — never modify source code directly\n- **Phase gates require human confirmation** — missions before flights, flights before legs\n- **Legs are immutable once in-flight** — create new ones instead of modifying\n- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents\n- **Artifacts live in the target project** — not in mission-control\n\nproject rules\n\n## Flight Operations\n\nThis project uses **Flight Control** (bundled with Triple-C) for structured development.\n\n**Before any mission/flight/leg work, read these files in order:**\n1. `.flightops/README.md` — What the flightops directory contains\n2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**\n3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored\n4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)\n\n## Available Port Mappings\nThe following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:\n- Host port 8080 -> Container port 3000 (tcp)\n\n## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relLine truncated
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"allowUnsandboxedCommands\":false,\"enableWeakerNestedSandbox\":true,\"enabled\":true},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,33 @@
[
"ANTHROPIC_BASE_URL=http://llama:8080",
"ANTHROPIC_AUTH_TOKEN=llama.cpp",
"ANTHROPIC_MODEL=llama-big",
"ANTHROPIC_DEFAULT_OPUS_MODEL=llama-big",
"ANTHROPIC_DEFAULT_SONNET_MODEL=llama-big",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=llama-small",
"ANTHROPIC_DEFAULT_FABLE_MODEL=llama-big",
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,33 @@
[
"ANTHROPIC_BASE_URL=http://ollama.local:11434",
"ANTHROPIC_AUTH_TOKEN=ollama",
"ANTHROPIC_MODEL=qwen3",
"ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3",
"ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=tiny",
"ANTHROPIC_DEFAULT_FABLE_MODEL=qwen3",
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,33 @@
[
"ANTHROPIC_BASE_URL=https://gw.example/v1",
"ANTHROPIC_AUTH_TOKEN=sk-gateway",
"ANTHROPIC_MODEL=gpt-x",
"ANTHROPIC_DEFAULT_OPUS_MODEL=gpt-x",
"ANTHROPIC_DEFAULT_SONNET_MODEL=gpt-x",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=gpt-mini",
"ANTHROPIC_DEFAULT_FABLE_MODEL=gpt-x",
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"VPN_SUPPORT_ENABLED=0",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
@@ -0,0 +1,37 @@
[
"GIT_TOKEN=ghp_faketoken",
"GIT_USER_NAME=Project User",
"GIT_USER_EMAIL=default@example.com",
"NODE_EXTRA_CA_CERTS=",
"REQUESTS_CA_BUNDLE=",
"SSL_CERT_FILE=",
"CLAUDE_CODE_USE_BEDROCK=",
"AWS_REGION=",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_SESSION_TOKEN=",
"AWS_PROFILE=",
"AWS_BEARER_TOKEN_BEDROCK=",
"AWS_SSO_AUTH_REFRESH_CMD=",
"ANTHROPIC_BASE_URL=",
"ANTHROPIC_AUTH_TOKEN=",
"ANTHROPIC_MODEL=",
"DISABLE_PROMPT_CACHING=",
"ANTHROPIC_BEDROCK_SERVICE_TIER=",
"ANTHROPIC_DEFAULT_OPUS_MODEL=",
"ANTHROPIC_DEFAULT_SONNET_MODEL=",
"ANTHROPIC_DEFAULT_HAIKU_MODEL=",
"ANTHROPIC_DEFAULT_FABLE_MODEL=",
"CLAUDE_CODE_OAUTH_TOKEN=",
"TRIPLE_C_CUSTOM_ENV=",
"TZ=Europe/Berlin",
"VPN_SUPPORT_ENABLED=1",
"TRIPLE_C_PERMISSION_MODE=default",
"CLAUDE_INSTRUCTIONS=## Scheduled Tasks\n\nThis container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.\n\n### Commands\n- `triple-c-scheduler add --name \"NAME\" --schedule \"CRON\" --prompt \"TASK\"` — Add a recurring task\n- `triple-c-scheduler add --name \"NAME\" --at \"YYYY-MM-DD HH:MM\" --prompt \"TASK\"` — Add a one-time task\n- `triple-c-scheduler list` — List all scheduled tasks, with a running/idle status column\n- `triple-c-scheduler remove --id ID` — Remove a task\n- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks\n- `triple-c-scheduler status [--id ID] [--watch]` — Show what is running right now, and for how long\n- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs\n- `triple-c-scheduler run --id ID` — Manually trigger a task immediately (streams its log)\n- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications\n\n### Cron format\nStandard 5-field cron: `minute hour day-of-month month day-of-week`\nExamples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)\n\n### One-time tasks\nUse `--at \"YYYY-MM-DD HH:MM\"` instead of `--schedule`. The task automatically removes itself after execution.\n\n### Working directory\nUse `--working-dir /workspace/project` to set where the task runs (default: /workspace).\n\n### Checking results\nWhile a task is running, `triple-c-scheduler status` reports it with elapsed time — a log that has stopped growing is normal, because `claude -p` writes its answer only at the end, so use `status` rather than log silence to tell a slow run from a dead one. After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.\n\n### Timezone\nScheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used.\n\n## Referring to files\n\nThe user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened.",
"CLAUDE_CODE_NO_FLICKER=",
"CLAUDE_CODE_ENABLE_AWAY_SUMMARY=",
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=0",
"ENABLE_PROMPT_CACHING_1H=0",
"CLAUDE_CODE_SETTINGS_JSON={\"autoScrollEnabled\":true,\"sandbox\":{\"enabled\":false},\"showThinkingSummaries\":false}",
"CLAUDE_CODE_SETTINGS_CLEAR=[\"awaySummaryEnabled\",\"effortLevel\",\"tui\",\"viewMode\"]",
]
+109
View File
@@ -0,0 +1,109 @@
//! The terminal file viewer: one OS window per clicked path.
//!
//! Every window is a `file-viewer-<n>` label registered in [`registry::ViewerRegistry`];
//! the commands in `commands/file_viewer_commands.rs` gate on the label and act only on
//! the caller's own entry, which is why nothing here takes a path from a window.
//!
//! `file-viewer-*` is also the `windows` glob of `capabilities/file-viewer.json`, which grants
//! exactly the five `viewer_*` commands and nothing else. Labels are minted only here; a window
//! created anywhere else with a matching label would inherit those grants.
pub mod poll;
pub mod registry;
pub mod resolve;
pub mod window;
pub mod write;
/// Spec §3: the 21st click is refused with a toast.
pub const MAX_VIEWER_WINDOWS: usize = 20;
pub const VIEWER_LABEL_PREFIX: &str = "file-viewer-";
pub fn is_viewer_label(label: &str) -> bool {
label
.strip_prefix(VIEWER_LABEL_PREFIX)
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_numbered_viewer_labels_pass() {
assert!(is_viewer_label("file-viewer-1"));
assert!(is_viewer_label("file-viewer-20"));
assert!(!is_viewer_label("file-viewer-"));
assert!(!is_viewer_label("file-viewer-x"));
assert!(!is_viewer_label("main"));
assert!(!is_viewer_label("browser-view-abc"));
}
/// Both Vite's dev server and Tauri's asset lookup fall back to `index.html`
/// when `viewer.html` is missing, so a broken entry opens the *main app* in
/// the viewer window with no error anywhere. Pin the two files the entry needs.
#[test]
fn the_viewer_entry_exists_and_is_a_vite_input() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let html = std::fs::read_to_string(app_dir.join("viewer.html")).expect("app/viewer.html");
assert!(html.contains("/src/viewer/main.tsx"));
assert!(!html.contains("<style"), "an inline <style> makes Tauri add a style nonce, which disables 'unsafe-inline' and breaks CodeMirror");
let vite = std::fs::read_to_string(app_dir.join("vite.config.ts")).expect("vite.config.ts");
assert!(vite.contains("viewer.html"), "vite.config.ts must list viewer.html in build.rollupOptions.input");
}
#[derive(serde::Deserialize)]
struct Capability {
windows: Vec<String>,
permissions: Vec<String>,
}
/// Task 12: a substring check on the capability JSON (the form this test used to take)
/// only proves a permission string appears *somewhere* in the file — it would not catch
/// `windows` widened past `file-viewer-*`, nor an extra grant slipped in beside the ones
/// this window actually needs. Parse both capability files and pin `windows`/`permissions`
/// exactly, so a later widening of either file is a failing test, not a silent threat-model
/// drift — this file *is* the reviewed threat model of record (see its own description).
#[test]
fn the_viewer_capability_grants_exactly_the_reviewed_windows_and_permissions() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/file-viewer.json"))
.expect("capabilities/file-viewer.json");
let cap: Capability = serde_json::from_str(&raw).expect("file-viewer.json must be valid JSON");
assert_eq!(cap.windows, vec!["file-viewer-*"]);
let mut permissions = cap.permissions;
permissions.sort();
assert_eq!(
permissions,
vec![
// App commands (bare): the five viewer commands, and nothing else — build.rs
// refuses any other bare grant in this file.
"allow-viewer-choose-file",
"allow-viewer-get-state",
"allow-viewer-poll-file",
"allow-viewer-read-file",
"allow-viewer-write-file",
// Plugin/core grants, unchanged.
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:webview:allow-internal-toggle-devtools",
"core:window:allow-destroy",
]
);
}
/// The main window's capability file must stay scoped to `main` — a `windows` list that
/// grew to include `file-viewer-*` would hand every viewer window the dialog/store surface
/// `default.json` grants `main`, which is a much larger IPC surface than the one
/// `file-viewer.json` was deliberately kept small.
#[test]
fn the_default_capability_is_scoped_to_the_main_window_only() {
let app_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let raw = std::fs::read_to_string(app_dir.join("src-tauri/capabilities/default.json"))
.expect("capabilities/default.json");
let cap: Capability = serde_json::from_str(&raw).expect("default.json must be valid JSON");
assert_eq!(cap.windows, vec!["main"]);
}
}
+200
View File
@@ -0,0 +1,200 @@
//! One cheap exec per tick: the file's full hash and size, or "gone".
//!
//! This is what the 2 s poll asks, instead of re-downloading up to 1 MiB of archive per
//! window per tick. The hash is coreutils `sha256sum`, which equals `write::sha256_hex`
//! of the bytes whenever the read was not truncated — the only case in which the
//! editor uses a hash as its save base.
use serde::Serialize;
use crate::host::ContainerTarget;
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct ViewerPoll {
pub exists: bool,
pub hash: Option<String>,
pub size: Option<u64>,
}
/// Exit 4 = gone. A failure after `test -f` passed is re-checked: if the file vanished
/// in between (deleted while being hashed), that is "gone", not an error (M6).
pub const POLL_SCRIPT: &str = r#"test -f "$1" || exit 4
sha256sum -- "$1" && stat -c %s -- "$1" && exit 0
test -f "$1" || exit 4
exit 1"#;
pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll {
if code == 4 {
return ViewerPoll { exists: false, hash: None, size: None };
}
let mut lines = stdout.lines();
let hash = lines
.next()
.and_then(|l| l.split_whitespace().next())
// GNU `sha256sum` prefixes the line with `\` when the name contains a
// backslash or a newline; strip it before validating the hex (P15).
.map(|h| h.trim_start_matches('\\'))
.filter(|h| super::write::is_sha256_hex(h))
.map(str::to_string);
let size = lines.next().and_then(|l| l.trim().parse::<u64>().ok());
ViewerPoll { exists: true, hash, size }
}
pub async fn poll_file(
container: &ContainerTarget,
container_path: &str,
) -> Result<ViewerPoll, String> {
let cmd = vec![
"sh".to_string(),
"-c".to_string(),
POLL_SCRIPT.to_string(),
"poll".to_string(),
container_path.to_string(),
];
// A background poll: an open viewer must not keep an idle AWS workspace
// up. Opening and saving a file are the user's own actions and count.
let (stdout, stderr, code) = container
.exec_oneshot_streams_background("claude", cmd)
.await?;
if code != 0 && code != 4 {
return Err(format!(
"Could not check the file: {}",
crate::commands::file_commands::clip_container_text(&stderr)
));
}
Ok(parse_poll_output(code, &stdout))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_present_file_yields_hash_and_size() {
let out = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /workspace/x\n42\n";
assert_eq!(
parse_poll_output(0, out),
ViewerPoll {
exists: true,
hash: Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".into()),
size: Some(42)
}
);
}
#[test]
fn exit_four_means_gone() {
assert_eq!(parse_poll_output(4, ""), ViewerPoll { exists: false, hash: None, size: None });
}
#[test]
fn garbage_is_not_a_hash() {
let p = parse_poll_output(0, "not a hash /x\nabc\n");
assert_eq!(p, ViewerPoll { exists: true, hash: None, size: None });
}
#[test]
fn the_script_tests_existence_before_hashing() {
assert!(POLL_SCRIPT.contains("test -f \"$1\" || exit 4"));
assert!(POLL_SCRIPT.contains("sha256sum -- \"$1\""));
assert!(POLL_SCRIPT.contains("stat -c %s -- \"$1\""));
}
#[cfg(unix)]
fn run_poll_script(path_env: Option<&str>, target: &std::path::Path) -> (i64, String, String) {
let mut cmd = std::process::Command::new("sh");
if let Some(p) = path_env {
cmd.env("PATH", p);
}
let out = cmd.arg("-c").arg(POLL_SCRIPT).arg("poll").arg(target).output().unwrap();
(
out.status.code().unwrap_or(-1) as i64,
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[cfg(unix)]
fn test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-poll-{}-{}", name, uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(unix)]
#[test]
fn on_the_host_the_poll_script_reports_hash_size_and_gone() {
let dir = test_dir("plain");
let target = dir.join("t.txt");
std::fs::write(&target, b"hello\n").unwrap();
let (code, stdout, stderr) = run_poll_script(None, &target);
assert_eq!(code, 0, "stderr={stderr}");
let p = parse_poll_output(code, &stdout);
assert_eq!(p.hash.as_deref(), Some(super::super::write::sha256_hex(b"hello\n").as_str()));
assert_eq!(p.size, Some(6));
let (code, _, _) = run_poll_script(None, &dir.join("missing"));
assert_eq!(code, 4);
let _ = std::fs::remove_dir_all(&dir);
}
/// M6: the file is deleted after `test -f` passed but before `sha256sum` read it
/// (a `sha256sum` shim on PATH deletes it and fails). That is "gone", not an error
/// the viewer would have to explain.
#[cfg(unix)]
#[test]
fn on_the_host_a_file_deleted_mid_poll_reads_as_gone() {
use std::os::unix::fs::PermissionsExt;
let dir = test_dir("race");
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let shim = bin.join("sha256sum");
std::fs::write(&shim, "#!/bin/sh\nrm -f -- \"$2\"\necho 'sha256sum: No such file or directory' >&2\nexit 1\n").unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.join("t.txt");
std::fs::write(&target, b"x").unwrap();
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
let (code, stdout, stderr) = run_poll_script(Some(&path), &target);
assert_eq!(code, 4, "stderr={stderr}");
assert_eq!(parse_poll_output(code, &stdout), ViewerPoll { exists: false, hash: None, size: None });
let _ = std::fs::remove_dir_all(&dir);
}
/// A failure with the file still present stays a real error (exit 1), which
/// `poll_file` turns into "Could not check the file: …".
#[cfg(unix)]
#[test]
fn on_the_host_a_hash_failure_on_a_present_file_is_an_error() {
use std::os::unix::fs::PermissionsExt;
let dir = test_dir("fail");
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let shim = bin.join("sha256sum");
std::fs::write(&shim, "#!/bin/sh\necho 'sha256sum: Permission denied' >&2\nexit 1\n").unwrap();
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = dir.join("t.txt");
std::fs::write(&target, b"x").unwrap();
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default());
let (code, _stdout, stderr) = run_poll_script(Some(&path), &target);
assert_eq!(code, 1, "stderr={stderr}");
assert!(stderr.contains("Permission denied"));
let _ = std::fs::remove_dir_all(&dir);
}
/// P15: a path containing a backslash makes GNU `sha256sum` prefix the whole
/// line with `\`; that must not blind change detection by yielding `hash: None`.
#[test]
fn a_backslash_prefixed_hash_is_still_recognised() {
let out = "\\e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /workspace/x\\y\n7\n";
let p = parse_poll_output(0, out);
assert_eq!(
p.hash.as_deref(),
Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
);
assert_eq!(p.size, Some(7));
}
}
+379
View File
@@ -0,0 +1,379 @@
//! Which viewer window is looking at what.
//!
//! Managed with `app.manage(ViewerRegistry::default())` rather than as a field on
//! `AppState`, like the browser view keeps its own state. A label is reserved *before*
//! the window is built so two concurrent clicks cannot both pass the cap check.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use super::{MAX_VIEWER_WINDOWS, VIEWER_LABEL_PREFIX};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Location {
pub line: Option<u32>,
pub col: Option<u32>,
pub end_line: Option<u32>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ViewerTargetState {
Resolved { container_path: String },
Choose { candidates: Vec<String> },
NotFound { tried: Vec<String> },
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct ViewerTarget {
pub project_id: String,
pub project_name: String,
pub raw_path: String,
pub state: ViewerTargetState,
pub initial: Location,
}
/// What [`ViewerRegistry::reserve`] decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Reservation {
/// A window is already registered on this file. `built` is false while that
/// window is still being created: it has no `WebviewWindow` to focus yet, and
/// it will open at its own location, so the caller should simply return.
Existing { label: String, built: bool },
/// A new label, registered and counted against the cap; build its window,
/// then call [`ViewerRegistry::mark_built`] (or `remove` if building failed).
Reserved(String),
}
/// What [`ViewerRegistry::choose`] decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Choice {
/// The caller's entry now points at the chosen file.
Resolved(ViewerTarget),
/// Another window already has that file; the caller's entry is unchanged.
AlreadyOpen { label: String, built: bool },
}
#[derive(Clone, Debug)]
struct Entry {
target: ViewerTarget,
/// Set once the window's `build()` has returned. Until then the label has no
/// window by design, so "registered but windowless" means "being built", not
/// "stale" — only built entries are ever pruned.
built: bool,
}
#[derive(Default)]
pub struct ViewerRegistry {
entries: Mutex<HashMap<String, Entry>>,
next: AtomicU64,
}
fn same_file(t: &ViewerTarget, project_id: &str, container_path: &str) -> bool {
t.project_id == project_id
&& matches!(&t.state, ViewerTargetState::Resolved { container_path: p } if p == container_path)
}
fn open_on(
entries: &HashMap<String, Entry>,
project_id: &str,
container_path: &str,
except: Option<&str>,
) -> Option<(String, bool)> {
entries
.iter()
.find(|(label, e)| Some(label.as_str()) != except && same_file(&e.target, project_id, container_path))
.map(|(label, e)| (label.clone(), e.built))
}
/// Drops built entries whose window is gone, whatever their state. `Destroyed`
/// normally removes an entry; this is the backstop for one it missed, so a leak
/// can never hold a cap slot for good.
fn prune(entries: &mut HashMap<String, Entry>, is_live: &dyn Fn(&str) -> bool) {
entries.retain(|label, e| !e.built || is_live(label));
}
impl ViewerRegistry {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
self.entries.lock().unwrap_or_else(|e| e.into_inner())
}
/// Finds the window already open on a resolved target, or reserves a label,
/// in one critical section, after pruning built entries `is_live` says are
/// gone. `is_live` runs under the registry lock and must not call back into
/// the registry.
pub fn reserve(
&self,
target: ViewerTarget,
is_live: impl Fn(&str) -> bool,
) -> Result<Reservation, String> {
let mut entries = self.lock();
prune(&mut entries, &is_live);
if let ViewerTargetState::Resolved { container_path } = &target.state {
if let Some((label, built)) = open_on(&entries, &target.project_id, container_path, None) {
return Ok(Reservation::Existing { label, built });
}
}
if entries.len() >= MAX_VIEWER_WINDOWS {
return Err(format!(
"{} file windows are already open — close one before opening another.",
MAX_VIEWER_WINDOWS
));
}
let n = self.next.fetch_add(1, Ordering::SeqCst) + 1;
let label = format!("{}{}", VIEWER_LABEL_PREFIX, n);
entries.insert(label.clone(), Entry { target, built: false });
Ok(Reservation::Reserved(label))
}
/// Records that `label`'s window exists. A no-op if it was already removed
/// (a window destroyed the moment it appeared).
pub fn mark_built(&self, label: &str) {
if let Some(e) = self.lock().get_mut(label) {
e.built = true;
}
}
/// Points `label`'s entry at `container_path`, unless another window already
/// has that file open — then the entry is left alone, so no two entries are
/// ever resolved to the same file.
pub fn choose(
&self,
label: &str,
container_path: String,
is_live: impl Fn(&str) -> bool,
) -> Result<Choice, String> {
let mut entries = self.lock();
prune(&mut entries, &is_live);
let project_id = entries
.get(label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?
.target
.project_id
.clone();
if let Some((other, built)) = open_on(&entries, &project_id, &container_path, Some(label)) {
return Ok(Choice::AlreadyOpen { label: other, built });
}
let entry = entries.get_mut(label).expect("checked above under the same lock");
entry.target.state = ViewerTargetState::Resolved { container_path };
Ok(Choice::Resolved(entry.target.clone()))
}
pub fn get(&self, label: &str) -> Option<ViewerTarget> {
self.lock().get(label).map(|e| e.target.clone())
}
pub fn set_state(&self, label: &str, state: ViewerTargetState) -> Result<ViewerTarget, String> {
let mut entries = self.lock();
let entry = entries
.get_mut(label)
.ok_or_else(|| "This file window is no longer registered.".to_string())?;
entry.target.state = state;
Ok(entry.target.clone())
}
pub fn remove(&self, label: &str) {
self.lock().remove(label);
}
pub fn find_open(&self, project_id: &str, container_path: &str) -> Option<String> {
open_on(&self.lock(), project_id, container_path, None).map(|(label, _)| label)
}
pub fn len(&self) -> usize {
self.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target(project: &str, path: &str) -> ViewerTarget {
ViewerTarget {
project_id: project.into(),
project_name: "Demo".into(),
raw_path: path.into(),
state: ViewerTargetState::Resolved { container_path: path.into() },
initial: Location { line: Some(3), col: None, end_line: None },
}
}
fn all_live(_: &str) -> bool {
true
}
/// Reserves a label that must be new.
fn fresh(r: &ViewerRegistry, t: ViewerTarget) -> String {
match r.reserve(t, all_live).unwrap() {
Reservation::Reserved(label) => label,
other => panic!("expected a new label, got {:?}", other),
}
}
fn choosing(project: &str, candidates: &[&str]) -> ViewerTarget {
ViewerTarget {
state: ViewerTargetState::Choose { candidates: candidates.iter().map(|c| c.to_string()).collect() },
..target(project, "a")
}
}
#[test]
fn labels_are_sequential_and_never_reused() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
let b = fresh(&r, target("p", "/workspace/b"));
assert_eq!(a, "file-viewer-1");
assert_eq!(b, "file-viewer-2");
r.remove(&a);
let c = fresh(&r, target("p", "/workspace/c"));
assert_eq!(c, "file-viewer-3");
assert_eq!(r.len(), 2);
}
#[test]
fn the_cap_refuses_the_twenty_first_window() {
let r = ViewerRegistry::default();
for i in 0..MAX_VIEWER_WINDOWS {
fresh(&r, target("p", &format!("/workspace/{}", i)));
}
let err = r.reserve(target("p", "/workspace/one-more"), all_live).unwrap_err();
assert!(err.contains("20"), "{}", err);
assert_eq!(r.len(), MAX_VIEWER_WINDOWS);
}
#[test]
fn an_open_resolved_file_is_found_by_project_and_path() {
let r = ViewerRegistry::default();
let label = fresh(&r, target("p", "/workspace/a"));
assert_eq!(r.find_open("p", "/workspace/a"), Some(label.clone()));
assert_eq!(r.find_open("other", "/workspace/a"), None);
// A window still choosing is not "open on" any path.
r.set_state(&label, ViewerTargetState::Choose { candidates: vec!["/workspace/a".into()] }).unwrap();
assert_eq!(r.find_open("p", "/workspace/a"), None);
r.remove(&label);
assert_eq!(r.get(&label), None);
}
#[test]
fn set_state_on_an_unknown_label_is_an_error() {
let r = ViewerRegistry::default();
assert!(r.set_state("file-viewer-9", ViewerTargetState::NotFound { tried: vec![] }).is_err());
}
#[test]
fn target_state_serialises_with_a_kind_tag() {
let s = serde_json::to_string(&ViewerTargetState::NotFound { tried: vec!["/x".into()] }).unwrap();
assert_eq!(s, r#"{"kind":"not_found","tried":["/x"]}"#);
}
/// I1: a second click while the first window is still being built must find
/// that window, not read it as stale and reserve a second one.
#[test]
fn a_window_being_built_is_found_not_replaced() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
// No window exists yet for `a`: `is_live` says so, and it must not matter.
let second = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
assert_eq!(second, Reservation::Existing { label: a.clone(), built: false });
assert!(r.get(&a).is_some());
assert_eq!(r.len(), 1);
r.mark_built(&a);
let third = r.reserve(target("p", "/workspace/a"), all_live).unwrap();
assert_eq!(third, Reservation::Existing { label: a, built: true });
assert_eq!(r.len(), 1);
}
/// A built entry whose window is gone is stale: pruned, and the file reopens.
#[test]
fn a_built_entry_without_a_window_is_pruned_and_the_file_reopens() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
r.mark_built(&a);
let again = r.reserve(target("p", "/workspace/a"), |_| false).unwrap();
assert_eq!(again, Reservation::Reserved("file-viewer-2".into()));
assert_eq!(r.get(&a), None);
assert_eq!(r.len(), 1);
}
/// M2: a leaked entry of any state cannot hold a cap slot once built and gone,
/// and an entry still being built always keeps its slot.
#[test]
fn leaked_entries_of_every_state_free_their_cap_slot() {
let r = ViewerRegistry::default();
let mut labels = Vec::new();
for i in 0..MAX_VIEWER_WINDOWS {
let t = match i % 3 {
0 => target("p", &format!("/workspace/{}", i)),
1 => choosing("p", &["/workspace/x", "/workspace/y"]),
_ => ViewerTarget { state: ViewerTargetState::NotFound { tried: vec![] }, ..target("p", "z") },
};
labels.push(fresh(&r, t));
}
// All still being built: none may be pruned, so the cap holds.
assert!(r.reserve(target("p", "/workspace/new"), |_| false).is_err());
for l in &labels {
r.mark_built(l);
}
// Built, and one of each state has lost its window.
let dead = [labels[0].clone(), labels[1].clone(), labels[2].clone()];
let live = |l: &str| !dead.iter().any(|d| d == l);
assert!(matches!(r.reserve(target("p", "/workspace/new"), live), Ok(Reservation::Reserved(_))));
assert_eq!(r.len(), MAX_VIEWER_WINDOWS - 2);
for d in &dead {
assert_eq!(r.get(d), None);
}
}
#[test]
fn mark_built_on_a_removed_label_is_a_no_op() {
let r = ViewerRegistry::default();
let a = fresh(&r, target("p", "/workspace/a"));
r.remove(&a);
r.mark_built(&a);
assert_eq!(r.get(&a), None);
}
/// M5: choosing a file another window already has leaves the chooser alone,
/// so two entries are never resolved to the same file.
#[test]
fn choosing_a_file_open_elsewhere_does_not_resolve_a_second_entry() {
let r = ViewerRegistry::default();
let open = fresh(&r, target("p", "/workspace/x"));
r.mark_built(&open);
let chooser = fresh(&r, choosing("p", &["/workspace/x", "/workspace/y"]));
r.mark_built(&chooser);
let c = r.choose(&chooser, "/workspace/x".into(), all_live).unwrap();
assert_eq!(c, Choice::AlreadyOpen { label: open.clone(), built: true });
assert!(matches!(r.get(&chooser).unwrap().state, ViewerTargetState::Choose { .. }));
match r.choose(&chooser, "/workspace/y".into(), all_live).unwrap() {
Choice::Resolved(t) => assert_eq!(t.state, ViewerTargetState::Resolved { container_path: "/workspace/y".into() }),
other => panic!("expected Resolved, got {:?}", other),
}
assert_eq!(r.find_open("p", "/workspace/y"), Some(chooser));
}
#[test]
fn choosing_the_same_path_in_another_project_is_not_a_duplicate() {
let r = ViewerRegistry::default();
fresh(&r, target("other", "/workspace/x"));
let chooser = fresh(&r, choosing("p", &["/workspace/x"]));
assert!(matches!(r.choose(&chooser, "/workspace/x".into(), all_live), Ok(Choice::Resolved(_))));
}
#[test]
fn choose_on_an_unknown_label_is_an_error() {
let r = ViewerRegistry::default();
assert!(r.choose("file-viewer-9", "/workspace/x".into(), all_live).is_err());
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Turning what Claude printed into a container path that exists.
//!
//! Relative paths are the common case (Claude prints project-relative paths). The
//! terminal exec's cwd is `/workspace`, and each project path is mounted at
//! `/workspace/<mount_name>`, so those are the roots probed, in that order.
//! A relative path none of them has is then tried under `/home/claude` and `/tmp`
//! ([`fallback_candidate_paths`]); `~/p` is `/home/claude/p`. The probe
//! is one exec as the container user and prints `realpath -e` of every candidate that
//! is a regular file: `fetch_container_file` refuses a symlink, so the registry must
//! hold the resolved path, not the one that was clicked.
use crate::commands::file_commands::validate_container_path;
use crate::docker::CONTAINER_HOME;
use crate::host::ContainerTarget;
pub const MAX_CANDIDATES: usize = 16;
const MAX_RAW_LEN: usize = 4096;
/// `$@` are the candidates. For each regular file, print its resolved path.
pub const PROBE_SCRIPT: &str = r#"for c in "$@"; do if test -f "$c"; then realpath -e -- "$c" 2>/dev/null; fi; done; exit 0"#;
pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>, String> {
if raw.is_empty() {
return Err("The path is empty.".into());
}
if raw.len() > MAX_RAW_LEN {
return Err("The path is too long.".into());
}
if raw.contains('\0') {
return Err("The path contains a NUL byte.".into());
}
if raw.split('/').any(|seg| seg == "..") {
return Err(format!("{} climbs out of its folder with `..`; refusing.", raw));
}
if raw.starts_with('/') {
let normalised = collapse(raw);
validate_container_path("File", &normalised)?;
return Ok(vec![normalised]);
}
// Claude Code prints paths under $HOME as `~/...`. Only the bare `~/` form:
// `~user/` names some other account's home and stays an ordinary name.
if let Some(rest) = raw.strip_prefix("~/") {
let rest = collapse(rest);
if rest.is_empty() {
return Err("The path is empty.".into());
}
let p = format!("{}/{}", CONTAINER_HOME, rest);
validate_container_path("File", &p)?;
return Ok(vec![p]);
}
let rel = relative_part(raw).ok_or_else(|| "The path is empty.".to_string())?;
let mut out: Vec<String> = Vec::new();
let mut push = |candidate: String| {
if out.len() < MAX_CANDIDATES && !out.contains(&candidate) {
out.push(candidate);
}
};
push(format!("/workspace/{}", rel));
for mount in mount_names {
if mount.is_empty() || mount.contains('/') || mount == "." || mount == ".." {
continue;
}
push(format!("/workspace/{}/{}", mount, rel));
}
for c in &out {
validate_container_path("File", c)?;
}
Ok(out)
}
/// Where a relative path is looked for when no project root has it, in order.
/// The container user's home, then `/tmp` (drops, pastes and scratch files).
/// Probed only after [`candidate_paths`] found nothing, so a project file never
/// turns into a "which one?" chooser because a same-named file sits in `/tmp`.
const FALLBACK_ROOTS: &[&str] = &[CONTAINER_HOME, "/tmp"];
/// The second tier for a relative path: [`FALLBACK_ROOTS`]`/<p>`. Empty for an
/// absolute or `~/` path (those name one place already) and for anything
/// [`candidate_paths`] refuses — it is always called after that succeeded, but
/// must not be a way around its checks.
pub fn fallback_candidate_paths(raw: &str) -> Vec<String> {
if raw.starts_with('/') || raw.starts_with("~/") || candidate_paths(raw, &[]).is_err() {
return Vec::new();
}
let Some(rel) = relative_part(raw) else { return Vec::new() };
FALLBACK_ROOTS
.iter()
.map(|root| format!("{}/{}", root, rel))
.filter(|c| validate_container_path("File", c).is_ok())
.take(MAX_CANDIDATES)
.collect()
}
/// `./a//b` → `a/b`; `None` when nothing is left.
fn relative_part(raw: &str) -> Option<String> {
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
let rel = rel.trim_start_matches("./");
(!rel.is_empty()).then(|| rel.to_string())
}
/// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs).
fn collapse(path: &str) -> String {
let absolute = path.starts_with('/');
let joined = path
.split('/')
.filter(|seg| !seg.is_empty() && *seg != ".")
.collect::<Vec<_>>()
.join("/");
if absolute { format!("/{}", joined) } else { joined }
}
/// One resolved path per line; anything that is not an absolute, valid container path is
/// dropped (the script's own diagnostics go to stderr, but a hostile `realpath` output is
/// still container-authored text).
pub fn parse_probe_output(stdout: &str) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() || validate_container_path("File", line).is_err() {
continue;
}
if !seen.iter().any(|s| s == line) {
seen.push(line.to_string());
}
}
seen
}
pub async fn probe_candidates(
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) = container
.exec_oneshot_streams_as("claude", cmd, Vec::new())
.await?;
Ok(parse_probe_output(&stdout))
}
#[cfg(test)]
mod tests {
use super::*;
fn mounts(names: &[&str]) -> Vec<String> {
names.iter().map(|s| s.to_string()).collect()
}
#[test]
fn an_absolute_path_is_its_own_only_candidate() {
let c = candidate_paths("/workspace/api/src/main.rs", &mounts(&["api"])).unwrap();
assert_eq!(c, vec!["/workspace/api/src/main.rs"]);
}
#[test]
fn a_relative_path_probes_workspace_then_each_mount() {
let c = candidate_paths("src/main.rs", &mounts(&["api", "web"])).unwrap();
assert_eq!(
c,
vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
);
}
#[test]
fn dot_prefix_and_duplicate_slashes_are_normalised_and_candidates_deduped() {
let c = candidate_paths("./src//main.rs", &mounts(&["api", "api", ""])).unwrap();
assert_eq!(c, vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs"]);
}
#[test]
fn an_absolute_tmp_path_is_accepted_as_is() {
// Claude Code's scratchpad: long, and with a segment that starts with `-`.
let p = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
assert_eq!(candidate_paths(p, &mounts(&["api"])).unwrap(), vec![p]);
}
#[test]
fn a_home_relative_path_is_the_container_users_home() {
// Claude Code prints paths under $HOME as `~/...`.
let c = candidate_paths("~/.claude/plans/plan.md", &mounts(&["api"])).unwrap();
assert_eq!(c, vec!["/home/claude/.claude/plans/plan.md"]);
assert!(candidate_paths("~/", &[]).is_err());
assert!(candidate_paths("~/../etc/passwd", &[]).is_err());
// `~user/` is not expanded; it stays an ordinary relative name.
assert_eq!(candidate_paths("~bob/x.md", &[]).unwrap(), vec!["/workspace/~bob/x.md"]);
}
#[test]
fn a_relative_path_falls_back_to_home_and_tmp() {
assert_eq!(
fallback_candidate_paths("scratch/notes.md"),
vec!["/home/claude/scratch/notes.md", "/tmp/scratch/notes.md"]
);
assert_eq!(fallback_candidate_paths("./notes.md"), vec!["/home/claude/notes.md", "/tmp/notes.md"]);
// Absolute, home-relative and refused paths have no fallbacks.
assert!(fallback_candidate_paths("/tmp/notes.md").is_empty());
assert!(fallback_candidate_paths("~/notes.md").is_empty());
assert!(fallback_candidate_paths("../notes.md").is_empty());
assert!(fallback_candidate_paths("").is_empty());
}
#[test]
fn traversal_nul_and_oversize_are_refused() {
assert!(candidate_paths("../etc/passwd", &[]).is_err());
assert!(candidate_paths("src/../../x", &[]).is_err());
assert!(candidate_paths("/workspace/../etc/passwd", &[]).is_err());
assert!(candidate_paths("a\0b", &[]).is_err());
assert!(candidate_paths("", &[]).is_err());
assert!(candidate_paths(&"a".repeat(5000), &[]).is_err());
}
#[test]
fn candidate_list_is_capped() {
let many: Vec<String> = (0..40).map(|i| format!("m{}", i)).collect();
let c = candidate_paths("x.rs", &many).unwrap();
assert_eq!(c.len(), MAX_CANDIDATES);
}
#[test]
fn probe_output_keeps_valid_resolved_regular_files_only() {
let out = "/workspace/api/src/main.rs\n/workspace/api/src/main.rs\n\nrelative/junk\n/etc/../x\n/workspace/web/src/main.rs\n";
assert_eq!(
parse_probe_output(out),
vec!["/workspace/api/src/main.rs", "/workspace/web/src/main.rs"]
);
}
#[test]
fn the_probe_script_prints_resolved_paths_of_regular_files() {
// Shape assertions: the script is data handed to `sh -c`, and these are the
// three things a later edit must not lose.
assert!(PROBE_SCRIPT.contains("test -f"));
assert!(PROBE_SCRIPT.contains("realpath -e --"));
assert!(PROBE_SCRIPT.contains("for c in \"$@\""));
}
}
+27
View File
@@ -0,0 +1,27 @@
//! The viewer window itself. Mirrors `browser_view/popout.rs`, with two differences:
//! the URL is the app's own second entry (`WebviewUrl::App`), so the capability in
//! `capabilities/file-viewer.json` applies; and the registry entry is removed on
//! `Destroyed`, which fires for both the X button (after JS calls `destroy()`) and a
//! Rust-side `destroy()`.
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
use super::registry::ViewerRegistry;
pub fn open_viewer_window(app: &AppHandle, label: &str, title: &str) -> Result<(), String> {
let window = WebviewWindowBuilder::new(app, label, WebviewUrl::App("viewer.html".into()))
.title(title)
.inner_size(900.0, 700.0)
.min_inner_size(480.0, 320.0)
.build()
.map_err(|e| format!("Could not open the file window: {}", e))?;
let app_for_event = app.clone();
let label_owned = label.to_string();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
app_for_event.state::<ViewerRegistry>().remove(&label_owned);
}
});
Ok(())
}
Loaded 100 of 369 files, more files were not shown because too many files have changed in this diff. Show more