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>
- 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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
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>
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>
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>
- 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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
`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>
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>
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>
Tracked build output; regenerated by the Tauri build from
capabilities/default.json.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
`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>
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>
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>
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
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
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
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
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
`build-linux` fails before `tauri build` runs, on every workflow, at "Install
frontend dependencies":
npm error Cannot read properties of null (reading 'edgesOut')
Reproduced exactly on the first attempt by running the step's own commands
locally on the same Node 22.23.2 the runner installs. The debug log gives the
frame the CI output omits:
at #loadPeerSet (.../@npmcli/arborist/lib/arborist/build-ideal-tree.js:1289:38)
It is a null dereference in npm 10.9.8's peer-set resolver, reached through
vite → @vitejs/devtools → @vitejs/devtools-vitest → vitest@* →
@vitest/browser-playwright → vitest@4.1.11 → jsdom@* → canvas.
**Nothing in this repo changed to cause it.** The step deleted
`package-lock.json` before installing, so every build re-resolved the entire
tree against the registry against ranges like `vitest@*`. A dependency
published a version that produces a peer graph npm cannot resolve, and our CI
broke — the same command succeeded fifteen hours earlier for 0.4.21. That is
the real defect: the build was never reproducible, and the crash is only how we
found out.
So Linux installs with `npm ci`, from the committed lockfile, like Windows
already did. macOS moves too — it kept the lockfile but still ran `npm
install`, which is free to re-resolve; all three platforms now install
identically and none can re-resolve mid-release.
**The reason the lockfile was being deleted is obsolete, not ignored.** 2d4fce9
removed it "to ensure correct platform-specific bindings", which was a real
problem once. The committed lockfile now records 25 rollup platform variants,
and `npm ci` on Linux installs precisely rollup-linux-x64-{gnu,musl} and
@esbuild/linux-x64 — checked directly. A comment on the step says so, and says
not to reach for deleting the lockfile again: if `npm ci` refuses, package.json
and the lockfile have genuinely diverged and the fix is to commit an updated
lockfile.
Verified from the resulting tree: `tsc --noEmit` clean, `npm run build`
successful, 752 tests across 62 files passing. The `npx tauri --version ||
npm install @tauri-apps/cli` fallback in the next step cannot reintroduce a
fresh resolution — the CLI is a pinned devDependency that `npm ci` installs, so
the fallback is unreachable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Second review of this branch. Two blockers and one real defect I had papered
over with a true-but-misleading claim.
**`make_latest` was missing from the republish path.** The create path sends
`"make_latest": "false"` so the channel cannot displace the versioned release
on the releases page. The reuse path — taken on every run after the first —
omitted it, and the API's documented default for a publish transition is
`true`. So the second release would have quietly promoted `linux-latest` to
the repository's Latest release: a release whose own body says "for a specific
version, use the versioned releases instead". Now sent on both paths.
`tag_name` is re-sent deliberately and now says so in a comment — the API
removes the tag when a PATCH omits it, and this branch exists because a tag
disappeared.
**A transient Gitea error would have cost the whole release.** `curl -sf`
fails identically for "404, the tag is genuinely absent" and "503, Gitea is
briefly unreachable", and both landed in the create branch. Creating a tag that
already exists returns 409, which aborted the last step of `build-linux` — and
`create-tag` and `sync-to-github` both depend on it, so no version tag and no
GitHub sync at all. The failure message also read "the tag does not exist" when
Gitea had merely been unreachable. Now a `case` on the HTTP code — 200 leave
alone, 404 create, anything else fail loudly with the real code — the same
idiom `Upload to Gitea release` already uses two steps above. `422
already_exists` on the release POST is likewise a recoverable answer, not a
reason to lose a release.
**The empty `Categories=` was still shipping, and my claim hid it.** I wrote
that the guard "asserts the absence of an empty value rather than the presence
of any filled one" — true of the regex, false of the artifact. The AppDir root
`.desktop` is a *symlink* into usr/share/applications, so `sed -i` replaced the
link with a regular file and left the real entry empty; the guard globbed the
root only, so it saw the copy it had just written and passed. Verified on the
real artifact: two divergent entries, and the one that shipped was empty. Fixed
with `--follow-symlinks`, both locations globbed, and the guard turned into a
positive assertion over every entry — which also closes its missing-key and
unmatched-glob holes. Both entries now read `Categories=Development;Utility;`.
Also taken: the duplicate-AppImage check moves to a precondition, since as a
post-mortem it let the script repack and overwrite the versioned artifact
before failing, and it silently selected by glob order, i.e. the older version
— it now refuses in under a second; assets are deleted and re-uploaded one at
a time, because deleting both up front left a fresh AppImage with no .zsync if
the second upload failed, which silently stops every client; and the success
line no longer claims a fallback was kept when there was nothing to demote.
Left as informational, with the reasoning recorded rather than acted on:
`--retry-all-errors` retries permanent 4xx (fail-closed, matches the repo's
other upload steps); the release list is unpaginated (a GraphQL lookup by
pending tag name is the durable fix, but 7 releases is decades from the cliff,
and the 422 handling above covers the failure mode); process-substitution
failure is invisible to `mapfile` (fail-closed downstream).
Verified against the real 0.4.19 artifact — happy path, no AppImage, two
AppImages, and an AppDir rebuilt with the bundled library removed. shellcheck
clean at warning level on both scripts. appimagetool now reports the AppStream
metadata found.
Nothing here is CI-proven, and that is worth stating plainly: `build-linux`
fails on this branch before `tauri build` even runs, at "Install frontend
dependencies" with `npm error Cannot read properties of null (reading
'edgesOut')` — confirmed in the logs of jobs 5644 and 5636. Unrelated to this
change and tracked separately, but it means the finalizer has never executed
in CI on either commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
An adversarial review of the previous commit found six real problems and
corrected one of my claims. Taking all of it.
**The anchoring could kill the channel it exists to protect.** It did
DELETE-then-POST so the tag would name the current build. If the POST failed
for any transient reason the script aborted having already deleted the anchor a
previous run put there, and the next mirror run pruned GitHub's copy — a
transient Gitea error converting a healthy channel into a dead one, which is
strictly worse than the step not existing. There was also a real window
between the two calls with no tag at all.
The DELETE bought nothing. The update string resolves the tag by *name* and the
assets hang off the release object, so nothing about the channel depends on
which commit the tag points at; moving it changes only the source-zip link.
It existed solely to get past a 409, since Gitea's POST /tags has no force
semantics. Now the tag is created if absent and otherwise left alone, which
removes the window too.
**My "no window where the two disagree" claim was wrong, and it is the third
time in this area I have asserted something I had not established.** The
release POST sets no `target_commitish`, so GitHub creates its tag at its own
default-branch HEAD, not at `GITEA_SHA`; the two agree only because
`sync_on_commit` pushes main minutes earlier. And the DELETE actively created
the window. What the ordering genuinely buys is narrower: if anchoring fails,
the script aborts before creating a GitHub release that would be orphaned.
**Orphaned drafts were invisible to the release lookup.** GitHub demotes a
release to a draft when its tag is deleted, and `/releases/tags/` never returns
drafts — precisely the state every mirror run left behind. The by-tag lookup
reported "absent" while 86 MB drafts accumulated, one per release. The lookup
now reads the authenticated list, republishes the newest, and deletes the rest.
**A guard that could not catch what it named.** The update-info assertion was
a substring match on the tag, so it passed for a wrong host, path, filename or
transport — verified: an `evil.example.com/.../linux-latest/...` string passes
the old check and fails the new one. Now a fixed full-string match.
Also from the review: an absent bundled library no longer exits early, because
that skipped the metadata *and* left `update-channel/` uncreated, killing the
publish step on a missing directory and taking the tag and mirror jobs with it;
the Categories guard asserts the absence of an empty value rather than the
presence of any filled one; the channel directory is cleared before use so a
stale zsync cannot satisfy an existence check while describing the previous
build; the AppImage count uses a glob array, since `ls | wc -l` aborted under
pipefail before the message it promised could print; uploads carry the
retry/http1.1 hardening this repo's other upload steps already learned to
need; verification compares served size against built size, because a status
code only proves something is served; and the release workflow now fails on
empty artifacts instead of publishing a release with no AppImage.
The metainfo file is installed as `Triple-C.appdata.xml`. appimagetool derives
the name it looks for from the .desktop basename, so under the id-based name it
warned the metadata was missing on every build while this script reported it
present. Now it prints "AppStream upstream metadata found in
usr/share/metainfo/Triple-C.appdata.xml" — the AppStream id inside the file is
unchanged and is what identifies the component.
Two review hypotheses did not hold and nothing was changed for them: `set -e`
does not abort on a failing `&&` list mid-script, and my claim of a `trap`
reassignment was wrong — there is one trap, installed once.
Verified against the real 0.4.19 artifact: exit 0, one AppImage beside the
release, channel pair in its own directory, appimagetool reporting the metadata
found, and the wayland fallback intact. Guards exercised individually — the
duplicate one bites, the exact-match one rejects an impostor carrying the tag,
the empty directory reports cleanly, and all four publisher preconditions
refuse rather than half-publishing. Header parsing for the size check was
tested against a real redirecting GitHub asset URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Two defects in the update channel, both visible in 0.4.20 and 0.4.21.
**The channel tag does not survive.** `publish-update-channel.sh` created the
GitHub release, uploaded both assets and verified each URL returned 200 — the
job log shows it succeeding at 00:38. By 13:04 the tag was gone and every
installed copy was checking a 404.
Gitea push-mirrors this repo to GitHub every four hours, and a mirror push
deletes remote refs with no local counterpart. `linux-latest` was created by
GitHub's release API and never existed as a Gitea tag, so the mirror removed
it. Versioned tags were never affected because `create-tag` creates them in
Gitea first.
So the tag is now anchored in Gitea, and before the GitHub release rather than
after, so there is no window where the two disagree. Its absence fails the
step instead of warning, because it is the only thing keeping the channel
alive. Worth stating plainly: publishing correctly is not evidence the channel
still works, and the verification that passed at 00:38 could not have caught a
failure that arrives twelve hours later.
**Every release carried the AppImage twice.** The channel's stable-named copy
sat beside the versioned one, where the release job's `*.AppImage` glob picked
it up — so v0.4.21 published `Triple-C_0.4.21_amd64.AppImage` and
`Triple-C_x86_64.AppImage`, byte-identical at 86,686,200 bytes each, and
`sync-to-github` copied both to the mirror. 80 MB of duplicate per release,
under a name that reads like a different build. That is how it was noticed.
The channel pair now lives in `bundle/appimage/update-channel/`, out of the
glob's reach, and a guard fails the build if more than one AppImage is left
beside the release. Verified by planting a second one: it fails.
One appimagetool quirk found while moving it — zsyncmake writes the .zsync
into the working directory, not beside the image it describes, so it has to be
collected rather than assumed in place. The existing guard caught that too.
Verified against the real 0.4.19 artifact: exactly one AppImage at top level,
the channel pair in its own directory, update string still resolving to the
fixed tag, and the wayland fallback intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
An AppImage manager can adopt the current build but never update it: the image
carries no update information, which is the string that tells such a tool where
to look for a newer one. It also carries no AppStream metadata, so a manager
has nothing to show but a filename — appimagetool has been warning about that
on every build — and linuxdeploy leaves `Categories=` empty, which files the
app nowhere in a desktop menu.
All three are fixed while the image is already unpacked for the wayland fix, so
the cost is a few lines rather than a second pass. `unbundle-wayland-client.sh`
is now `finalize-appimage.sh`, since it does more than unbundle.
The update URL is a **fixed** `linux-latest` tag on the GitHub mirror, which is
where updates are pulled from — deliberately not `releases/latest`. `latest`
follows whichever release is newest, and the Gitea-to-GitHub backfill creates
one GitHub release per Gitea tag, including the `-win` and `-mac` tags that
carry no AppImage. A URL that can resolve to a release with no AppImage in it
fails on users' machines and nowhere else.
The output is named for that tag too, and that is not cosmetic: zsync records
a *relative* filename which a client resolves against the .zsync URL it
fetched, so a versioned name would send every client after the build it already
has. Verified by reading the generated header — `Filename: Triple-C_x86_64
.AppImage` — and the image's own `.upd_info` section, which is where the tag
actually lives. My first guard checked the .zsync for the tag and failed
correctly, which is how that distinction got found rather than shipped.
Range requests were confirmed against the mirror before building on them: 206
with a correct content-range, so updates are real deltas rather than an 85 MB
re-download.
The .deb and .rpm go. They are two more artifacts to build, publish and keep
working for an audience already served by the one file that runs on every
distribution, and neither could ever self-update — which is now the difference
that matters. Older releases keep theirs. The Linux job passes
`--bundles appimage` rather than changing `tauri.conf.json`, so macOS and
Windows are untouched.
Verified against the real 0.4.19 artifact: it repacks, the AppStream file and
filled-in Categories land inside the image, the update string resolves to the
fixed tag, and the wayland fallback still holds. Both publisher failure paths
refuse rather than half-publishing — no token, and missing artifacts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
The AppImage came up blank on CachyOS with `Could not create default EGL
display: EGL_BAD_PARAMETER. Aborting...`, and the DMA-BUF workaround already
in `main.rs` did not help — verified by finding the flag compiled into the
shipped 0.4.19 binary, where it runs unconditionally on Linux.
It is a different fault with the same error text. linuxdeploy bundles
`libwayland-client.so.0` as a GTK dependency and `AppRun.wrapped` puts the
bundled directory ahead of the host's, so the host's Mesa resolves against our
copy. `libEGL_mesa.so.0` — the driver libglvnd's `libEGL.so.1` dlopens — has a
hard DT_NEEDED on that library, so when its symbols will not resolve the
driver never loads, glvnd is left with none, and `eglGetDisplay` reports no
display. That is why `GDK_BACKEND=x11` does not dodge it, and why the symptom
is a bad-parameter error rather than a link failure.
Bisected on the reporter's machine against the released artifact — removing
`libwayland-client.so.0` from the AppDir cleared the abort, while removing
`libwayland-egl` or `libepoxy` did not. The bundled copy (Ubuntu 22.04,
wayland 1.20) is missing eleven symbols their wayland 1.26 exports, including
`wl_proxy_get_display`, `wl_proxy_get_queue`,
`wl_display_create_queue_with_name` and `wl_fixes_interface`.
Bundling a newer wayland would defer this, not fix it: the floor is set by the
host's Mesa, which updates independently of our releases, so any version we
pick is one release away from being too old again. This is a host-coupled
library like libGL and libdrm — the only correct version is the host's.
So the copy is demoted rather than deleted. It moves off the loader path into
`usr/lib/wayland-fallback`, and a hook adds that directory back only when the
host has no libwayland-client of its own — so a host without one still starts.
The ordering is safe because `AppRun.wrapped` appends the inherited
LD_LIBRARY_PATH after its own entries, making the hook a fallback and never an
override. AppRun sources hooks by name rather than globbing, so it is patched
to source this one.
X11-only hosts are unaffected: libwayland-client is a package dependency of
Mesa and GTK, so it is present even on a machine with no display server at all
— confirmed on a headless container with neither DISPLAY nor WAYLAND_DISPLAY
set. And the AppImage already runs as an X11 client everywhere, since
linuxdeploy's own hook forces GDK_BACKEND=x11.
Verified against the real 0.4.19 artifact rather than a synthetic AppDir: it
repacks, the binary and AppRun survive, and both hook branches were exercised
— host-has-it leaves LD_LIBRARY_PATH untouched, and a debian:12-slim container
with no wayland at all engages the fallback.
The comment in `main.rs` quoted this exact error as one the DMA-BUF flag
fixes. That claim sent this investigation down the wrong path first, so it is
corrected rather than left to do it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Sending already switched to the target terminal's tab, which looks like it
should be enough: `TerminalView` focuses xterm whenever a terminal becomes
active. But that effect keys off `active`, so it only fires on a *change* —
and the dock's ordinary case is sending to the terminal already on screen.
`setActiveTabKey` writes the key that is already set, nothing changes, no
effect re-runs, and focus stays on the Send button. The note is sitting in the
prompt and the user still has to click the terminal before pressing Enter.
So the send now asks for focus explicitly, through a one-shot request in the
store that `TerminalView` consumes and clears — the shape `pendingHomeTab`
already uses. Clearing is not tidiness: hold the id and the second send to the
same terminal writes a value that is already there, which is precisely the
no-op this exists to fix.
Focus is requested only on success. A failed send toasts and leaves the user
where they are, because there is nothing in the prompt to press Enter on.
The three `TerminalView` tests give focus away after mounting before making
any assertion, so what they observe is the request landing and never the focus
that `active` already grants on mount — which would pass with the feature
absent.
752 tests pass, 62 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
The dock was showing `NotesPanel`, which is a master/detail layout: a column
of titles beside an editor. The previous commit made that survive dock width;
it did not make it right. At 352px the layout still spends roughly 356px of
height on chrome — dock header, panel header, title strip, a button row that
wraps, and a paragraph of help — before the body gets a pixel.
So the dock now shows one note. The title field names what is open and the
chevron beside it switches; New and Delete move into the overflow menu; the
help text goes. Chrome drops to about 112px and the body takes the rest.
The two surfaces are now different components, which contradicts a docstring
I wrote — "shared so the two cannot drift into different behaviour". That
claim was about behaviour, and behaviour was never in the layout: it is in
`useNotes` for the cache and its write ordering, and now in `useNoteDraft`,
extracted here so when a keystroke becomes a save is defined in exactly one
place. Only the layout diverges. `NotesPanel.shared.test.tsx` gets stronger
for it — it now mounts the dock panel and the tab panel together, which is
what the app actually does, instead of the same component twice.
`NoteSwitcher` is not `OverflowMenu` despite the shape being close: that keys
items by label, and notes are addressed by id, so two untitled notes — the
ordinary case — would collapse into one row. It is also not a `combobox`; an
input plus a listbox button is two honest controls, where the role would owe
active-descendant tracking and filtering that nothing here needs.
`SendToAgentButton` picks up `useUnavailable` from #49, which is what its
`disabled` plus explanatory `title` was already asking for. Four tests moved
from `toBeDisabled()` to the new contract, and one of them — "does nothing for
an empty note" — turned out never to have asserted that it does nothing. It
does now, for click and for Enter, which is the guard the swap needs.
It also gains `dropUp`, and that is load-bearing rather than cosmetic: the
dock clips its own overflow, so a session menu opening downward from a button
on the bottom edge is drawn outside the panel and never seen.
744 tests pass, 62 files. As before, jsdom has no layout engine: that the dock
now reads as compact is not something the suite can tell you.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
The panel splits master/detail unconditionally: a 192px title column beside
the editor. That fits the Project Home tab and does not fit the dock. At the
dock's 352px default the editor gets 157px, and its action row wants ~200px,
so the Delete button lands outside the dock's `overflow-hidden` with no
scrollbar to reach it, and the textarea collapses to a two-word column.
The two surfaces differ in width while sharing a viewport, so this is a
container query rather than a `md:` breakpoint — a viewport query reads the
window and hands both surfaces the same answer, which is wrong for one of
them. Tailwind v4 has these in core; verified as real
`@container (min-width: 32rem)` rules in the built CSS, since a variant that
silently compiles to nothing looks identical in review.
The threshold is arithmetic: side by side needs the 192px list, an editor
wide enough for its own buttons (~280px), and the divider. `@lg` (512px) is
the first stop clearing ~473px. Below it the titles become a capped strip
above the editor, so the note being written keeps the height.
The action row now wraps, which is the part that holds at *any* width rather
than on one side of a threshold: the buttons are a group that does not shrink,
the title field shrinks to 96px, and past that the title takes one row and the
buttons the next. Nothing can be pushed out of the panel.
Not covered by the suite — jsdom has no layout engine, so 711 tests pass
before and after. This needs eyes on the dock at its minimum, default and
maximum widths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Both were the defect the new hook exists for. The sidebar's Claude terminal
button is disabled whenever the container is not running and never said so —
its `title` names the action, so the precondition appeared nowhere in the
accessibility tree at all. Add Project's submit button is disabled while an
add is in flight, and its only signal is the label swapping to "Adding…" on
an element a screen reader can no longer reach.
The submit button needs a second guard the hook cannot supply: Enter inside
a text field submits a form without touching the submit button, so
`handleSubmit` now returns early while loading. Without it, swapping
`disabled` for `aria-disabled` would have turned an accessibility fix into a
double-submit bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Native `disabled` removes an element from the tab order and from the
accessibility tree, so any explanation of why a control cannot be used is
delivered only to a sighted user with a mouse. `useUnavailable` is the way
out: `aria-disabled` keeps the control focusable and announced,
`aria-describedby` carries the reason, and — because `aria-disabled` is
advisory and blocks nothing — the hook hands back the click and Enter/Space
guards along with the attributes, so a call site cannot take the
announcement without the guard.
`Button` gets it as an opt-in `unavailable` / `unavailableReason` pair.
Opt-in matters: 37 files render this button and none of them change. The
`aria-disabled:` class mirrors exist because Tailwind's `disabled:` variant
only matches the native attribute, which this pattern deliberately omits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
One gesture puts two requests in flight. With the tab already loaded, clicking
the dock toggle while the textarea has focus fires `blur` -> `saveNote` and the
dock's mount -> `list_notes` in the same tick. The save finishes and its re-read
writes the post-save list; the mount's read -- issued earlier, still out -- then
lands its pre-save snapshot on top, and both panels show stale text until
something else refreshes.
`mutationChains` could not have caught this: it orders a project's writes
against each other and the mount load is a read outside it. Putting the read on
the chain would work, but it buys correctness with latency the user feels -- a
panel mount waiting behind `save_note`'s double-fsync write -- and leaves a
"mutation chain" holding reads.
The two requests are not competing for a resource; the loser's result is simply
older. So every write into `notesByProject[p]` now claims a per-project sequence
when the request behind it is issued, and `commitNotes` drops one whose sequence
predates what is already cached. Reads take their sequence at issue time, since
being ordered by resolution is the bug. Local patches -- the filter behind a
confirmed delete, the prepend behind a failed re-read -- take a fresh one at
commit time, because they are authoritative then rather than derived from an
earlier read, and anything still in flight behind them is genuinely stale. A
failed read commits under its *own* sequence, not a fresh one, so its empty list
cannot beat a later read that has the real answer.
`isCurrent()` stays, and is not folded in. It guards `setSaveState`, not the
cache: it asks whether this *panel* is still showing the project a save was made
for, which a per-project counter cannot answer -- two panels on one project share
every sequence value. Ordering and panel identity are two questions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
`toClaudePayload` matched `/\r?\n/`, so a bare CR that is not part of a
CRLF went through verbatim — and a bare CR *submits* in a Claude prompt
and *runs* the line in a shell, which is the terminator the function's
own contract says it never appends. A `<textarea>` cannot produce one,
but `load_in` returns whatever a hand-edited or externally written notes
file holds, so the guarantee has to cover that rather than only what the
editor can type.
`Note.pinned` is persisted and sorted on, but nothing in the app sets
it: there is no pin control and no indicator. The spec stated the
ordering rule as though pinning existed and §8 did not list it, so the
spec is amended to say `pinned` is reserved and inert in v1, and pinning
is added to the out-of-scope list. No UI is added — a user-facing
affordance does not belong in a fix wave.
Also renamed NotesPanel.test.tsx's "deletes the selected note and falls
back to another": `useNotes` is mocked in that file and the mocked list
never changes, so the fallback was never exercised. A name that claims
coverage which is absent is worse than an absent test, because it makes
the gap invisible. The real assertion now lives against the real hook in
NotesPanel.shared.test.tsx.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
Implements the design spec's §2 — "notes cached in zustand keyed by
project id" — which the plan substituted with a hook-local `useState`.
Sharing the `NotesPanel` *component* between the Project Home sub-tab and
the dock did not share the *cache*. Both resolve to the same project, so
two panels mount two `useNotes(P)`, each with its own list. Edit a note
in the dock and blur; the tab's copy is still pre-edit, and the tab's
next blur commits `{...staleRecord, title, body}` — the dock's edit gone
from disk with no error and no indicator. That is the feature's own
primary workflow: take notes in the dock while the agent runs, which is
the reason the dock exists, then go back to the tab.
`notesByProject` plus a per-project in-flight flag now hold the list.
Both surfaces render from one array; two panels mounting for one project
make one read; and because the write is keyed by project, a response
that lands after the user has moved on updates the project it belongs to
rather than whichever is on screen. This is also the boundary §8 says a
detached notes window needs.
Three more bugs in the same code, fixed with it:
- Delete-after-edit could resurrect the note. Clicking Delete with the
textarea focused fires blur first, so `save_note` and `delete_note` go
out back to back; Rust's `write_lock` stops them interleaving but does
not order them, and a delete that wins the lock is undone by the
upsert behind it. A project's mutations now go through one promise
chain, module-scoped for the reason `useTerminal`'s input queue is.
- An unsaved draft vanished when any other note was saved, because the
re-read replaced the list with the backend's. "New note" now persists,
so the backend owns the row from the start — chosen over merging local
drafts because a local-only row in a *shared* cache would exist in the
panel that made it and nowhere else.
- The save outcome was reported for the wrong project after a switch:
the guard covered only the list replacement, so the new project's
SaveIndicator flashed "Saved ✓" for the old project's write. The
indicator now resets on a project change and reports only its own.
`NotesPanel` also re-seeds its draft when the *stored* text of the note
it has selected changes, so an edit made in the other surface reaches
the editor and not only the list. It never overwrites something
half-typed; that still blurs into a last-writer-wins save, as any
blur-commit editor does.
NotesPanel.shared.test.tsx is the configuration none of the existing
tests had: two panels, one project, the real hook. Four of its six
assertions fail against the previous implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
Two things the plan dropped from the design spec's §1.
`keep_corrupt_copy`'s only guard was "does this second's copy already
exist", so a persistently unparseable file minted a full copy of the
user's prose every time the clock ticked over — and `list_notes` runs on
*every* NotesPanel mount, i.e. every project switch, every
dock-follows-tab change, every sub-tab toggle. A minute of clicking
between two projects was ~60 copies. `MAX_CORRUPT_BACKUPS`,
`corrupt_backups_full()` and the three-outcome `Kept` enum come across
from `migration_store` whole, including the reason the cap is asked
*before* the copy (so it is not implemented by writing a file and
deleting it again, and so the surviving copies are the oldest ones) and
the reason the log line must not claim a backup that was never written.
The file itself is now `{ version, notes }` rather than a bare array.
It costs nothing today and gets permanently more expensive once files
exist in the field. No released build has written notes, so there is no
migration path — but a bare array is still *read*, because declaring a
perfectly readable file corrupt is the one outcome this store exists to
avoid, and a developer's own notes are prose nothing else has a copy of.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
- notesDockWidth store initialization now clamps/defaults a bad
localStorage value on load, not just on write (verified this fails
without the clamp).
- The keyboard resize test asserts the exact widened/narrowed value
instead of just that the setter was called, so a swapped or
inverted arrow-key branch would be caught.
When a save is in flight for project A and the user switches to project B before it resolves, the stale closure still has projectId=A. When A's save resolves, the post-save re-read of listNotes(projectId) runs with the stale closed-over projectId, and setNotes(reloaded) overwrites B's displayed notes with A's list—the same cross-project contamination class as Finding 2 but reintroduced through the fix itself.
Fix: Add a currentProjectId ref updated on every render, and guard both saveNote and deleteNote callbacks with a check before replacing/filtering the whole list. If the project changed while the async operation was in flight, bail out of the state update but still report success (the operation itself succeeded on the backend; only the stale list update is skipped).
Added test: a save in flight for one project, a switch to another, then the first save resolving—asserts the second project's notes are still displayed.
- Finding 1 (saveNote): After a successful save, re-read the canonical list from the backend instead of patching in place. A successful save stamps a new updated_at, and the backend sorts by updated_at descending, so the record's position has changed and positional patching would disagree with what a reload would show. If the re-read fails, keep the save reported as successful and leave the existing list alone.
- Finding 2 (stale notes): Clear notes on projectId change (not only when empty) and on load failure. Previously, switching from project A to project B would leave A's notes on screen until B's fetch resolved, and if a user edited one, A's note would be written into B's notes file—cross-project data corruption. If a load fails, A's notes stay visible under B indefinitely.
- Added four new tests covering these scenarios: projectId change clears old notes, failed load leaves no stale notes, saving a new note ends with the backend's list, and saves re-read the list rather than patching.
Seven tasks, each ending in a testable deliverable: the store, the IPC
surface, the hook, the two shared helpers, the send button, the tab, and
the dock.
Two extractions are folded in rather than left for later, both because
this feature would otherwise duplicate knowledge that is already written
down. `\x1b\r` becomes `lib/claudeInput.ts` so the hard-won comment in
`TerminalView` stays the single source of truth for a sequence that must
never be "simplified" to `\n`. The session display-name rule becomes
`lib/sessionName.ts`, which is a fix rather than a precaution: the rule is
currently written twice inside `MainTabs.tsx`, both copies local and
non-exported, and the send-target picker would have made three.
The spec is also corrected in three places against what the code actually
does. `migration_store` is a free-function module with no struct, so the
notes store is too, and the "read-modify-write under the store's Mutex"
line described a shape that file does not have — the upsert takes an
explicit process-wide write lock instead, and the read path takes none.
`useProjectSave` has no debounce; its only timer is a 2500 ms reset of the
"Saved" label. And the storage section now specifies the durable write
`migration_store` uses — fsync the file, rename, fsync the directory —
rather than `projects_store`'s bare rename, because notes are prose
nothing else holds a copy of.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
Notes are discrete, addressable items with a button that puts one into a
running Claude session's prompt. That is deliberately not what
`claude_instructions` does — that field is *ambient*, merged into the
container's CLAUDE.md on every start and always in context. Nor is it a
`NOTES.md` in the workspace, which the agent can read but the user cannot,
once the container is stopped. Discrete items, fired on demand, readable
with the container down, is the gap neither of those covers.
Storage is one file per project under the app data dir, following
`migration_store.rs` rather than living on the `Project` record: that record
is rewritten on every blur by the debounced save path, so notes there would
mean the whole project list is rewritten per keystroke-batch and a note edit
could clobber a Config edit. `migration_store.rs` already documents that
reasoning for itself.
Two findings are worth more than the design they support.
**Newlines already have a verified answer.** A note body has newlines; typed
as raw keystrokes each one submits a separate prompt, so a note would arrive
as N truncated messages. `TerminalView.tsx` already sends `\x1b\r` for
Shift+Enter and its comment states those are the in-band bytes, not a guess,
with an explicit warning against simplifying to `\n` because a shell would
run the line. Send-to-agent reuses that sequence through one shared helper,
and — from the same comment — only offers `claude` sessions as targets,
since bash's readline has no binding for it and merely bells.
**The dock cannot widen the OS window.** A throwaway Tauri app was built and
run on KDE Plasma to find out, because the app has no window-geometry code to
reason from. Under XWayland every test passed exactly. Under native Wayland
the same binary asked +420 and got +600, moved the height +276 without being
asked, compounded that offset on every call, and ended reporting 5400x2900 on
a 4800x2700 monitor. Worse, `outer_position()` did not fail — it returned
`Ok(0,0)` for a window that was not at 0,0, so a "cannot determine position,
do not grow" fallback never fires. A clean failure could have been handled; a
plausible wrong answer cannot be detected from the value itself.
AppImages get XWayland because linuxdeploy-plugin-gtk forces GDK_BACKEND=x11;
the .deb and .rpm do not. The split is therefore by *packaging*, not platform
— two users on identical hardware would see different behavior. So the dock
takes space inward on every backend, which also costs nothing: the
ResizeObserver in `TerminalView.tsx` already reflows xterm and resizes the
container PTY on width change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
The `triple-c-bin` package was never on the AUR, so installing it meant
downloading a file and running `pacman -U` — the same gesture as making an
AppImage executable, for a second artifact to keep building. And being
`workflow_dispatch`-only it reached 1 release in 28 (only v0.4.16 has a
`.pkg.tar.zst`), while HOW-TO-USE.md told Arch and CachyOS users to download
it from every release. A distribution channel that is absent 27 times out of
28 is worse than not promising one.
`packaging/arch/` and `.gitea/workflows/publish-arch-package.yml` are
preserved whole on `hold/arch-packaging`, the same way the disk panel and
drag-out work were held rather than deleted. What would make an Arch package
worth having is an AUR account and its SSH key as a repo secret — both
one-time manual steps that never happened; the workflow's own header already
said as much about its AUR push step.
This also closes the gap that prompted the review: nothing validated the
PKGBUILD until someone manually dispatched the workflow, making it the only
packaging path with no CI coverage. Removing it removes the untested surface
rather than adding a job to test something nobody installs.
In its place, `scripts/install-appimage.sh` does what a package manager's
install hooks would. An AppImage carries a `.desktop` entry and icons inside
itself, but nothing on the host reads them, so it never appears in the app
launcher. The script extracts the bundled icons into the user's icon theme
and writes a launcher entry — no sudo, nothing outside `~/.local/share`, and
the AppImage itself is never copied or moved.
Two details it gets right on purpose:
* The `Exec` line is rewritten, not copied. The bundled entry says
`Exec=triple-c`, which resolves only inside the running AppImage's own
mount — a verbatim copy gives a launcher entry that starts nothing.
* Extraction uses `--appimage-extract`, which needs no FUSE, so the script
works on a machine where *running* the AppImage would first need
`fuse2` installed. That requirement is now documented too: Arch and
CachyOS do not ship FUSE 2 by default.
Verified against the real artifact — the AppImage from this repo's own
preview-3a49a67 release: 4 icon sizes install, `desktop-file-validate` passes
with no warnings, `--uninstall` leaves nothing behind, and shellcheck is
clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
Two separate defects behind the same report: typing in a container terminal
is sluggish on Linux, and a backspace can land *after* the characters typed
behind it.
The web terminal was the control that separated them. It shares the Docker
exec, the PTY, `exec_manager`, the input channel and its serial writer task,
and xterm.js itself — and it does not exhibit either symptom. Only three
things differ, and each accounts for part of the report.
**Input ordering.** Every keystroke was its own `invoke("terminal_input")`.
That command is `async`, so Tauri spawns each one as an independent task, and
those tasks then race for the session mutex in `ExecSessionManager::send_input`
— nothing preserved the order the bytes were typed in. The serial writer
downstream cannot help, because the order is already lost before anything
reaches the channel. The web terminal gets ordering for free by awaiting
`send_input` inline in a single WebSocket reader loop.
`useTerminal` now holds a per-session queue: one write in flight at a time,
the next only after the previous resolves. Anything typed meanwhile coalesces
into the next chunk, which also collapses a burst of typing into a couple of
IPC round trips rather than one per key. The queue is module scope, not hook
scope, because `useTerminal()` is called from several components — a per-hook
queue would leave speech-to-text, image paste and typing racing each other.
Each caller's promise still settles only when its own bytes have gone, so
`await sendInput(...)` keeps its meaning.
**The DMA-BUF escape hatch did not exist.** `apply_webkit_wayland_workaround`
left any pre-set value alone, including `0`, on a stated assumption that
WebKitGTK reads the variable as a boolean. It reads presence, so
`WEBKIT_DISABLE_DMABUF_RENDERER=0` disabled DMA-BUF exactly like `=1`, and no
value a user could set got the accelerated path back. `0`/`false`/`no`/empty
now remove the variable, which is the only thing WebKitGTK reads as enabled.
The default is unchanged: unset still means disabled on Linux.
**WebGL does not degrade to canvas here.** The comment on that workaround
assumed `@xterm/addon-webgl` would fall back to the canvas renderer once
DMA-BUF was off. Its constructor throws only when WebGL is *absent*, and with
DMA-BUF disabled WebGL is still present — served by software rasterisation.
So the addon loads and every frame is rendered on the CPU, slower than the
canvas renderer it was assumed to fall back to. `AppSettings::terminal_gpu_
rendering` decides whether it loads at all: `None` is auto (on for macOS and
Windows, off on Linux), `Some(_)` forces it either way from Settings →
Terminal. `Option<bool>` rather than `bool` so the zero value means "we
choose" instead of pinning every existing settings file to one answer.
Verified: 643 frontend tests and 530 Rust tests pass, clippy clean, secret
scan clean. The Linux rendering half needs confirming on a real desktop —
neither symptom reproduces in a headless container.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
A user hit this after installing the new Arch/CachyOS package (triple-c#34):
icon missing in the app menu, taskbar, and titlebar alike, with no error
in the app's own log. Root cause has nothing to do with the app or its
packaging — GNOME/KDE cache the installed-app list and resolved icons in
the shell process's memory at startup, and Wayland has no equivalent to
X11's soft shell-restart trick to force a live reload. Logging out and
back in fixed it for them.
The workflow only ever uploaded to the GitHub release — the Gitea release
for the same version (the plain, unsuffixed vX.Y.Z tag build-app.yml's
Linux job creates, which already holds the .deb/.rpm/.AppImage) never got
it, so it looked missing to anyone checking releases on Gitea instead of
GitHub.
New step mirrors build-app.yml's own Gitea upload step exactly: same
get-or-create-by-tag, delete-existing-asset, upload-as-octet-stream shape,
same REGISTRY_TOKEN secret. Verified the read side (release lookup, asset
listing) against the real v0.4.16 release before writing this — resolves
to the correct release id and correctly finds no existing asset yet.
HOW-TO-USE.md's Prerequisites jumped straight to Docker and a Claude Code
account, assuming Triple-C was already installed; the app itself had no
download/install instructions anywhere in the docs. Covers all six release
assets, including the new Arch/CachyOS .pkg.tar.zst (triple-c#34) that
publish-arch-package.yml now attaches to each release.
Publishing to the AUR needs a maintainer AUR account and its SSH key
registered as a secret here, neither of which exists yet. Rather than
leave the workflow permanently failing at that last step, it now stops
short of AUR and instead uploads the built .pkg.tar.zst to the same
GitHub release it built from, as a plain downloadable asset (`pacman -U`
to install). The AUR-push step is still in this file's git history if
that setup happens later.
Renamed publish-aur-package.yml -> publish-arch-package.yml to match.
The render/validate steps are unchanged; new here is capturing the exact
built package filename from inside the build container (makepkg is the
only thing that actually knows it) and an upload step that follows the
same create-or-reuse-release, strip-upload_url, POST-octet-stream pattern
build-app.yml and backfill-releases.yml already use for GitHub assets,
plus a delete-existing-asset-first step so a re-dispatch for an
already-packaged version replaces rather than 422s.
Verified with a real Docker run end to end: rendered a real PKGBUILD,
built a real (synthetic) .deb through makepkg + namcap in an archlinux
container, confirmed the container exits 0, and confirmed the exact
package filename it captures (triple-c-bin-<version>-1-x86_64.pkg.tar.zst)
round-trips out via docker cp intact.
The "Render PKGBUILD" step's Python heredoc built its old_source match
string via an f-string, escaping literal braces as `${{pkgver}}` — which
put that exact four-character sequence directly in this workflow file's
own YAML text. Gitea Actions scans a run: block for `${{ ... }}` and tries
to evaluate whatever's inside as one of its own expressions before the
shell ever sees the script; "pkgver" isn't a valid expression context, so
every run has been failing that interpolation and emptying the step
instead of raising anything visible there. The next step's `makepkg` then
failed with "PKGBUILD does not exist" — the actual point of failure was
one step earlier and unrelated to AUR credentials.
Rebuilt the same match string with a "$" variable and plain concatenation
so the file's own text never contains the trigger sequence. Verified by
extracting the exact heredoc and running it standalone against the real
PKGBUILD template — renders identically to the intended output.
Round 4 review findings:
- Disclose and warn on a custom Docker image the import would set (HIGH):
it's the image every project container is created from, so an
undisclosed change here was a sharper version of the redirected-base-URL
problem round 3 already flagged for the model backends.
- Recreate a running gateway container when an import restores a new
secret with the shape unchanged (MEDIUM): reconcile_gateway's shape
comparison can't see a secret-only change, so the container would
otherwise keep serving old key material indefinitely.
- Report keychain write failures back to the caller instead of only
logging them (MEDIUM): apply_settings_import now returns
SettingsImportOutcome with secret_restore_warnings so a partial restore
can't read as unqualified success.
- Pin a hash of the previewed file's ciphertext and refuse to apply if it
changed on disk (MEDIUM): closes a TOCTOU between preview and apply.
- Sanitize and cap every free-form string a preview surfaces, and move the
warning boxes above the replace list in the UI (MEDIUM): an unbounded
base URL or image name could otherwise push the security warnings below
the scroll fold.
- Validate the Docker socket path on import the same as the SSH key and CA
cert paths (LOW): it was the one mounted host path validate_settings_update
didn't cover.
- Fix ExportedSecrets::is_empty() to treat whitespace-only as blank, like
every other secret-presence check in this feature (LOW).
- Authenticate the file header as AEAD associated data (LOW, defense in
depth) and correct two doc comments that overstated the password not
being cached.
A rejected import (bad env var name, disallowed host path) used to leave
keychain secrets already overwritten while the settings themselves stayed
unchanged. apply_settings_import now runs update_settings's validation
(extracted into validate_settings_update) before any secret write.
Also from this review round: sharpened two format-version tests that
previously passed against the pre-fix code too, added a direct test for
split_settings_and_secrets, warned on a dormant web terminal token even
when the terminal import leaves it off, matched the password-length check
to the frontend's unit of measure, zeroized the export plaintext buffer,
and surfaced non-blank Ollama/llama.cpp/OpenAI-compatible/gateway base
URLs in the import preview so a traffic redirect isn't silent.
The headline finding: WebTerminalSettings::access_token is a live bearer
credential for a server that binds every interface, stored as a plain
field on AppSettings — which this feature was exporting and importing
wholesale as if it were as inert as a port number. A crafted export file
could set web_terminal.enabled and access_token together, and importing
it (with no more warning than any other setting change) would silently
stand up a LAN-listening terminal server with an attacker-known token on
the victim's next launch.
Fixed by carving the token out into ExportedSecrets, same as the other
three global secrets, with the same "only overwrite what the import
actually has" treatment — except that has to be done by hand here, since
this one lives inside the AppSettings blob that gets replaced wholesale
rather than in the keychain. Added SettingsImportPreview::
enables_web_terminal so "this turns on a listening service" gets its own
visible warning in the confirmation modal rather than hiding inside a
generic "settings replaced" bullet list.
Also fixed:
- read_and_decrypt checked format_version only after attempting to parse
the full payload, so a future version bump that isn't
deserialize-compatible would fail on the shape mismatch before the
version check ever ran — and serde's type-mismatch errors quote the
offending value inline, which is a real leak path since the plaintext
here can hold a live credential. Now probes just the version field
first, and neither error path interpolates the underlying serde message
into what the user sees.
- apply_settings_import cleared the pending-import path before it could
fail, so a rejected import (an invalid host path, anything
update_settings validates) dead-ended the modal with no way back except
cancelling and reopening the file picker. The path is now only cleared
on success.
- Secrets are restored before the settings replace runs, not after —
replacing settings is what triggers reconcile_gateway, and restoring
secrets afterward left a real window where a gateway recreation
happened against the destination's stale keys.
- The 8-character password minimum was frontend-only; export_settings now
enforces it too, since that's the actual boundary a weak password has
to cross. The derived key and decrypted plaintext are wrapped in
zeroize::Zeroizing (already in the tree via aes-gcm).
Added test coverage the review named as missing: format-version
ordering, the generic-error-message guarantee, non_blank's blank-vs-
absent handling, and the new web-terminal preview/warning behavior on
both sides of the IPC boundary.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
Closes#35. Exports the host environment — global AppSettings (already
the non-secret shape persisted to settings.json) plus the global secrets
that live in the OS keychain instead (the shared Claude Code OAuth login,
the model gateway's provider API key and master key) — to one
password-encrypted file, and restores it on another machine.
Per-project settings, per-project secrets, and Docker volumes are
deliberately out of scope; this is not a project backup.
Designed with the user in issue #35's comments: global settings only, no
docker volumes, the password is the lock/key, and the export is portable
as one file.
Crypto (storage/settings_crypto.rs): Argon2id derives a 256-bit key from
the password (memory-hard, meaningfully resistant to GPU/ASIC
brute-forcing in a way PBKDF2 at any reasonable iteration count is not),
AES-256-GCM does the actual encryption. A wrong password fails GCM's
authentication tag rather than producing silent garbage. Salt and nonce
are random per export and stored in the clear in the file header — their
job is uniqueness, not secrecy.
The save/open dialogs are opened from Rust, matching the boundary
file_commands.rs's pick_save_path/pick_files_to_upload already establish:
a frontend-driven dialog handing Rust a host path is the exact shape of
bug that produced this app's past criticals. preview_settings_import
resolves the chosen import path itself and remembers it
(AppState::pending_settings_import) so apply_settings_import re-reads the
same file without a path crossing back over IPC. The password is
re-entered rather than cached between preview and apply, so nothing here
holds decrypted plaintext in memory for longer than one command's
execution; the preview returned to the frontend carries counts and
presence flags only, never a secret value.
Import replaces settings wholesale (an import is "restore this
environment"), but only writes secrets actually present in the file — an
absent secret means "the source machine never had this configured," not
"delete this on import."
Added storage::secure::store_gateway_master_key and get_gateway_master_key
(read-only, unlike get_or_create_gateway_master_key which mints one as a
side effect) since neither existed and import needs to restore an exact
captured value rather than mint a new random one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
A review found the "Validate with makepkg and namcap" step's bind mount
(docker run -v "$PWD/rendered:/work") would very likely fail on Gitea's
own act_runner: a containerized job's $PWD isn't a path the daemon's host
can resolve, so the mount would silently attach an empty directory
instead of failing loudly — the same class of problem noted elsewhere for
this exact environment. Switched to docker create + docker cp (in and
back out) + docker start -a, the pattern already validated locally, which
works regardless of where the daemon actually lives.
Also found and fixed, most severe first:
- The namcap error gate (`grep -q "^[a-zA-Z0-9_-]*bin E:"`) only matched
one of namcap's two line shapes for reporting an error
("triple-c-bin E: ...") and missed the other ("PKGBUILD
(triple-c-bin) E: ...") entirely — confirmed by reproducing both against
a real namcap run. The PKGBUILD-level half of the safety net was dead.
Replaced with a plain `grep -q " E: "`, confirmed to match both real
shapes (and a split-package variant) and nothing else.
- package()'s `ar x "Triple-C_${pkgver}_amd64.deb"` named the asset
literally, defeating the whole point of the resolve step discovering
the real filename from the release instead of assuming a pattern — a
future Tauri bundler naming change would still break here with an
opaque error. Changed to `ar x ./*_amd64.deb`, which `source=()` already
guarantees matches exactly one file.
- `pacman -Sy` before installing packages is the canonical Arch partial-
upgrade footgun; changed to `pacman -Syu --noconfirm --needed`.
- `${{ inputs.version }}` was interpolated directly into a shell step
instead of routed through `env:`, unlike every other step in the file.
- `git push origin master` assumes the local branch name after cloning a
brand-new (not-yet-created) AUR repo's empty state is `master`, which
depends on the runner's own `init.defaultBranch` if the server sends no
symref. `git push origin HEAD:master` is unambiguous either way.
- The private key was written with a plain redirect then chmod'd after,
leaving a window where it's world-readable; now created at its final
mode first via `install -m 600 /dev/null`. Added `-o IdentitiesOnly=yes`
so a runner ssh-agent can't offer a different key first.
- Added GH_PAT auth to the api.github.com calls, matching every other
workflow in this repo, to avoid the unauthenticated 60/hour rate limit.
- Fixed two comments: the `options` comment credited `!debug` for
suppressing the empty debug-package directory, when it's actually
`!strip` doing that (verified in a real build); and documented in the
README that a hand-edit made directly in the AUR repo is silently
reverted by the next dispatch, since every run renders fresh from this
repo's template.
All of the above re-verified with the same real end-to-end methodology as
the original commit: real makepkg build, real namcap lint (clean), and
the exact updated docker create/cp/start sequence run against a live
container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
Part of triple-c#34's third ask ("I would like to also have an
Arch/CachyOS native version as well"), addressed separately from the
Wayland crash fix (fix/wayland-webkit-egl-crash) since it's an unrelated
feature, not a bug.
packaging/arch/PKGBUILD is a "-bin" AUR package repackaging the same .deb
build-app.yml already produces — no Rust/Node toolchain needed to install
it, and the user gets exactly the binary the project ships and tests.
Verified end to end against a real release (v0.4.14) rather than going by
Tauri's generic docs: downloaded the actual .deb, ldd'd the actual binary
to ground-truth `depends` (dropped `pango` and `libayatana-appindicator`
from an earlier draft — the first is already pulled in transitively by
gtk3, the second was never linked at all since this app has no tray icon
or menu), and ran a real makepkg/namcap/pacman -U cycle. namcap caught a
real issue this way (missing license file under
/usr/share/licenses/triple-c-bin/), now fixed by fetching LICENSE
alongside the .deb.
.gitea/workflows/publish-aur-package.yml does the actual publishing:
given a version (or "latest"), it finds that release's real Linux asset
on GitHub, downloads it, computes real checksums, renders the PKGBUILD
template, validates the result with makepkg and namcap inside a real
Arch container, and pushes to AUR. workflow_dispatch only, deliberately —
the same reasoning that killed sync-release.yml in triple-c#32 (releases
are assembled by build-app.yml across three separate platform jobs, so
there's no single automatic event that fires only once the Linux .deb
this needs actually exists) applies here too.
Requires a repo secret this workflow cannot set up itself:
AUR_SSH_PRIVATE_KEY, from an AUR account that has already created (or
been given co-maintainer access to) triple-c-bin — both one-time manual
steps on aur.archlinux.org. Until that secret exists, the workflow fails
loudly at the push step rather than silently doing nothing. See
packaging/arch/README.md for the full maintenance flow.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
Review found: "nothing this app's UI depends on" is backwards — the
terminal's @xterm/addon-webgl renderer is exactly the GPU compositing path
this setting disables, it just degrades gracefully (the addon's own
construction already handles WebGL being unavailable) rather than
crashing. And the "not simply Wayland vs X11" justification for going
unconditional doesn't hold up: WAYLAND_DISPLAY is exported into an
XWayland client's environment too, so gating on it would have caught that
case as well — the real reason to go unconditional is that there's no
reliable heuristic for the thing that actually matters (which
Mesa/driver/compositor combination is affected), not that the naive gate
misses XWayland specifically.
Also noted, not changed: the env var leaks to whatever the app spawns
afterwards (a cold-launched default browser via xdg-open), and the "=0
re-enables it" parenthetical isn't verified against WebKitGTK's own
source, so softened to say what's actually guaranteed (an already-set
value is left alone) rather than assume presence-vs-boolean parsing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
Reported on CachyOS/Arch with Wayland: the app aborts immediately with
"Could not create default EGL display: EGL_BAD_PARAMETER. Aborting."
printed straight to stderr by WebKitGTK's own C code, before Triple-C's
own logging even gets a chance to say anything useful about it.
This is WebKitGTK's DMA-BUF renderer (its default accelerated-compositing
path since 2.42) failing on some Mesa/driver/compositor combinations. Set
WEBKIT_DISABLE_DMABUF_RENDERER=1 unconditionally on Linux before the Tauri
builder runs, which is where GTK/WebKitGTK actually read it — there's no
reliable way to detect the affected combination ahead of time (reports of
this exact failure exist under XWayland too, not just pure Wayland
sessions), and WebKitGTK's fallback compositing path costs some rendering
performance this app's UI doesn't need. Left alone if a user has already
set the variable themselves.
Does not address the other two things filed under the same issue (links
not opening on the host, and a request for a native Arch/CachyOS package)
— those need more information / are a separate scope, respectively.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
Final review pass gave this a clean bill of health overall but named
three small things:
- get_app_version() and check_for_updates() each read
option_env!("TRIPLE_C_BUILD_SUFFIX") independently with slightly
different idioms — if one were ever edited alone, the About panel and
the update check could silently disagree about whether this is a
preview build. Extracted preview_build_suffix() as the single place
that reads and classifies it.
- pick_update's doc comment described the unparseable-tag case as a
`-preview.<sha>` suffix; the actual tag build-app-preview.yml creates is
`preview-<sha>` (no version, no dot) — already correct in the
neighboring GitHubRelease::prerelease comment, just not here.
- That same prerelease comment claimed defence against a preview release
leaking through backfill-releases.yml, but a preview's tag already fails
semver parsing on its own — this field's actual job is the case parsing
can't catch: a normally-tagged release someone flags prerelease on
Gitea (a hotfix candidate, an RC) that a backfill would otherwise mirror
as-is.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
An Opus review of the previous commit found its headline claim didn't
hold: a preview and the release it precedes compute to the identical
numeric version by construction, but check_for_updates compared with a
strict `>` against the bare CARGO_PKG_VERSION (never the suffixed display
string), so `(0,4,13) > (0,4,13)` is false and the release was never
offered. Plain semver ordering doesn't make a `-preview.<sha>` suffix sort
below the same numeric release on its own here, since the comparison
never sees the suffix at all.
pick_update now takes is_preview_build, derived from whether
TRIPLE_C_BUILD_SUFFIX was baked in, and relaxes that one comparison to
`>=` — so "a release exists at my own number" reads as an update. A
production build still requires strictly newer.
Also: ported build-app.yml's `git tag --points-at HEAD` guard into the
preview version computation. Without it, workflow_dispatch (which this
workflow allows on main, not just PR builds) run on a commit a release
was already cut from would compute one past that release — reintroducing
"preview outranks production" through the manual-dispatch door. And
corrected two comments that claimed the prerelease filter was currently a
no-op: backfill-releases.yml mirrors every Gitea release to GitHub
unfiltered, prerelease flag included, so it's real defence-in-depth
against a dispatched backfill leaking a preview release, not a no-op.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
build-app-preview.yml computed its patch number as
`git rev-list --count <latest tag>..HEAD` — the exact formula build-app.yml
itself documents as broken and replaced (#26): a distance from whichever
tag sorts highest, not a counter, so it resets to zero on every release and
previews went backwards (0.4.62 -> 0.4.0) the moment one landed. Ported the
same "one past the highest patch already used" computation build-app.yml
uses for real releases, reading the same tags (including -mac/-win
suffixes), so a preview built right before a release now computes the
exact number that release is about to take — semver already orders
`0.4.12-preview.<sha> < 0.4.12`, so a preview user is offered the release
the moment it ships instead of being silently pinned forever.
The installed preview's reported version was also indistinguishable from
production: the bundle's own version field strips the `-preview.<sha>`
suffix before touching tauri.conf.json/Cargo.toml/package.json, since the
Windows MSI's ProductVersion has no room for one. Rather than risk that
(unverifiable without an actual Windows build), preview builds now bake
the suffix into the binary separately via a TRIPLE_C_BUILD_SUFFIX
build-time env var, and get_app_version() appends it when present — a
production build sets nothing, so this is a no-op there.
Also: added `prerelease` to `GitHubRelease` and filter on it in
check_for_updates (currently a no-op against real data — nothing mirrored
to GitHub is ever prerelease:true — but the updater is no longer
structurally incapable of enforcing a channel split if one is ever made
explicit). And deleted sync-release.yml: workflow_dispatch-only, reading
gitea.event.release.* fields a manual dispatch never populates, so it
could never have actually run; build-app.yml's inline mirror already does
the same job.
Refactored check_for_updates' filtering into a pure, testable pick_update
helper (this file had no tests before), and added tests for it and the
new get_app_version suffix handling.
Fixes#32.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
A third Opus review pass confirmed round 2's fixes hold up, then found:
- The pending-cleanup record `remove_project` writes is fully durable
(fsync'd); the projects_store.remove() that follows it is a plain
fs::write with no fsync. A crash or power loss in that window — or that
store write failing outright, beyond what the previous round's in-process
rollback catches — leaves a record on disk naming a project
projects.json still lists as present. The very next startup retry would
then delete that project's container, snapshot image, and both volumes
(including the one holding the OAuth credential and every session
transcript) out from under a project the user still sees in the sidebar.
retry_pending_cleanup_logged now takes the ProjectsStore and refuses to
touch — clearing instead — any record whose project id still exists.
Also stopped swallowing the round-2 rollback's own failure.
- Resolving the container through find_existing_container instead of
project.container_id (round 2's stale-id fix) changed what drove
close_sessions_for_container in remove_project and rebuild_project_
container: sessions are now leaked when Docker is unreachable (nothing
resolves, so nothing closes, and the project record is gone a moment
later) and in the stale-id race itself (sessions were opened against the
container that actually exists, not the id find_existing_container
bypasses). Both functions now close sessions for the stored id
unconditionally, and again for the resolved id if it differs.
- A pronoun-agreement bug in the no-retry removal toast ("remove them
manually" for a single leftover) that was fixed one line above for verb
agreement but not for the pronoun.
Also closed the test gaps the review named: the pending-cleanup
corrupt-record aside-move had no test, the Reset toast's leftover copy
was inline and untested (extracted to lib/resetOutcome.ts, mirroring
components/projects/home/removalReport.ts, with unit tests), and nothing
asserted rebuild()'s success path maps outcome.project into the list
rather than the whole outcome.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
A second Opus review of commit 2 found it had introduced real problems of
its own rather than just polish gaps:
- remove_project's "None or stale" container-id fallback only handled
None. A stale id (the documented start-failure race in
start_project_container_locked, where the old container is removed and
the new one's id isn't persisted until after start_container succeeds)
still 404'd on removal — now treated as success by commit 1's own fix —
while the real container survived to block every volume removal with a
409 forever, with nothing in the pending-cleanup record ever naming it.
Both remove_project and rebuild_project_container now resolve the
container via find_existing_container() unconditionally, matching every
other container-destroying path in the codebase, and remove_project
fails closed (records a leftover rather than silently skipping) if
Docker itself can't be reached to check.
- remove_project could leave a pending-cleanup record for a project still
live in projects.json: if the store's own save failed after the record
was written, startup housekeeping would delete that project's container
and volumes out from under it on the next launch. The record is now
rolled back when the store write fails.
- rebuild_project_container (Reset) only surfaced a leftover volume, not a
leftover snapshot image — the more serious failure, since the next
container is built from that image whenever it exists, silently
reviving the exact system layer Reset was asked to discard.
ProjectResetOutcome now carries leftover_image too, and the toast's
"run docker volume rm" advice is corrected: the new container has
already remounted the volume by the time the toast renders, so that
command would just hit the same conflict Reset did.
Also from the same pass: reworded a couple of log/toast lines that still
asserted resources were "still present" when the daemon-unreachable case
covered by the same code path can't actually confirm that; fixed a
singular/verb mismatch in the leftover toast text; moved an unparseable
pending-cleanup record aside instead of re-warning about it forever; and
added a debug log when a record's recorded_at can't be parsed, so aging
never silently no-ops.
Pulled describeLeftovers/leftoverVerb out of ProjectHome.tsx into their
own module with unit tests, and added tests for the recorded_at staleness
check — the previous commit's equivalent logic had none.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
An Opus review of the previous commit found several real gaps:
- pending_cleanup::save used plain write-temp-then-rename, unlike
migration_store's fsync'd write it claimed to mirror — a crash in that
window left a truncated record that list() would skip forever, silently
reproducing the exact bug this module exists to fix. Now matches
migration_store's File::create/write_all/sync_all/rename/sync_dir shape,
and the tests exercise the real save/list/clear functions against a temp
dir instead of re-implementing their bodies inline.
- remove_project and rebuild_project_container only ever looked at
project.container_id, unlike every other container-destroying path in the
codebase, which falls back to find_existing_container for exactly this
race (a crash between creating a container and persisting its id). A miss
here left a container that then blocked every subsequent volume removal
with a 409, forever. Both now resolve the same way the rest of the
codebase does, and record the container by its deterministic name rather
than its id so a retry still has something that resolves.
- remove_project's toast promised an automatic retry unconditionally, even
when writing the pending-cleanup record itself failed (the one case
where nothing will actually retry). ProjectRemovalReport now carries
retry_scheduled, and the UI is honest about which case it's in.
- remove_volumes_by_name now retries once after a short delay on a 409,
since Docker releasing a volume's mount reference right after its
container is removed is not always instantaneous, and this is exactly
the sequence remove_project runs.
- rebuild_project_container (Reset) returns ProjectResetOutcome so the UI
can warn when Reset could not fully clear a project's volumes, instead
of only logging it — the new container silently reuses old data
otherwise, which is what Reset promises not to do.
- retry_pending_cleanup_logged escalates a record's log level after it has
failed for a week, since recorded_at was otherwise write-only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
remove_project_volumes always returned Ok(()) regardless of what actually
happened, making the `if let Err(e)` guarding it at every call site dead
code. remove_project then dropped the project record unconditionally, so a
volume, image or container that failed to delete became permanently
unreachable — confirmed against a real orphaned volume pair found in the
wild (fixes#31).
remove_project_volumes/remove_snapshot_image/remove_container now report
what they could not remove (treating "already gone" as success rather than
a leftover), remove_project surfaces this to the user via a toast, and
before dropping the project record it writes a pending-cleanup record that
startup housekeeping retries automatically on the next launch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
The companion to the fixture removal. A site-admin token sat in a test file for
92 commits and fourteen days on a public mirror, past five audit rounds and two
independent reviews, because all of them read the code under change and this was
not under change. A grep would have caught it the first day.
`scripts/scan-secrets.sh` is that grep, in three rules:
* vendor-prefixed credentials — `ghp_`, `github_pat_`, `glpat-`, `xox*-`,
`sk-`, `AKIA`/`ASIA`, `ya29.`, `AIza`, `npm_`, `dckr_pat_`. Shape alone
identifies these, so there is no context to get wrong.
* `BEGIN … PRIVATE KEY` blocks.
* an opaque literal assigned to a secret-shaped name — the rule that would
have caught this one.
The third rule needs **both** halves, and that is what makes it usable rather
than another disabled check. Measured before writing it: an entropy-only rule
flags 317 literals in this tree, and name-proximity alone flags four, three of
which are `secure::get_project_secret(&id, "aws-secret-access-key")` — a
keychain *key name* sitting next to the word `secret`. Requiring the literal
itself to be hex or base64 with no word structure is what excludes those.
Validated rather than asserted:
* **0 false positives** across every tracked file.
* **Catches the real incident** — `--range 9b2f4fe~1..9b2f4fe` is refused.
* Twelve shaped cases pass and fail as intended, including a sha256 in an
`assert_eq!`, a git sha in a comment and the new dummy fixture, none of
which trip it.
* The hook was proved to block an actual `git commit`, not just to exist.
Two halves, because each covers the other's gap:
* `.githooks/pre-commit`, enabled per clone by `npm run hooks`. Git will not
let a repository set its own hooks path — cloning would then be enough to
run its code — so this is opt-in everywhere and `--no-verify` skips it.
* `Secret Scan`, which nobody can bypass. It carries **no `paths:` filter** on
purpose: the leak lived in `app/**` and `build.yml` only runs for
`container/**`, so a path-filtered scan would have missed the very thing it
exists for. It scans the whole tracked tree rather than a range, because a
wrong range fails *open* and the full pass takes 0.5s.
Also fixed while here: `core.hooksPath` in this clone pointed at
`/workspace/.git/hooks`, a directory that does not exist — so git hooks were
disabled outright and anything dropped in `.git/hooks` would have been ignored
in silence. A hook that never runs is worse than no hook, because the checklist
says it is there.
`--tracked` skips binaries. Feeding a blob to grep gets "binary file matches"
instead of the line, so a genuine finding inside one would arrive as a sentence
nobody can act on.
A line ending `pragma: allowlist secret` is skipped — wordy on purpose, so it
reads as a claim and leaves something greppable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
`the_custom_env_fingerprint_never_carries_the_value` asserted that the custom-env
fingerprint does not leak a secret — using the maintainer's real Gitea token as
the secret. It was committed on 2026-08-11 in 9b2f4fe, reached 92 commits, and
was readable for fourteen days in the public GitHub mirror at
`shadowdao/triple-c`, confirmed by fetching the raw file.
The token was a **site-admin** token (`is_admin: true`, user id 1) with admin
rights on every repository the account can see, not a repo-scoped one. It has
been revoked; the API now answers 401.
Release bundles were never affected — the literal is inside `#[cfg(test)]`, and
a search of the shipped 0.4.62 AppImage finds nothing.
The fixture is now an obviously fake string, and the test is unchanged
otherwise. Mutation-checked against the replacement: a fingerprint that returns
the raw value, one that returns the key name, and one that ignores its input are
all still caught, so nothing about the test's power depended on the value being
real — which was true the whole time.
History is deliberately **not** rewritten. The value was public for two weeks, so
rotation is the fix and the old value is now worthless; rewriting 92 commits of
published history would break every clone to hide something already seen.
Worth noting how this survived: five audit rounds and two independent reviews
all pointed at new code, and this sat in a test file that none of them had reason
to open. A high-entropy-literal check in CI would have caught it on the day.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
Five audit rounds of terminal, auth, settings and Files-tab work, plus the Files tab regaining its host transfers in a shape that opens the OS dialogs from Rust.
480 Rust tests, 602 frontend, no new clippy warnings.
Two independent reviews of 2c9482a, one for correctness and one against the
threat model. Between them they found two ways to lose a file, one way for a
container to choose a Windows save destination, and a rule that made every
dotfile unsavable. Every finding below was demonstrated against a real
container before being fixed, and the fixes are demonstrated the same way.
## The download was accepting truncated reads and refusing whole ones
The `[ -f ]` bracket around the read was wrong in both directions.
It missed the case that loses data. Truncation *in place* — `> file`, log
rotation, `tar -x`, most build tools — leaves a regular file behind, so `dd`
stopped at the new EOF and exited 0. Measured: a 600 MB source truncated
mid-read delivered 34 MB, which was then renamed over the user's own earlier
copy and toasted as `Saved (34.1 MB)`. Truncate-to-zero did the same and needs
no adversary at all.
And it failed *good* downloads. `dd` already holds the fd, and neither `rm` nor
`mv` can touch an open one — the bytes are complete. But `rm` makes `[ -f ]`
false, so a finished 600 MB transfer of a file a bundler happened to unlink was
deleted, reporting "nothing was saved". The comment claiming the bracket caught
a "mix of two files" was simply wrong; an open fd cannot be a mix.
So the script now measures the file before reading it and puts the answer on
stderr, and Rust checks that at least that many bytes arrived. One rule,
subsuming everything the bracket was for. Verified against a real container:
truncation mid-read and the FIFO race are refused; deletion, rename-over, a
growing file, an empty file and an untouched 600 MB read all pass.
## On Windows the container, not the user, was naming the save destination
`suggested_save_name` split on `/` only, and it feeds the save dialog's
pre-filled name. Backslash is a legal Linux filename character and
`validate_container_path` has no reason to object — `..\..\Users\…` is one
POSIX segment. The Windows common file dialog parses its name box as a path on
Save, so a container-created file called
`..\..\..\Users\vic\AppData\Roaming\Microsoft\Word\STARTUP\x.dotm` put its own
bytes in an auto-loading Office directory on one un-read click. `resolve_host_path`
did not stop it: Word's `STARTUP` and Excel's `XLSTART` are not in the autorun
denylist, which this file's own docs already concede is "losing by
construction" and was never meant to be the boundary here.
The name is now sanitized of every separator, the drive colon and the rest of
what NTFS refuses, so it cannot be a path on any platform this ships to.
## No dotfile could be saved, and the app pre-filled the name that guaranteed it
The write policy judged the leaf for hiddenness, so `/workspace/.env` was
refused *after* the modal and the overwrite prompt — quoting the name the app
itself had suggested. `.gitignore`, `.dockerignore`, `.eslintrc.json`, `.nvmrc`:
all unsavable, while uploading them worked, so a dotfile could go in and never
come out.
`HostPathUse` gains a third mode. `WriteChosenName` drops the leaf check and
keeps every directory rule, and only `download_container_file` uses it — the
dialog is a real boundary for that caller and only that caller.
`download_container_backup` still takes its path over IPC as a string and keeps
the strict rule.
## Smaller, all found by the reviews
* A download had no ceiling. `dd` resolves through the container's `PATH`,
which its agent owns with passwordless sudo; a replacement writing forever
was measured at ~6 GB/s, so one click on a file listed as 2 KB filled the
host disk with no progress shown and no cancel. Bounded now by what the
file measured, with slack that is absolute for small files and
proportional for large ones, so an honest growing log is unaffected.
* "Framed rather than verbatim" did not stop container text reaching the
toast headline: `readableRefusal` matches with `includes`, and it has to,
because the app's own refusals carry those markers mid-sentence. Anchoring
would break them. The fix is at the injection point — container text is
clipped to one 200-character line with control characters stripped — plus a
`max-h-40` on the toast message, which the `detail` block always had and
this half did not. 8 KB of prose in a `z-[60]` card pushed its own dismiss
button off-screen.
* `savingPath` was a scalar while the design deliberately allows concurrent
saves. Starting a second freed the first's row mid-transfer, and whichever
finished first cleared both; dismissing the second dialog was enough. It is
a `Set` now.
* `setUploading(false)` fired when the command settled, not when the refresh
finished, so a second click landed mid-relisting.
* `upload_files_to_container` checked the container directory before the
picker and then used the unresolved path. A modal has no time limit. It
re-checks after, which is what `docker::exec`'s doc comment already claimed.
* `wait_for_exec_exit` flattened a missing exit code to `Some(0)`, which made
the new `!= Some(0)` check unreachable by construction.
* `normalize_host_path` did not collapse repeated separators, so the lexical
system-root rule was silently absent for `C:\\Windows\…`. Not exploitable —
the resolved pass catches it — but a documented layer that does nothing is
a trap for the next caller.
* README still carried the "no host path crosses IPC in either direction"
claim the previous commit narrowed everywhere else, and HOW-TO-USE
described the hidden rule without saying it applies to folders only.
480 Rust tests, 602 frontend, no new clippy warnings. Eleven mutations against
the new tests, all killed — two of the first round survived and were rewritten:
one because `split_whitespace` already handled the case I thought I was
testing, one because I had deleted a comment rather than the behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
`upload_file_to_container` and `download_container_file` existed on main before
any of this work started. "Ship the Files tab container-side only" removed them
and called it narrowing scope; from a user's side it was a regression they
upgraded into. This restores the feature.
The reason for the removal was real — four consecutive audits found their
criticals in host paths crossing IPC — so the feature comes back only in the
shape that removes the class rather than patching it a fifth time. The dialogs
are opened by **Rust** (`pick_save_path`, `pick_files_to_upload`), not by the
webview. A frontend `open()`/`save()` handing the backend a path string is
exactly what failed, and the backend cannot tell such a string from one a
compromised webview invented. Now the webview can ask for a picker and that is
the whole of its influence: it cannot name a host path as an input. That is the
shape the previous round's own notes named as the honest one if this ever
returned.
None of the machinery the audits condemned returns. No `link(2)` destination
reservation, no placeholder rollback, no collision marker: the OS save dialog
already asks about overwriting and Docker's extractor overwrites on upload the
way `cp` does, so there was nothing left for it to do. Download reuses the
sequence `download_container_backup` has been using unchanged — resolve, stream
into a partial file beside the destination, rename last — so a failed transfer
never touches the file that was already there. Upload reuses the terminal
drop's hardened uploader, with the container's uid/gid resolved once per
selection rather than once per file.
Against a container that is actively hostile rather than merely surprising:
* the read is `dd iflag=nonblock`, not `cat`. `[ -f ]` and the `open` after it
are two syscalls and the container owns the filesystem in between; a loop
swapping the file for a FIFO wins that race, and `cat` then blocks forever
with no writer and no timeout anywhere on the path — the `invoke` never
settles and a partial is left in the user's directory for good. Verified in
a real container that `cat` hangs, that `iflag=nonblock` returns, and that
it is byte-identical on a regular file.
* the read is bracketed by a second `[ -f ]`, because non-blocking turns that
hang into an empty file that would otherwise be renamed over the
destination and reported as a successful save.
* an *undeterminable* exit code is a failure. Backup catches this class with
its `total == 0` check, which download cannot have because an empty file is
a legitimate save; without a replacement, a project restarted mid-download
renames a truncated partial over the user's file and reports the byte count
as if it were whole.
* container stderr is capped. Every other reader of container output in the
tree is capped for this reason; the two streaming commands were the
exception, and stdout was bounded by disk while stderr was bounded by
nothing.
* the script's refusals are framed rather than used verbatim, so a directory
named to look like one of our own sentences cannot become the toast
headline through `readableRefusal`.
* the partial name is capped at NAME_MAX. A bundler's 230-character content
hash is a name that fits its directory and produces a partial name that
does not.
Also: a non-UTF-8 dialog path is refused by name rather than silently mangled
into a different path by U+FFFD substitution; both actions carry in-flight
state, so a second click cannot open a second dialog and a slow save is not
indistinguishable from a dead button; and the upload's completion message names
the directory, since the picker is modal and the user can browse elsewhere
while it is open.
Not restored: drag-and-drop, in either direction. `drag:allow-start-drag` stays
ungranted and `hold/disk-and-dragout` still holds that work.
Two bugs the new tests caught while being written: a double-click on "Save to
host…" opened the file viewer on top of the save dialog, and an N-file upload
made N redundant execs to re-ask `id -u`.
Docs that asserted this feature did not and must not exist are corrected —
CLAUDE.md, README, HOW-TO-USE, TECHNICAL and the capability threat model. The
"no host path crosses IPC" claim is deliberately narrowed to the inbound
direction: paths do still travel outward inside error text, canonical ones
included, and the reviewed record should not overstate.
600 frontend tests, 473 Rust, no new clippy warnings. Every new test was
mutation-checked; four that survived their first mutation were rewritten,
including two whose mutations turned out to be unfaithful and one that was
blind to a dismissal leaving a row stuck on "Saving…".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
The handler returned `false` from xterm's custom key handler and a
comment claimed that was enough to stop the bare CR. It is not.
`_keyDown` returns the instant the handler says `false` — before it sets
`_keyDownHandled` and before it cancels the event — and `_keyPress` then
checks that same flag, finds it false, and emits a bare CR for Enter's
charCode 13.
So the headline feature of this branch did the wrong thing in a real
browser: Shift+Enter inserted the newline and then submitted the
half-written prompt, now with a stray blank line in it. Arguably worse
than before the fix. Reproduced in Chromium and confirmed against the
bundled xterm 5.5.0 source.
`preventDefault()` is what stops the browser firing keypress at all.
Applied at both call sites — the desktop terminal and the web terminal's
copy.
The test could not have caught this. jsdom never synthesizes the
follow-up keypress, so `expect(sent()).not.toContain("\r")` was asserting
a property the environment cannot falsify — a test named for a behaviour
it could not exercise. It now asserts `defaultPrevented`, which is the
mechanism that actually suppresses the keypress and which jsdom can
observe. Mutation-checked: removing the `preventDefault()` fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Docs and disclosure. HOW-TO-USE.md's settings table still described the
pre-fix behaviour — and help_commands.rs fetches that file from GitHub
main at runtime, ahead of the embedded copy, so it would have reached
every user's Help dialog the moment this merged. The Config tab named
three settings that need a base-image update; there are four, and the
omitted one (Session recap) is the one that fails *without* the "won't
switch off" symptom the warning teaches. Both now also state the cost
nobody had written down: changing any of these recreates the container,
which commits a layer.
Two stale comments that told a reviewer the code was safe when it was
not. compute_claude_code_settings_fingerprint still claimed the
historical fingerprint is preserved so an upgrade cannot churn every
container — carried over from before the widening, false since the
format string changed. And capabilities/default.json, which is the
reviewed threat model of record, described a "Save to host…" action this
branch deletes.
Security and correctness. update_settings validated env vars and nothing
else, so the *global* default_ssh_key_path — the fallback for every
project without an override — took `/` and read-only bind-mounted the
host, which entrypoint.sh then copies into the home volume. classify_
mount_source ran canonicalize on the raw string, which resolves a
relative path against Triple-C's own cwd, so `.` and `..` were accepted
or refused depending on where the app was launched; the daemon then
refuses the mount and the project can never start. Its test passed only
because its examples did not exist under app/src-tauri.
bind_mount_exclusions still derived a path from every row while
project_path_mounts had learned to skip unmountable ones, so a legacy
row made /workspace/<name> ordinary container content that a migration
would then exclude from staging and destroy. The skip is also logged now
rather than silently dropping a folder.
The terminal's file-in path checked is_dir() but not file type, so a
dropped FIFO blocked forever with no timeout — and it is the only route
in now. The web terminal labelled sessions from a global set at request
time, so two quick opens swapped them; harmless until Shift+Enter became
type-dependent, at which point a mislabelled Claude session submitted a
half-written prompt. Opened now carries the type.
Every ~/.claude.json write goes through one atomic helper. The
awsAuthRefresh branches still truncated in place — the same corruption
the Shift+Enter block was fixed for twenty lines later, and its own
comment said so. Demonstrated: a failed write now leaves the original
byte-identical.
And the registration test I added yesterday could pass while the
property was false: an audit got five real unregistered commands past its
exact-string attribute match, and "exactly once" was in its name but not
its body. Mutation-checked against all six shapes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
It listed every container on the daemon — including the user's unrelated
postgres, mysql and other work — and handed the summaries to the webview.
It had a registration, a command, a docker-layer helper, a typed frontend
wrapper and a `SiblingContainer` type, and zero call sites.
An audit named it as step one of an escalation chain: enumerate the
daemon's containers, then point `update_project`'s unvalidated
`container_id` at one and read its files through the file-command
surface. The second half of that chain is closed now, but a command that
exposes the user's unrelated containers and serves no feature is surface
with no upside.
Found by the registration test added in the previous commit, which is the
answer to "why test something the compiler already checks": the compiler
is perfectly happy with a command nobody calls.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This is the shape of the bug behind the original OAuth-callback report:
`set_auth_bridge_enabled` existed, worked, and had a typed frontend
wrapper — with zero call sites. The switch the docs told users to flip
was wired to nothing, so every login callback was refused. Both halves
compiled, so nothing noticed.
The reverse direction is the sharper one: a command that is registered
but reachable from nowhere is still IPC surface a compromised webview
can call. `list_sibling_containers`, which returns every container on the
daemon including the user's unrelated work, sits in exactly that state.
Mutation-checked both ways: removing a registration fails the test,
restoring it passes. The first parser I wrote split the list on commas,
which glued each `// Docker` style comment to the command after it and
then dropped that command as a comment — silently, once per group, 17 in
total. Line-based now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Two fail-open gaps, both latent on the shipped image and both live on an
ImageSource::Custom one.
`--one-file-system` is the only bound on the scrub's `rm -rf`, and it was
probed into `$rmopt` with `rm --one-file-system --help`. BusyBox answers
that with `unrecognized option` and exits 1, so on any busybox- or
toybox-derived image the variable was empty and the delete ran as root
across mount boundaries — reported as a completed `Reclaimed(n)`.
Measured in `busybox:latest` with a named volume one level below the
match at /tmp/claude-x/inner: emptied, `###TRIPLE-C-SCRUBBED 102423`. It
is a prerequisite now, probed by `rm --one-file-system -f -- ''` — the
code path that matters, with the one operand no filesystem can name — and
an image without it prints `###TRIPLE-C-SCRUB-UNAVAILABLE` and deletes
nothing. That costs Alpine and busybox images their scrub, which is the
cheaper half of the trade: declining costs disk, proceeding costs the
mount.
The second is that resetting `PATH` was never the whole of command
lookup. `bash` builds functions out of `BASH_FUNC_<name>%%` environment
variables, function lookup precedes `PATH` entirely, and `command -v`
reports a function as found — so a planted `stat` passed the prerequisite
probe and then answered the containment checks. Every in-shell answer is
itself importable: `unset`, `command`, and — the point that settles it —
`[`, `pwd` and `cd`, which are checks 0 to 2 rather than merely the
tools. So the script is no longer run by the shell that read the
environment. The exec's argv is a bootstrap using only reserved words,
parameter expansions and command words containing a `/` (which `bash`
refuses to import a function for), and it hands the script as `$1` to a
second `/bin/sh` started by `env -i`. Measured on `/bin/sh -> bash` with
`BASH_FUNC_stat%%` set on the container and a volume mounted at the
match: the old invocation emptied it and printed 306565, the bootstrap
left it intact and printed 65536. `/usr/bin/env` then `/bin/env`, because
H3's lesson about hardcoded coreutils locations applies to `env` too.
The comment claiming the `PATH` reset "defeats it just as completely as
spelling /usr/bin/stat out" is corrected, as is the one claiming a
sudo-written /usr/bin/stat does not survive a container restart — it
lands in the writable layer, which is exactly what the commit this runs
in front of captures.
Also here: a stored project path row with an empty host_path or
mount_name no longer becomes a mount. The first sends `field Source must
not be empty` back for the whole create, so the project cannot start at
all until the row is gone, and no amount of save-time validation reaches
a record already on disk; the second mounts over /workspace itself and
the daemon then creates the other rows' mount points inside the user's
real folder.
And in migration_commands, the deferred-reconcile claim is RAII rather
than a trailing statement — a panic inside `reconcile_migration_now`
stranded it for the rest of the process — and `await_release` looks
before it sleeps, so a project released a moment later no longer costs a
full twenty seconds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Four successive audits found the same thing: host filesystem paths crossing
IPC is where the criticals in this work live. The most recent one found the
`link(2)` upload reservation returning success against a *directory* (linking
into it, leaving permanent stray files, and via a symlink-to-directory writing
outside the validated write root), failing every upload permanently on any
filesystem without hard links, and the post-resolution credential check
weakened from a general rule to an eleven-name denylist.
Rather than fix that a fifth time, the Files tab ships as what it is good at:
a browser, viewer and renamer that never touches the host.
Removed: `upload_file_to_container`, `download_container_file`, and everything
that existed only for them — the whole reservation (`UPLOAD_RESERVATION_SCRIPT`,
`reserve_upload_destination`, the placeholder rollback, `exec_oneshot_as_within`
which had no other caller), `stream_container_file_to_host`, `ChannelReader`,
`save_to_host`, the download ceiling, and the collision marker with its
frontend contract. On the frontend: the upload button, the pane's
`onDragDropEvent` handler, both "Save to host…" affordances, `uploadPaths` /
`downloadFile` / the overwrite prompt, and `OverwriteConfirmModal`.
`lib/uploadErrors.ts` is now `lib/refusalText.ts` and keeps only the half that
turns any backend refusal into the sentence a person reads.
Kept, and not weakened: `upload_host_file_to_terminal` and
`download_container_backup`. They predate this work, their hardening is a real
improvement over main, and they are now the whole answer to "how do I get a
file in or out" — drop it on the Terminal, or Back up container. The drop gate
(`lib/dropTarget.ts`, `PaneVisibility`) is untouched.
`resolve_host_path` gets the general hidden-component rule back. Round 3
replaced it with `HOST_CREDENTIAL_DIRS`, which is allow-by-omission for the
rest of `$HOME`: `~/.local/bin` (write there and you own the user's next shell
command), `~/.password-store`, browser profiles and `~/.pki/nssdb` were all
reachable through a planted symlink with a visible name — verified against a
real home directory, and all five refused now. It over-catches `.pnpm` and
`~/.cache`; for two occasional callers that is the cheaper mistake, and the
refusal says which folder it resolved through.
Two defects fixed while in here:
* A symlinked directory listed as empty. `find` defaults to `-P`, which does
not follow a symlink even as the starting point, so `-mindepth 1` discarded
the only match and a real directory rendered as "Empty directory" — a
first-order defect now that browsing *is* the feature. `-H` follows the
starting point and nothing else, so a loop is `ELOOP` rather than a walk
that does not end; verified against a live container for a symlinked
directory, a broken link and a loop. `find`'s errno for the loop case is
now a sentence.
* `finish_download`'s replace path fired on *any* rename failure with a
destination present — a vanished partial, a permission error, a directory
at the destination — and deleted the user's file to complete a move that
could not complete. It is now fenced to Windows (where a rename onto an
existing path genuinely fails) and to a partial that still exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Four fixes that share a shape: a value already on disk, or one spelled
around a check, being taken at face value.
`/..` bind-mounted the entire host filesystem read-write. `is_filesystem_root`
was purely lexical — trim trailing separators, refuse what was left only if it
was empty or a bare `C:` — and nothing in the file ever called `canonicalize`,
so `/..`, `/./`, `/home/..`, `/etc/../` and `C:\..` all passed. The daemon
resolves them: `docker run -v /..:/mnt/probe` mounts the host root, and the app
mounts read-*write* into a container whose agent has passwordless sudo. It is
the escalation `check_mount_name_stays_under_workspace` exists to close,
reached through the host-path half of the mount instead of the mount-name half.
`classify_mount_source` replaces it and asks the OS: `canonicalize` applies
`..`, follows symlinks, and resolves 8.3 aliases and UNC spellings on Windows.
A path that cannot be resolved — `projects.json` synced from another machine,
a folder not created yet — falls back to a lexical collapse rather than being
refused, because refusing would make such a project unsavable; the gap is
bounded, since what resolution adds is a property of paths that exist. A path
that names no location at all (`C:x`, a relative path) is refused rather than
guessed at. Same check now guards `ssh_key_path` and `ca_cert_path`, whose
read-only mounts were whole-host disclosure at /tmp/.host-ssh.
Custom env var names had no charset check anywhere, so `BASH_FUNC_stat%%` —
bash's wire format for an exported shell function, body in the value — reached
the container environment verbatim. Latent today because the image's /bin/sh is
dash, but the pre-commit scrub runs `/bin/sh -c` as root and nothing pins that.
Keys are now shell identifiers, on the project and the global list both, with
the same grandfathering the folder rows get: a stored key is admitted, a new or
edited one is not.
The blank workspace row was persisted. The comment said it was dropped on save;
the code computed the filtered list and then saved the unfiltered one, so
"+ Add folder" plus a blur stored `{"Target": "/workspace/", "Source": ""}` and
the project could never be started or recreated again. Every save in the
section now goes through one filter, and a blur that changed nothing saves
nothing.
Widening the five `ClaudeCodeSettings` booleans to `Option<bool>` reinterpreted
every stored record. They were plain `bool`s that always serialised, so every
project ever saved carries an explicit `"env_scrub": false` that nobody chose —
and under the new merge that `Some(false)` beats a global `Some(true)`, where
the old rule let the global win. Upgrading silently turned five settings off,
"strip credentials from subprocess environments" among them. Deserialisation
now goes through a shim that dates the record by the presence of the
pre-widening `enable_session_recap` key and reads its `false`s as unset. The
fields skip serialising when unset, so an older binary can still parse
`projects.json` after a downgrade — a `null` would fail to parse and take the
whole list down, since `ProjectsStore` parses all-or-nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The ~/.claude.json write was `printf ... > "$CLAUDE_JSON"`, which
truncates before it writes. A write that fails part-way — a full home
volume, which is the exact condition half this release exists to prevent
— leaves the file unparseable, and it never self-heals: the next start's
jq fails on the corrupt file, MERGED is empty, and the guard skips the
write that would have repaired it. That file holds the OAuth account, so
the failure mode is a permanently lost login, in service of a cosmetic
flag that suppresses a tip. Demonstrated: old pattern loses the
credential, new tmp+rename leaves the original intact. The correct
pattern was already in triple-c-task-runner.
The web terminal scoped its xterm key handler to Claude sessions but not
its mobile input bar or its dedicated newline button, so both sent ESC+CR
into `bash -l`, where readline has no binding for it. Silent no-op, and
worse from a button that stays on screen looking live. Both now consult
the active session's type, and the button is disabled with a reason on a
shell tab.
The Config tab claimed "Off overrides a global On" without qualification.
True for the env-var-driven settings, false for TUI mode, Effort level
and Focus mode, whose off state is *removing* a key — an older base
image's entrypoint ignores the instruction to remove it. The copy now
says so and points at the base-image update.
HOW-TO-USE.md said there is no add-task form; AutomationTab renders a
"New task" button. That file is fetched from GitHub at runtime by
help_commands.rs, so the error was live in every user's Help dialog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The terminal's drop target derived the tar entry name from the host path
*after* symlink resolution, so dropping ~/Downloads/latest.log — where
latest.log is a symlink — landed the file in the container under the
target's name. Nothing errored; the user got a name they never typed. The
Files pane had the identical bug and was fixed with `host_upload_name`;
the terminal now calls the same helper, so the two drop targets cannot
drift. It also drops the "dropped-file" fallback, which silently renamed
anything the old `file_name()` could not parse.
`migration_store::load` told the user a backup existed when it had
deliberately not written one. `load` runs on every reconcile, survey and
reaper pass, so a persistently corrupt record reaches MAX_CORRUPT_BACKUPS
within seconds; from then on the copy was skipped while the log still
read "(a copy was kept at <path>)". Three outcomes are now distinct, and
the "enough already" case says so rather than naming a file that is not
there — that being the message someone reads immediately before going to
look for their data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
H3 — the pre-commit scrub was a silent no-op on any base image whose coreutils
are not under /usr/bin. Hardening against a PATH-planted `stat` shim by naming
every tool absolutely bought nothing the `PATH` reset on the first line had not
already bought — the shim lives in the persisted home volume, and uid 1000
cannot write /usr/bin or /bin — and it cost the whole feature on Alpine, which
Settings -> Docker -> Custom accepts. Measured on one seeded tree in a real
container: the absolute-path script printed `###TRIPLE-C-SCRUBBED 0` and left
every planted file in place; the PATH-resolved one reclaims 77824 bytes. The
default image is unchanged at 521038.
It was silent three times over, and all three are fixed:
* The script now probes for all six things it needs (`command -v` for the five
tools, plus the root device id reading back as a number) and, if any is
missing, prints `###TRIPLE-C-SCRUB-UNAVAILABLE <what>` and no total at all.
* `scrub_writable_layer` reads that marker first and returns a new
`ScrubOutcome::Unavailable`, warning with what the image is missing; a
genuine `Reclaimed(0)` now leaves a debug line rather than nothing.
* `commit_log_suffix` renders `Reclaimed(0)` as "ran and found nothing to drop"
rather than "0.00 MB dropped", which is what a scrub that could not run used
to look like.
Verified in real containers: the mount-at-the-match defence still holds with a
home-volume `stat` shim first on PATH (the volume survives; deleting the PATH
reset from the same script empties it, so the harness can tell the difference).
H2 — the pre-migration scrub had been folded into `log::info!`'s argument list
to satisfy `#[must_use]`. `log::info!` expands to `if Info <= max_level() { … }`,
so the awaited scrub lived inside the level check, and `logging::init`
tolerates `dispatch.apply()` failing — which returns before `set_max_level` and
leaves the process at `Off`. In that state the scrub never ran and the layer
was committed into the longest-lived snapshot the app takes. The outcome is
bound first now, `logging::init` restores the level on failure and says so on
stderr, and a test scans all four files for an `.await` inside any `log::*!`
argument list.
Also:
* `reconcile_migration` deferred a held project instead of dropping it. Its
only caller fires once per "Docker became available", so a project held at
that instant was never revisited for the session — phase un-normalised, no
resume or rollback offered, pin left `Claimed`. It now waits for the holder
to let go (20s x 90, one waiter per project) and reconciles then.
* The scrub's byte total counts what a partly failed `rm` removed, by
re-measuring rather than dropping the whole subtree on a non-zero exit —
which was exactly the `--one-file-system` case.
* The scrub exec blanks `LD_PRELOAD`, `LD_AUDIT` and `LD_LIBRARY_PATH`.
`LD_PRELOAD` is in none of the reserved env families, so a project's custom
env var reached a root exec and injected code into every tool the scrub runs,
`PATH` reset or not. Verified against Engine 29.7 that `docker exec -e` wins.
* The device test is described honestly: it is a mount test under `overlay2`
and not under `vfs`, where checks 1 and 2 are what still hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Three things, all reachable from a single credential-handling round.
**The "Retry snapshot cleanup" button deleted the token.** `clear_claude_token`
called `secure::delete_claude_oauth_token()` unconditionally; the `mode`
argument only changed the toast wording, so there was no sweep-only path on the
wire. The leftover panel rendered on `leftover !== null` alone and
`onAuthenticated` never cleared it, so the sequence revoke -> snapshot skipped
-> re-authenticate from the button directly above -> press the retry the panel
is still offering threw away the token acquired seconds earlier, announced by a
message about images. The deliberate Revoke needs a confirmation modal; this
needed nothing.
`sweep_claude_token_snapshots` is the honest primitive: it rewrites the images
and never touches the keychain. The images are the durable record, so the retry
re-derives its work from Docker and needs no stored token. Re-authenticating
now clears the panel, and the Authenticate button is disabled while a cleanup
runs — a sweep is a per-image inspect/create/commit/rmi over the Docker socket
and takes minutes.
**Sweep-first left the token live for that whole window.** The keychain delete
sat behind `list_images` plus the per-image loop, at bollard's 120s-per-request
default, while the UI said "Revoking...". A quit or crash in there and nothing
was revoked at all; worse, `has_claude_token` stayed true and
`shared_claude_auth` reads the keychain at container-*create* time, so a
project whose `SecretScrub` guard had already released could be started later
in the same sweep and be handed a fresh copy of the credential in its env.
Keychain-first now, and the comment that claimed "no window in which a scrubbed
image is re-poisoned" — true of images, silent about containers — is corrected.
The reorder's original justification (crash-mid-sweep recoverability) is what
the sweep-only command covers.
A keychain refusal no longer discards a scrub report, because nothing has been
swept yet, and both remedies stay on screen: Revoke, and the image sweep, which
is now offered in every state rather than only when nothing is stored.
**`update_project` validating every folder list bricked existing projects.** It
validated nothing until recently while `WorkspaceSection` saved `{paths}` on
every blur, so `projects.json` can hold a half-filled row, a mount name with a
space, a duplicate, or `/` as a host path. Any such project became entirely
unsavable — every Config toggle, every permission-mode change and
`useTerminal.ts`'s tab rename came back with a message about folders — and
refusing the save did not unmount anything. `validate_project_paths_update`
admits a row carried over verbatim from what is stored and holds a new or
edited row to every rule, which keeps the escalation closed: introducing a bad
value through this command is exactly what a non-carried-over row is. The
`/workspace/../tmp/claude-x` chain is the one exception and runs on every row
regardless, because a stored one is live data loss rather than untidy data.
`ssh_key_path` and `ca_cert_path` had no check at all; a filesystem root there
read-only bind-mounts the whole host at /tmp/.host-ssh. Refused on change, with
the same grandfathering.
Tests are mutation-checked: reverting to sweep-first fails all four new Rust
ordering tests, pointing the retry back at `clear_claude_token` fails five
frontend tests, and validating an update in isolation fails the legacy-data
tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
H6 (HIGH) — `resolve_host_path` canonicalised the host path and then re-ran
the *lexical* policy over the answer, hidden-component rule included. Because
canonicalisation resolves through symlinks, that rule started judging where a
path happens to live rather than where the user pointed: uploading out of a
dependency under pnpm (`node_modules/pkg` → `node_modules/.pnpm/…`) was
refused, and so was every download into, or upload out of, a visible directory
that leads to `~/.local/share`, `~/.cache`, `~/.var/app`, `~/.nvm` or
`~/.cargo`. None of it was refused before the H4 fix landed.
The two questions are now separate functions. `validate_host_path` judges the
string the user chose, unchanged. `validate_resolved_host_path` judges the
canonical form for the things only it can answer — the system roots (a Mac's
`/etc` *is* `/private/etc`), the login-item directories, and a new
`HOST_CREDENTIAL_DIRS` list. That last one is what keeps H4's escape closed:
`Downloads/pub` → `~/.ssh` with a leaf of `authorized_keys` is refused because
of where it lands, not because of how the directory is spelled. macOS handling
is untouched — `/private/tmp` stays out of `HOST_SYSTEM_ROOTS` and
`/var/folders` stays in the exceptions.
H8 (HIGH) — the upload reservation claimed its destination with
`sh -c 'set -C; : > "$0"'`, and the comment claiming that is `O_EXCL` was
wrong for a destination that is not a regular file. Against a FIFO the shell
opens it and blocks in `open(2)` forever; `exec_oneshot_raw` has no timeout, so
`upload_file_to_container` never returned and the Files pane sat on
"Uploading…" for the session with the rest of the batch abandoned. Verified in
a fresh ubuntu:24.04: the old form times out and the blocked `sh` stays in
`ps`; the new form answers in 35 ms.
The reservation is now a `link(2)` — it claims a name atomically, never opens
anything, and `EEXIST` is immediate whatever is in the way. A staging file at
an unguessable name in the same directory is linked into place and unlinked,
under a `trap … EXIT`. `exec_oneshot_as_within` adds a wall-clock ceiling as
the second line of defence, opt-in per call site so migration's `apt-get` is
unaffected. The upload contract is unchanged: default-refuse,
`overwrite: Option<bool>`, and `FILE_EXISTS: <full container path> already
exists`.
Also fixed, all in the same surface:
* A dangling symlink destination was a permanent dead end — `set -C` refused,
the confirming `test -e` followed the link and said no, and raw shell text
came back with no Replace on offer. `link(2)` does not follow the new-path
link, and the script confirms with `[ -L ]`, so it reports as a collision.
* An upload through a symlink renamed the file: the leaf came off the
*resolved* path, so `~/Downloads/latest.log` landed as `2026-08-23.log` and
the collision prompt named a file the user never chose. The name now comes
from the path the user gave; the resolved path is still what gets opened.
* `download_container_backup` leaked its partial file when the descriptor
check fired. It now tracks `created` the way `stream_container_file_to_host`
already did.
* The failed-upload cleanup was `rm -f` on a path that, the reservation having
succeeded, held whatever was written in the interim — a host file under
`/workspace/…`. It now removes only an empty regular file, and the comment
says what it is doing.
* `resolve_container_dir` parsed a combined stdout+stderr buffer as a path.
It uses the split-stream helper, like the listing next to it.
* `verify_opened_path` failed open on a readlink error (`if let Ok(actual)`).
A check that cannot see is not a check that saw nothing wrong; the macOS
compile-time no-op is now spelled out too.
* A trailing slash on a write path resolved to the directory itself.
Nine new tests, all mutation-checked against the pre-fix behaviour. Two more
cases added to the ignored live-Docker test: a FIFO and a dangling symlink,
both timed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The gate that decides whether a native file drop is accepted has been wrong
twice in opposite directions, both times because it tried to be precise about
*which points* a dialog covers:
- Round 1 asked `el.contains(elementFromPoint(x, y))` and was handed the inner
xterm host while the overlays are siblings, so the always-rendered
Following/Paused button made the terminal's top-right corner permanently
refuse drops.
- Round 2 replaced that with "is a blocking overlay painted here?" and deleted
the document-wide gate. `elementFromPoint` returns the *topmost* element, and
ToastHost is z-[60] against the Modal backdrop's z-50 in the same stacking
context — so a refused drop pushed a toast, the toast covered the dialog, and
the next drop released on it was reported clear and landed in the directory
the dialog was covering. The gate armed its own hole.
Split the two questions instead of merging them:
- Geometry answers *whose* drop it is (rect hit test, unchanged), so exactly
one listener speaks for a drop and a hidden pane's zero-size rect still keeps
TerminalView and FilesTab from both firing.
- `dropIsBlocked` answers whether the app should take a drop at all —
document-wide, no z-index in it. While a modal or blocking overlay is on
screen anywhere, every drop is refused.
There is no `elementFromPoint` call left, so no future overlay can become a
drop hole by being painted high enough and no chrome can become a dead zone by
being painted at all. The cost is over-refusal while a dialog is open, in a
state the user entered deliberately, announced, writing nothing.
Also:
- `[aria-hidden="true"]` no longer disqualifies a blocker. It is not a
visibility statement (it sits on visible decorative content), so a blocker
nested in such a wrapper would have silently stopped blocking.
- Modal drops `data-blocks-drop` when its pane hides, and moves focus out of
itself rather than leaving it inside a `display:none` panel.
- The refusal notice stays `kind: "info"` (an expected refusal is not an
error, and an error card never auto-dismisses) and carries a `dedupeKey`, so
repeated refusals replace rather than stack.
Tests: mutation-checked against the previous implementation — four in
dropTarget.test.ts, two in each of TerminalView/FilesTab, two in Modal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This is a scope reduction, not an abandonment. Both subsystems are
preserved in full on `hold/disk-and-dragout` and are intended to come
back once they have been hardened separately. Nothing here is a
judgement that the features are unwanted — three successive
audit-and-fix cycles each closed a critical defect in these two areas
and each opened a new one, so the rest of the round ships now and these
two get their own cycle rather than holding it up.
Removed: the Disk settings panel and its whole reclaim / destroy /
compaction surface — `DiskSettings`, `DiskProjectTable`, `useDiskUsage`,
`docker/disk.rs`, `disk_tests.rs`, the disk commands in
`docker_commands.rs`, and their `generate_handler!` entries. Dropping
the IPC entries is the point: a UI-only removal would have left five
commands callable by a compromised webview, one of them a verified
arbitrary-DELETE primitive. `sweep_orphaned_snapshots`'s *command* goes
with them (the panel was its only caller); the sweep itself stays.
Removed: OS drag-out from the Files tab — `stage_container_file_for_drag`
and its host staging lifecycle, the pointer gesture and `dragPreview`,
`stageForDrag` / `isStagedHostPath`, the `tauri-plugin-drag` and
`@crabnebula/tauri-plugin-drag` dependencies, and the
`drag:allow-start-drag` capability grant, which could not be scoped.
The capability test's expected list is updated; its `*:default` and
`store:*` assertions are untouched.
Kept, deliberately: drag-and-drop *into* the app (Files pane and
terminal) and "Save to host…", which is now the only route out of a
container. The prevention work is untouched — the pre-commit scrub and
`SNAPSHOT_SCRUB_PATHS`, capped container logs, the `triple-c.base` /
`triple-c.managed` labels, `sweep_orphaned_snapshots` and the startup
housekeeping, the migration pin/probe reapers, scheduler log pruning,
`formatBytes.ts`, and `project_lock.rs` in full with every acquisition
site outside `disk.rs`.
Entanglements, resolved rather than deleted blind:
* `container.rs`'s `a_compaction_runs_this_module_s_scrub_script_byte_for_byte`
pinned the compaction Dockerfile against `snapshot_scrub_script()`.
Dropped — it existed only for compaction. `snapshot_scrub_script` and
its containment tests are untouched.
* `lib.rs`'s startup reap of `:compacting` tags and `triple-c-compact-*`
containers is dropped: nothing on this branch creates them.
* `project_lock`'s `Compaction` / `CacheClear` variants and
`any_held_excluding`, `migration_commands::is_migrating`, and
`formatBytes{Delta,Ceiling}` lose their last production caller but are
kept and still tested, annotated with why.
* `projects_store::corrupt_since` and `migration_store::peek_ownerless_since`
were read only by the disk survey and are removed. The corrupt-load
marker and `.bak` are still written.
Verified: `npm run test` 611 passing, `npx tsc --noEmit` clean,
`npm run build` green; `cargo test` 419 passed / 2 ignored,
`cargo build` 0 warnings. Every test removed belongs to a removed
feature — no kept-behaviour test was weakened or deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Making `null` actually clear a secret — which it had to, since a blanked
token was previously never revoked — turned the config editors into a
credential shredder. Secrets are `#[serde(skip_serializing)]`, so the
inputs always render empty whether or not one is stored, and the blur
handlers sent `value || null` unconditionally. Focusing the Git token
field and tabbing away deleted it, with nothing shown and no undo.
`useSecretField` encodes the rule: only a field the user typed in may
speak about a secret. Untouched, `patch()` contributes no key at all, and
Rust already distinguishes an absent key from an explicit null.
`withoutUntouchedSecrets` covers the structural half. `saveBedrock`
spreads `{ ...bedrock, ...patch }`, and when that falls back to
DEFAULT_BEDROCK_CONFIG the literal spells every secret out as `null` — so
editing the AWS region would have wiped the credentials as a side effect.
Also here: `WorkspaceSection` no longer saves a half-filled folder row,
which `update_project`'s new validation would refuse on every keystroke
between the two inputs; `snapshots_skipped` is declared on the wire type
rather than widened locally; and `#[must_use]` on `ProjectGuard` and
`ScrubOutcome` — which immediately caught the migration path discarding
its scrub outcome, the one scrub whose silence is expensive because the
layer it declined to clean is about to be committed.
The capability test reads the real file and is mutation-verified: adding
`core:default` back makes it fail. That grant pulls in an unscoped
`std::fs::read` of any host path and went unnoticed for months, because
nothing in the suite read the file at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
`validate_host_path` was a string test. Nothing in the module called
`canonicalize`, `read_link` or `O_NOFOLLOW`, so a path whose components are
all visible could still land somewhere hidden: with `~/Downloads/pub` a
symlink to `~/.ssh`, a `host_path` of `~/Downloads/pub/authorized_keys` has no
hidden component, no `..` and no system root — and writes into `~/.ssh`. The
container end is not hypothetical: `/proc/self/mountinfo` inside a Triple-C
container spells the host's project paths out verbatim, so code in there knows
both where to plant the link and what host path to ask for. The same bypass
read host files back the other way.
So the policy now runs twice: once on the string, and once on what the OS says
the string resolves to. A write resolves the parent and keeps the caller's
leaf, because the leaf is never followed — the partial file is created with
`O_EXCL` and the download finishes with a rename, which replaces a link rather
than writing through it. A read resolves the whole path, because the whole
path is opened. On Linux the descriptor is then checked against the path that
was validated (`/proc/self/fd`), which is what closes the window between
resolving and opening; elsewhere that window stays open and the comment says so.
Also here:
* The upload's overwrite guard is a guard again. `noOverwriteDirNonDir`
refuses only dir-over-non-dir and the reverse — file-over-file extraction
proceeds, which is exactly the `.credentials.json` case (verified against a
live daemon). The probe and the write are now one `set -C` exclusive
create, with the path travelling as `$0` rather than as script. The
`FILE_EXISTS: <path> already exists` contract with the frontend is
unchanged, and now pinned by a test — as is the claim the old comment made.
* Windows normalisation stopped being a string swap: `\\?\`, `\\?\UNC\` and
administrative shares all reach the same places and are compared as such,
and the rules are pure functions over a string, so the Windows entries are
exercised on any platform. The old test passed on Linux only because
`Path::is_absolute` was false for a Windows path.
* Container write roots are resolved inside the container too, and the
comment no longer claims more than the check does.
* A failed download can no longer delete a pre-existing file that happened to
collide with the partial's name.
* One-shot exec output is buffered as bytes and decoded once, so a filename
split across two Docker frames survives; stdout and stderr are tellable
apart, so `find`'s diagnostics stay out of the listing parser; and a
directory too big to buffer is described as one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Handoff from the credential change on `r2/sec`, which landed
`secure::store_or_clear_project_secret` and left a `TODO` naming this
call site. `store_secrets_for_project` could only ever write: a token
blanked in the UI stayed in the keychain, `load_secrets_for_project` read
it straight back onto the project, and the container went on receiving a
credential the user had revoked.
**Not the mechanical switch the handoff describes, deliberately.** Every
secret field is `#[serde(skip_serializing)]`, so the project object the
frontend holds carries no `git_token` key at all — a save from the
Workspace, Runtime or Model section sends the field *absent*, while the
editor that owns it sends `git_token: null` when the user blanks it.
`Option<String>` maps both to `None`, so clearing on `None` would delete
every project secret each time an unrelated setting was changed.
`update_project` therefore takes the raw payload, records which secret
fields arrived as an explicit `null`, and clears exactly those; absent
still means "not mine to touch".
**This commit does not build on `r2/scrub` alone** — it calls
`secure::store_or_clear_project_secret`, which exists only on `r2/sec`.
Verified green against that file: 464 tests pass with `r2/sec`'s
`storage/secure.rs` in place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
C-2 (critical). `destroy_ownerless_rollback_pin` validated its tag and not its
project id, then interpolated both into `triple-c-snapshot-{id}:{tag}` and handed
the result to bollard. bollard does not percent-encode: `Uri::parse` joins an
absolute path onto the base URL, which replaces the path outright and applies RFC
3986 dot-segment removal. An id of `a/../../v1.47/volumes/<name>?` turns a
"remove image tag" into `DELETE /v1.47/volumes/<name>`. That arm is reached
*because* `find_project` failed, so the id is unconstrained IPC input, and the
typed confirmation is no barrier — it compares the caller's own two strings.
Reproduced against the live daemon, and now a test: with the check removed the
volume is gone and the test fails; with it, the volume survives and a legitimate
ownerless pin still deletes. The reference that reaches `remove_image` is now the
daemon's own repo_tag, matched on the parsed pair, so nothing built from IPC
input addresses the API at all. The same id check now guards the owned arms of
`destroy` and `compact_snapshot`, which build volume names and image references
from a `projects.json` field.
H-1. The ownerless arm decided ownership from the in-memory list alone and then
called `sweep_orphaned_snapshots()`, which deletes the freshly dangling image on
the same pass — so a corrupt `projects.json` could reap a pin whose migration is
still awaiting confirmation, the one thing `pin_is_reapable` orders its
conditions to prevent. It now re-reads the store from disk, runs
`project_store_trust`, refuses an id the store knows, takes the project lock
before reading anything a decision rests on, and checks `has_record`.
H-3. The corrupt-store guard keyed on "empty list + file exists", and
`ProjectsStore::new()` swallows a corrupt file without rewriting it — so the
first `save()`, as little as starting a project, wrote `[{new}]` over it and the
guard passed with every other project's volumes unclaimed. A corrupt load is now
recorded in a sticky `projects.json.corrupt` marker beside the file, and the
existing `.bak` is no longer clobbered by a second corruption. A missing
`projects.json` is refused too: it cannot be told from a moved or partially
restored data directory, and the genuinely fresh case has nothing to find.
Also: the three migration commands surface the lock's real refusal instead of
substituting "a migration is already running"; `note_ownerless_since` re-checks
`has_record` after writing a tombstone, closing the window that could plant one
behind a valid record and reap the pin with zero grace; corrupt migration-record
copies are capped at four; `reconcile_migration` yields to any lock holder, not
only a migration; and a 22-space run in a refusal string is gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The parent checks in `snapshot_scrub_script` validate the directory a
pattern is anchored to, and `rm --one-file-system` compares against its
own command-line argument's device — so with the mount planted *at* the
match there was nothing between the scrub and the mounted filesystem.
Verified against a live daemon with the byte-identical generated script:
mounted at the parent (`/var/log/apt`) refused, mounted one level below
(`/tmp/claude-x/inner`) refused, mounted as the match (`/tmp/claude-x`)
came back with the volume empty. The same run against a host directory
bound at `/workspace/../tmp/claude-x` — the target the daemon builds from
a mount name of `../tmp/claude-x`, confirmed through the API bollard uses
— emptied the host directory.
Each match is now checked against the root's device too, which for a
directory whose parent has already been validated is exactly a
"not a mount point" test. A symlinked match still reports the link's own
device, so `rm -rf -- link` goes on unlinking it and stopping.
The tools are also named absolutely and `PATH` is reset. The image's
`PATH` starts with three directories inside the container's persisted
home volume, and a three-line `stat` shim planted in the first of them
made the previously-refused `/var/log/apt` mount delete its contents.
"Missing `stat` fails closed" was true and beside the point.
`update_project` validated nothing while `add_project` validated its
folder list, so the mount name that reaches all of this was one
save-on-blur away from anything at all. Both now share
`validate_project_paths`, which also refuses `..`, a filesystem root as a
host path, and a half-filled row; `container_id`, `status` and
`created_at` are no longer writable through a project save.
`remove_project` was the only writer of a container, a snapshot image or
a volume that took no project claim (H-2), so removing a project during
a compaction let `restore_image_config` commit a flat image back over
`triple-c-snapshot-{id}:latest` for a project that no longer exists —
invisible to every reclaim path. It takes `ProjectOp::Destroy` for the
whole removal; the sidebar row survives a refusal because it is only
dropped after the command resolves.
Finally, a commit whose scrub was skipped no longer logs "0.00 MB
dropped", which every migration did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
`core:default` was an alias for nine core plugins' default sets, and one of
them — `core:image:default` — carries `allow-from-path`, whose handler is a
bare `std::fs::read(path)` with no scope mechanism at all. Nothing imports
`@tauri-apps/api/image`, so the plugin is dropped rather than scoped; there is
nothing to scope it with. The capability file now enumerates what `app/src`
actually invokes, which is `core:event`'s listen/unlisten and nothing else from
core — every emit in this app originates in Rust. `core:menu`, `core:tray`,
`core:window`, `core:path`, `core:resources` and the three dead `dialog:`
grants go with it. `core:webview:allow-internal-toggle-devtools` stays because
Tauri's own injected debug script calls it; both it and the command behind it
are `cfg(any(debug_assertions, feature = "devtools"))`, so it is absent from a
release bundle. Verified empirically: an unknown identifier fails the build, so
every identifier kept is real and the regenerated `gen/schemas/capabilities.json`
carries the opener scope verbatim rather than silently dropping it.
`opener:allow-open-url` cannot be host-narrowed — the terminal opens links
Claude printed inside the container — so what it does and does not buy is
recorded instead, including the verified fact that each scope entry's `app`
defaults to `Application::Default`, which matches only `with == None` and
therefore refuses `openUrl(url, "/bin/sh")`.
`clear_claude_token` deleted the keychain entry first and swept the snapshot
images second. The sweep runs once and skips a project another operation holds,
the deleted entry made `has_claude_token` false, and Revoke rendered only while
a token was stored — so a project that happened to be starting during a revoke
kept a live ~1-year OAuth token in its snapshot's `Config.Env` permanently,
with Reset (which destroys both volumes) as the only remaining remedy. The
sweep now runs first, so a crash mid-revoke leaves the app still saying
"authenticated" with the same button still able to finish; a busy project is
reported as `snapshots_skipped` rather than folded in with images that genuinely
cannot be rewritten; and the panel keeps a retry visible independent of token
status, plus offers the sweep outright when nothing is stored, because a
snapshot committed by an older build carries the token either way. The retry is
the same command — it is idempotent, and the images are the durable record.
Also: `openai-compatible-api-key` was written but never deleted, so it outlived
its project. The key list is now the single definition and an unlisted key is
refused outright, so the writer cannot get ahead of the deleter again.
`store_or_clear_project_secret` lands here unused on purpose: the editors send
a blanked field as `null` and `store_secrets_for_project` skips `None`, so
clearing a secret through the UI is impossible today. Its one call site is in
`commands/project_commands.rs`, which belongs to another change in this round.
No `devCsp` was added. `tauri dev` loads the main document straight from Vite,
and Tauri only attaches a CSP to documents it serves itself — the dev server is
proxied through `tauri://` only when `PROXY_DEV_SERVER`, which is
`cfg!(all(dev, mobile))`. A `devCsp` here would be inert config that reads as
protection. The reasoning, and the one place that could set one, are recorded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The z-order gate added last round asked `el.contains(elementFromPoint(x, y))`
— "is the thing painted here mine?" — and was handed `TerminalView`'s inner
xterm host while every overlay in that pane is a *sibling* of it. So any point
under the pane's own chrome answered "not mine" and the drop was refused, with
no message and no log line. The "▼ Following / ▽ Paused" toggle is rendered
unconditionally at `absolute top-2 right-4`, and `ToastHost` is `fixed
bottom-4 right-4` 24rem wide with error cards that never time out: two corners
of the terminal, and one of the Files pane, that could not accept a file for
as long as the app was running.
It shipped green because jsdom has no `elementFromPoint`, so not one of the 81
drop tests entered that branch. The tests here install one.
The question the gate asks is now "is a *blocking overlay* painted here?".
Chrome the pane paints over itself is not one; a dialog backdrop is, and
`ui/Modal` marks its own backdrop so the element `elementFromPoint` actually
returns is the one carrying the marker. `classifyDrop` also separates "aimed at
me and swallowed" from "not my drop", so the first gets a toast and a log line
and the second stays silent.
Three defects around it:
- **A dialog now refuses only the points it covers.** `dropIsBlocked` is
document-wide and `ui/Modal` portals to `document.body`, so any open dialog
refused every drop in the window. The deeper half of that is that a dialog
opened in project A really was still on screen after a tab switch — the pane
hides itself with a `hidden` class, which a portal does not inherit — so
`PaneVisibility` lets `App` tell a `Modal` its pane stepped aside, and a
hidden one paints nothing, traps no focus, answers no Escape and blocks no
drop while staying mounted with its state intact.
- **`devicePixelRatio` is applied on Windows only.** Only wry's WebView2
backend hands over physical pixels; the macOS and GTK ones deliver logical
points and `tauri-runtime-wry` does not rescale them. Halving those was
survivable while the test was a bare rect and is a refused drop once z-order
joins in. Read from the wry/tauri sources, not verified on a HiDPI Mac or
GTK box.
- **`isFileExistsError` can no longer be forged by a filename.** It matched
`fileexists` anywhere in a normalised error, so uploading a host file called
`file-exists.txt` turned *any* failure into a collision — and Replace
re-invoked the upload with `overwrite: true`. The marker now has to stand
alone in the backend's canonical form, or be a whole discriminant value.
- **A refused compaction or cache-clear keeps its dialog.** `reclaim` reports
refusals inside `Ok`, so "did it throw" read one as success: the dialog
closed, the tick list was dropped, and the explanation appeared in the
outcome panel several screens above the row that was clicked. The dialog now
stays put and renders the backend's own sentence verbatim.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
An audit agent downloaded findutils to check GNU find's operand parsing;
a76f2c0 swept the 298 KB artifact into the tree along with the capability
changes, unmentioned in its message. Second time this session that
`git add -A` has staged something unrelated — ignore the pattern so it
cannot happen a third time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
`survey_rollback_pins` walks images, not projects, and deliberately
tolerates an absent project by falling back to the raw id as the display
name. Two things then dropped it on the floor: `destroy` called
`find_project` before the confirmation check, so it refused such a pin
every time, and the per-project table joins destructive items to rows by
project_id, where rows come only from projects in the store. The result
was a multi-GB `pre-migration-*` image that the scan measured, the panel
never rendered, and nothing could remove — in the one screen built to
find exactly that.
`destroy` takes the same early return `OrphanVolume` already takes, and
still validates the tag: `latest` names the project's live snapshot, so
the ownerless path must not be a way around that check.
The UI grows a bucket for destructive items matching no row, rather than
filtering them away. The typed gate already compared against the id via
`project_name`; the dialog now says "project id" instead of asking for a
project name that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Two more pieces of drift from the same merges, both invisible to `tsc`.
**A refused Start/Stop/Reset strands the row.** `start`, `stop` and
`rebuild` paint an optimistic "starting"/"stopping" so a click moves the
row at once. That was safe while the only way these could fail was after
the backend had begun changing things. `fix/sec`'s per-project lock ended
that: all three now take the lock and are refused *before* any state
changes, and `stop` could not fail this way at all before — it took no
exclusion. So the optimistic paint has nothing to become, `isTransitioning`
disables both Start and Stop, and the only thing that clears it is
`reconcileProjectStatuses`, which runs once from `App.tsx` when Docker
first appears. Clicking Stop during a compaction left the project
unusable until the app was restarted.
`withOptimisticStatus` re-reads the authoritative list when the command
throws, and falls back to the status that was on screen if even that call
fails — two failures in a row must not land on the one state there is no
way out of. The error is rethrown unchanged, so the toast is unaffected.
Five of the six new tests fail against the previous code; the sixth pins
that the optimistic paint still happens on the way in.
**Six secrets are typed as if they arrive, and they never do.**
`git_token`, the four Bedrock credentials and `OpenAiCompatibleConfig
.api_key` are `#[serde(skip_serializing)]` in Rust, so the key is absent
from every project the backend returns — reading one gives `undefined`,
not the `null` the type promised. Every current reader happens to use
`?? ""`, so nothing is broken today; a single `=== null` would have been a
branch that silently never ran. They are optional now, which makes that a
compile error, and documented as write-only, which is what they are.
669 frontend tests pass, `tsc --noEmit` clean, `npm run build` green.
Nothing under `src-tauri/` touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Five backend branches merged and the TypeScript still compiled, because
none of this is a type error: a field that arrives `undefined`, a variant
nothing emits any more, a prompt whose loop never closes. Six things.
**Orphaned volumes are destructive now, not safe.** `ReclaimTarget::
OrphanVolume` is gone from Rust; the object is a `DestructiveTarget::
OrphanVolume { name, project_id }` confirmed against the *volume's* name,
there being no project to name. The TS union still listed it under
`ReclaimTarget`, and — worse — `DiskProjectTable` keys destructive items
off `project_id`, which an orphan's never matches. So the item existed in
the plan and appeared nowhere on screen. `DiskSettings` now splits the
plan's destructive list and gives orphans their own section with a
per-volume `TypedConfirmModal`. The copy says what a
`triple-c-claude-config-*` volume actually is — a Claude login
credential, every plugin and skill, every transcript that project had —
and keeps the sentence explaining that "no matching project" is a lookup
against the project list and is never inferred from a project being
stopped or having no image, which is the inference that once flagged two
live projects.
`TypedConfirmModal` grew a `subject` prop: asking a user for "the exact
project name" of a volume that has no project is asking for a string that
does not exist.
**Snapshot and Total reconcile.** `ProjectDiskRow.snapshot_attributed_bytes`
is the single figure `snapshot_attribution()` exists to produce. The
column rendered `snapshot_above_base_bytes` and fell back to `—` while
the Total was `size - shared` regardless — and in that branch `size -
shared` is the whole 4.7 GB base image, charged per project and then
added again as a base-image row. One field, one rule. The one branch
where the figure *is* the whole image says so rather than passing itself
off as a share.
**The overwrite loop closes.** Traced end to end: a `FILE_EXISTS:`
refusal raises the prompt, Replace re-invokes with `overwrite: true`,
Skip advances, "…all" answers the rest without asking, and picker and
host-drop both reach `uploadFileToContainer` through `uploadPaths`. Two
gaps: a second batch's `askOverwrite` overwrote the first's resolver,
leaving that batch awaiting an answer no dialog could produce; and the
backend's written refusals — a hidden host folder, a path outside the
write roots — were passed as a toast `detail`, which `ToastHost` renders
as collapsed monospace behind a "Details" button, so the only sentence
that explained anything was the part nobody saw. `readableRefusal`
promotes it to the headline when a batch failed the same way.
**The browser pane's sandbox is pinned.** `allow-same-origin` must stay
(the proxy's gate reads `Origin`/`Referer`, and an opaque origin sends
`null`); every top-navigation grant and `allow-popups-to-escape-sandbox`
must stay absent, and the test names the offending token rather than
printing a set diff.
**`@tauri-apps/plugin-store` is gone** from `package.json` — its
capability grants were removed as a host-file-write primitive and nothing
in `app/src` imports it. The lockfile was updated with
`--package-lock-only`, deliberately: `node_modules` is a symlink shared
with other worktrees and a real install would have pulled it out from
under them.
Nothing under `src-tauri/` is touched. 663 frontend tests pass (was 635),
`tsc --noEmit` clean, `npm run build` green, `cargo test` 446 unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Three items each of which fell between two agents' file lists.
`scrub_secrets_from_snapshots` was the third unsynchronised writer of
`triple-c-snapshot-{id}:latest`, after a recreate's commit and a
compaction. It has the same read-modify-write shape — create a scratch
container from the snapshot, commit back over the same tag — and loses
the same race, which here means re-baking the very credential it exists
to remove. It now takes the project's claim, under a new
`ProjectOp::SecretScrub`, and reports a snapshot it had to skip rather
than rewriting it unsafely.
Its scratch container also now carries `triple-c.scrub=true`, so the
Disk panel's reclaim bucket discriminates by label and by the live claim
rather than by a clock. The 15-minute age gate stays as the backstop for
the cross-process case the claim cannot see.
The store plugin is unregistered and its dependency dropped. Its
capability grants were removed as a host-file-write primitive; the
registration without a grant was unreachable but dead.
Finally, container.rs's fold test was pinning a fold that no longer
exists — disk.rs now emits the JSON exec form. It asserts the stronger
property instead: the script a compaction runs is byte-for-byte the one
snapshot_scrub_script() produces, so the two files cannot drift silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The compaction panel's headline action had never worked, three reclaim
paths could delete data with no confirmation and no grace period, and the
app's only mutual-exclusion primitive was one-way.
**A per-project lock (`project_lock.rs`).** `ACTIVE_MIGRATIONS` was the
app's only exclusion and everything but migration merely *polled* it once
at entry. Compaction, start/stop/recreate, Reset and destroy now
**acquire** a `ProjectGuard` and hold it for the whole operation;
`is_migrating` is a view onto the same registry. Closes the three verified
interleavings where a compaction commits `flat(A)` over a `:latest` that a
migration, a recreate or a Reset had already moved. In-process only — the
two-instance case is documented in the module, not solved, and the
daemon-wide reapers gained age gates to bound it.
**H1: compaction never ran.** `fold_shell_script` joined the scrub script's
lines with a space, so every build died on `syntax error: unexpected "do"`.
Replaced with the JSON exec form, which carries any script verbatim;
`sh -n` and a real end-to-end build now cover it (159.5 MB / 9 layers ->
33.7 MB / 1 layer, setuid and multi-line env preserved).
**H2/H4:** `reclaim_migration_pins` and `survey_rollback_pins` apply
`parse_rollback_tag` and `pin_is_reapable` like every other path, and stop
double-counting an image with two pin tags. The 14-day grace period is
re-anchored from the tag's timestamp (when the migration *started*) to a
tombstone recording when the record went missing, with clock skew handled
in both directions.
**H3:** `migration_store::load` no longer renames a corrupt record aside —
that destroyed the `has_record` signal both pin reapers depend on. `save`
fsyncs the file and the directory, and corruption backups are timestamped.
**H2b/M2:** a crashed compaction's `:compacting` tag and `triple-c-compact-*`
container are reaped at startup; the stale-container sweep moved from the
end of a compaction to the start, where its doc always claimed it was.
**M5/M6:** orphan-volume deletion moved from a `Safety::Safe` tick to the
destructive path with a typed volume name; `project_store_trust` reads the
real `projects.json` so a second instance's project is not offered as an
orphan.
Numbers: `images_total_bytes` uses `df()`'s deduplicated `layers_size`; the
Total column is derived from the same figure the Snapshot column shows;
partial container/staging reclaims report their failure count; `human()`
no longer prints "1000.0 KB"; `docker_cli` has a timeout; blocking `fs`
calls moved to `spawn_blocking`.
Also fixes `ProbeContainerGuard::remove_now`, which disarmed before the
await and so did nothing on the cancellation path it exists for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
`wait_for_exec_exit` returning `None` used to mean "call it 0"; it now
fails the call, so a busy daemon that has not settled within ~1s would
turn into a spurious "the rename failed". The loop exits on the first
poll that reports finished, so a wider window costs nothing when things
are normal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
`list_container_files` could delete the user's files. It builds
`["find", path, "-mindepth", …]`, and GNU find ends its starting-point
list at the first argument beginning with `-`, so a `path` of `-delete`
gave it zero starting points (defaulting to `.`, which for an exec that
sets no working_dir is the container's WorkingDir — the bind-mounted
project) and an expression starting with `-delete`. Verified against a
live container on findutils 4.10.0: files and empty directories went out
of the host bind mount, and because `exec_oneshot` discards the exit
code the panel then reported an empty folder.
The rest of the module had the same shape of hole:
* Every path parameter — `path`, `from_path`, `parent_path`,
`container_dir`, `container_path`, `host_path` — arrived over IPC
unchecked. There is now one validator for container paths (absolute,
no `..`, no NUL, length-capped), a second for the ones that *change*
something (contained in /workspace, /home/claude or /tmp), and one for
host paths, which refuses traversal, system locations and hidden
components. The `save()`/`open()` dialog in front of these commands is
a UI convention, not a boundary.
* `download_container_file` passed `None` for the fetch cap, so the cap
was inert: the whole transfer was buffered in host RAM twice, and the
directory refusal came *after* the buffer, so `/` meant buffering the
container's filesystem before erroring. Downloads now stream through a
bounded channel into the tar reader, which refuses a non-regular entry
and an oversize one before the host file is created at all. Verified
against a real container: a 300 MiB download peaks at 10 MiB RSS, a
9 GiB sparse file is refused in 0.01s with nothing written.
* Both download paths (file and backup) used to create — i.e. truncate —
the user's destination up front and delete it on a stream error, which
is precisely the wrong order for a path that already holds something.
They now write beside it and rename on success.
* `upload_file_to_container` silently clobbered: no existence check
anywhere in the stack. It now refuses by default with a FILE_EXISTS
marker the frontend turns into a Replace/Skip prompt, and takes an
`overwrite` flag for the retry.
* `exec_oneshot_inner` read an undeterminable exit status as 0, so
rename and mkdir reported success for an exec nobody could read the
outcome of. It fails closed now.
* A tab in a filename forged the type/size/permission columns of a
listing row, and a newline forged a whole row. `find` now prints the
name last with NUL-terminated records.
While verifying the size ceiling against a real container, the tar
header's size field turned out to be unusable past ustar's 8 GiB octal
limit — Docker's Go writer puts the real size in a PAX record — so both
readers take it from `entry.size()` instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
capabilities/default.json
- Drop every `store:*` grant. `@tauri-apps/plugin-store` has no caller in
`app/src`, and the plugin's `resolve_store_path` is a `PathBuf::push` against
AppData — `push` discards the base for an absolute path, so the grant was an
arbitrary host read/write from the webview.
- Replace `opener:default` with a scoped `opener:allow-open-url` (http/https
only). That drops `reveal_item_in_dir`, which the plugin does not scope-check
and nothing here calls, and the unused mailto:/tel: scope.
- Record the unscopable `drag:allow-start-drag` residual risk in `description`.
tauri.conf.json
- Add `form-action 'none'`, `base-uri 'none'`, `object-src 'none'`.
`form-action` has no `default-src` fallback, so an injected auto-submitting
form was unblocked even though `script-src 'self'` blocks XSS.
- Remove the dead `asset:` / `https://asset.localhost` img-src and `data:`
font-src grants; `blob:` stays (the file viewer uses it).
auth_bridge
- The reserved-port set covered only this project's mappings and the two
browser-view ranges. It now also covers the gateway, STT and web-terminal
host ports (configured value and shipped default, read off the settings
models) and every other project's published host port. A container binding
container-loopback 4000 / 9876 / 7681 while those services were stopped had
that port mirrored onto the host, unauthenticated, within one poll.
- Gate the host listener on fetch metadata: refuse a request that is a
cross-site sub-resource, allow navigations (the OAuth redirect) and anything
without `Sec-Fetch-*`. Non-HTTP connections are classified from their first
line and forwarded verbatim. Residual risk is spelled out in the module docs.
- Bound the forwards: max concurrent connections per port, a first-byte
deadline enforced before any `docker exec` is created, and an idle timeout.
browser_view/mod.rs
- `pick_viewer_port` reads procfs with `/usr/bin/cat`, not a bare `cat` the
container can shim via its writable PATH entry.
- Treat port choice as check-then-bind: walk to the next free candidate when
the viewer does not come up, instead of failing the start.
BrowserTab.tsx
- Sandbox the viewer iframe. Container-controlled content could `top.location`
the app's webview away. `allow-top-navigation*` and
`allow-popups-to-escape-sandbox` are deliberately absent.
HelpDialog.tsx
- Escape the quote characters in the entity pass and escape captured attribute
values. `href="$2"` with `$2` = `[^)]+` let remote GitHub markdown close the
attribute and open another, in a document rendered with
`dangerouslySetInnerHTML`.
web_terminal/terminal.html
- SRI hashes plus `crossorigin` on the three jsdelivr bundles and the
stylesheet, and a CSP for the page — it is served 0.0.0.0 behind a permissive
CORS layer and nothing else gives it one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
SNAPSHOT_SCRUB_PATHS is expanded by /bin/sh inside the container as root.
For an entry ending `/*` the parent is a path *component*, resolved by both
the glob expansion and the `rm -rf`. The agent has passwordless sudo, so
`ln -s /workspace/myproject /var/log/apt` turned the next commit into a
recursive delete of the user's real files on the host — reproduced end to
end against a live container.
snapshot_scrub_script now routes every deletion through one `scrub_in`
function that validates the parent before touching anything inside it:
`cd -P` for a TOCTOU-free handle, `pwd -P` equality to reject a symlinked
component, a hardcoded containment allowlist that is deliberately not
derived from the path list, and an st_dev comparison against `/` so a bind
mount or a volume is refused even though it is not a symlink. It fails
closed when `stat` is missing.
Also:
- /tmp/triple-c-drops/* and /tmp/clipboard_*.png are age-limited to 14 days
instead of scrubbed unconditionally. They hold the user's own files, and
scrubbing them meant "drop a file, change a setting, lose it silently";
removing them from the list would restore unbounded growth instead.
- M11: scrub_writable_layer returns a ScrubOutcome and skips cleanly when
the container is not running, so a migration no longer logs "could not
run … committing anyway" on every run.
- M12: the scrub exec is bounded by a 120s timeout; on expiry it logs and
lets the commit proceed.
- The script is now fold-safe (self-terminating lines, no `#` comments).
disk.rs folds it onto one `RUN` line and the previous form was a
`"do" unexpected` syntax error there, so compaction had been scrubbing
nothing at all.
Tests: the substring check on the script text is replaced by a behavioural
test that runs the real generated script with a real symlink planted in a
throwaway tree, plus structural tests over each containment construct and
a `sh -n` check of the folded form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Files pane
- F16: a drag-out released back inside the app no longer re-imports its own
staged copy over the container original. An in-flight flag (cleared from the
drag plugin's `onEvent` channel, with a watchdog) suppresses the drop and the
"Drop files into …" hint, and an exact staged-path filter is the second line
of defence — the `path|size|modified` cache could otherwise write a
minutes-old snapshot over a file an agent had since rewritten.
- F17: a slow upload/rename no longer yanks the user back to the directory the
operation started in. Every operation captures its target path and re-lists
only if the user is still there; failures go to the toast host either way.
- The grid keeps keyboard focus. Roving tabindex (one tab stop, not one per
row) plus focus restore after navigation, rename commit/cancel and Escape.
- Transient failures now surface in `ToastHost` (z-[60], persistent aria-live)
instead of a `role="alert"` 300 rows down a scroller or behind a modal
overlay. The inline error is kept only for the listing failure.
- `navigate` is sequenced by generation; "Save to host…" sets `busy`.
- Grid a11y: column headers, a text affordance for folder vs file, a live
region that is mounted empty and announces completion, Label-in-Name fixed.
- FileViewerModal: the blob URL is released only once its replacement exists;
the preview is a focusable, named, scrollable region.
Native drop routing
- New `lib/dropTarget.ts`: the hit test now refuses a drop while any
`[aria-modal="true"]` dialog or `[data-blocks-drop]` overlay is up, and
checks z-order where the environment can answer it. Shared by FilesTab and
TerminalView; App's shutdown overlay opts in.
Disk
- A partially failed reclaim says so in words ("… — 2 of 5 failed"), not by hue
alone.
- The scan/reclaim race is closed: every mutation retires an in-flight scan, so
a scan can no longer repaint a pre-reclaim report plus a clickable plan of
objects that are gone. Scan is disabled while working; the status is a live
region; a failed destructive action keeps its dialog open and reports there.
- The "unknown" layer count gets a screen-reader fallback; `--text-disabled`
no longer carries live information.
Terminal / OAuth
- After the toast is dismissed, a truncated heuristic guess can no longer fill
the slot that an exact OSC 8 or relay URL occupied — the detector remembers
every exact URL and drops any candidate that is a strict prefix of one.
- The prompt is reachable by keyboard: Ctrl+Shift+O jumps to the default
action, Escape dismisses, focus returns to the terminal, and auto-dismiss
holds off while focus is inside. It deliberately does not steal focus.
- UrlToast renders through `ui/Button` and `--shadow-overlay`.
Elsewhere
- AuthBridgeRow: a pushed `auth-bridge-changed` status always outranks an older
awaited toggle result.
- The last two ad-hoc byte formatters route through `lib/formatBytes`.
Contract for the backend agent: `upload_file_to_container` refusing to
overwrite must satisfy `isFileExistsError` in `src/lib/uploadErrors.ts` (marker
`FILE_EXISTS`) and accept an `overwrite` argument; the frontend turns that into
an `ui/Modal` Replace/Skip prompt rather than a raw error string.
Tests: 536 -> 627 passing. `npm run build` and `npx tsc --noEmit` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Safety:
- `destroy`'s rollback-pin arm took a tag over IPC and interpolated it
straight into an image reference it then removed. `tag: "latest"` named
the project's live snapshot, deleted under a dialog saying "rollback
pin". It is the one destructive variant carrying a free-form string, so
it now goes through `parse_rollback_tag`.
- The compaction's scratch container was named `triple-c-scrub-*`, which
is what the scrub reclaim bucket hunts and force-removes. A reclaim from
a second window would have destroyed the container a running compaction
was about to commit. It gets `triple-c-compact-*`, swept at the start of
the next compaction rather than from a bucket anything else can fire.
- Deleting a home or config volume only refused a *running* container, but
a stopped one still pins its volumes — the resting state of every
project ever started — so the user typed the project name and met a raw
409. The container is now removed first and `loses` says so.
Correctness:
- The compaction Dockerfile emitted no `LABEL`, so the flattened
intermediate could never match the sweep's `dangling` + `triple-c.managed`
filter that three cleanup paths rely on. Verified on Docker 29.7.2 that
the label lands on the final stage, the build still yields one layer, and
untagging the staging tag after the commit leaves the committed snapshot
intact and startable.
- `snapshot_commit_layers` silently meant something else when
`triple-c.base-image-id` was absent — the normal case for a pre-label
project — counting the base's own layers and letting a never-recreated
project qualify for compaction. `base_lineage_known` now carries that,
the column says "unknown", and the plan does not offer the rewrite.
- `destroy` returned a `ReclaimResult` wearing a `ReclaimTarget` that named
work it had not done (a home-volume deletion came back as
`OrphanVolume`). Split into `target` / `destroyed`, exactly one set.
- `formatBytes` ran `toFixed` after the divide loop, so 999,999 rendered as
"1000.0 KB" — in the app's only byte formatter, in a panel full of
near-boundary sizes.
- `is_base_image_reference` split on the first colon, so a registry port
ate the repo name.
UI:
- `snapshot_above_base_bytes: null` — deliberately unmeasurable — rendered
as "0 B", the one guessed number in the table.
- Layer count was flagged by colour alone; it now says "stacked".
- The tick list survived a reclaim, so the same call could be re-fired at
objects that no longer existed. The plan is dropped after any action and
the panel says the totals predate it.
- `setReport` landed before the plan call was awaited, so a plan failure
rendered fresh totals above the previous scan's rows.
- Both confirmation modals unmounted before awaiting, making the entire
busy path dead code during multi-second work.
- `buildx du` failures silently showed `docker system df`'s under-reported
build-cache figure with no explanation.
- Tooltip text reached no assistive tech, so two headers announced as
"Help"; hardcoded input id; error-toned glyph in warning-toned panels;
`sweepOrphanedSnapshots` and `clearOutcome` had no callers.
- Four docstrings claimed things the code did not do, and two tests were
named for behaviour they did not assert.
Tests: 513 frontend (was 502), 370 Rust (was 365).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Every recreation runs `docker commit`, which stacks a layer and never
rewrites one, and 24 conditions in `container_needs_recreation` trigger a
recreation. Prevention landed earlier on this branch; this is the half a
user can act on.
The per-project table leads with the two numbers that explain the
mechanism rather than just the total: how many commit layers a snapshot
has stacked above its base, and what the container's writable layer will
add at the next commit.
Backend (`docker/disk.rs`, commands in `docker_commands.rs`):
- `get_docker_disk_usage` — one `df()` joined against the project store,
behind an explicit Scan button because it walks the whole daemon.
- `list_reclaimable` / `reclaim` — classified buckets with measured bytes,
planned off the existing report so re-planning costs no second scan.
- `destroy_project_disk_object` — one object, typed confirmation.
- `sweep_orphaned_snapshots` — exposed, so its report is finally visible.
Safety is structural: `reclaim` takes `ReclaimTarget`, which has no
variant that can name a live project's data. Destructive work is a
separate type reached only through `destroy`. No unfiltered prune is
called anywhere, and nothing outside a `triple-c*` name or `triple-c.*`
label is touched.
Orphan detection subtracts ids from the project store and consults
nothing else. From the daemon's side an idle live project and a deleted
one are indistinguishable — volumes present, no container, no image — so
inferring from container or image absence would offer a live project's
credentials and transcripts for deletion. A store that loaded empty from
an existing `projects.json` is treated as a failed load, not as "no
projects", because `ProjectsStore::new()` recovers from a corrupt file by
starting empty.
Three things verified against a live Docker 29.7.2 rather than assumed:
- Compaction is a two-stage build (`FROM scratch` + `COPY --from`), which
keeps every byte inside the daemon; bollard's import buffers a whole
image into memory. uid/gid and setuid survive; a 192.6 MB/4-layer
synthetic came out 45.7 MB/1 layer. Image config does not survive, so it
is replayed via create+commit, which round-trips a multi-line env var
that a Dockerfile `ENV` could not.
- Flattening breaks base-layer sharing, so the result carries its own copy
of the base. Eight of ten real projects had a 0.10–1.32 GB delta over a
4.72 GB shared base — compacting those costs ~4 GB. The bound now
subtracts that penalty, such projects are not offered at all, and the
run compares unique bytes and abandons a rewrite that would grow.
- `docker builder prune` reports `Total:`, not `Total reclaimed space:`,
so the first parser scored every prune as freeing nothing.
The Windows/WSL2 note is mandatory and its copy lives in Rust beside the
tests that pin it: pruning frees space inside `ext4.vhdx`, which never
shrinks on its own, so C: does not change until the disk is compacted.
Also adds `lib/formatBytes.ts` — the app had four disagreeing copies, and
`projects/home/format.ts` and `migrationCopy.ts` now delegate to it with
byte-identical output. Base 1000 by default, matching what Docker prints.
Tests: 502 frontend (was 453), 365 Rust (was 322).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The Files tab could accept a drop but never produce one: getting a file
out meant "Save to host…" and a file picker. This adds the other
direction.
Two constraints shape it. `dragDropEnabled` is on — TerminalView needs
it, since the native drag-drop event is the only one carrying dropped
file paths — and it blocks HTML5 drag inside the webview, so `draggable`
plus `DataTransfer.setData("DownloadURL", …)` was never available. The
gesture is therefore pointer events into `tauri-plugin-drag`, the same
shape and the same reason as the tab strip's drag. And the file being
dragged does not exist on the host at all: it lives in a container, and
the OS can only drag a real host path.
So a drag-out is a copy first and a drag second.
`stage_container_file_for_drag` materialises the file into
`<os-temp>/triple-c-drag-out/<session>/<slot>/<name>` through the same
`fetch_container_file` the download and the viewer use, keeps the
original filename (a dropped `tmp1234` is not a file anyone wants), and
caps at the 256 MiB an upload already caps at, naming "Save to host…" in
the refusal. The path comes from Tauri's path API rather than `/tmp`,
because on Windows it is neither.
The staging directory has a lifecycle, because whole files accumulating
in the host temp dir would be the disk problem this project just fixed,
in a new place: cleared on exit inside the existing teardown (still
guarded on the main window), and reaped at startup for whatever a crash
left behind.
The copy is also an async gap in the middle of a gesture that feels
instantaneous, and the OS only adopts a drag while the button is still
down. Small files beat the pointer; large ones do not — so the staged
path is cached per entry (keyed on size and mtime, so an edited file
re-stages) and the pane says the copy is ready and to drag again, which
is an instruction rather than an apology because the retry is immediate.
A per-file slot keeps `a/notes.txt` and `b/notes.txt` from becoming the
same host path.
"Save to host…" stays exactly as it was. Drag-out is the enhancement;
a platform that refuses `startDrag` says so and points back at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The six boolean settings were plain `bool`s merged with
`if p.x { true } else { g.x }`, so a project could only ever add to the
global set. There was no project value that produced `false` — turning a
switch off at project level simply fell through to the global value and
the control did nothing.
Widen them to `Option<bool>`. `None` means "not set at this level":
inherit the global on a project, leave Claude Code's own default alone
globally. `Some(false)` is a deliberate off and wins outright.
The fingerprint now formats with `{:?}` rather than `{}` — `None` and
`Some(false)` mean different things, and conflating them would leave the
container un-recreated when a project switched from inherit to off.
The project editor grows a third "Global" state per switch; the global
editor has nothing to inherit from, so it stays a plain toggle and keeps
collapsing to null at the default. Its three existing tests passed
unchanged and caught a first attempt that rendered unset as off, which
would have told every user their session recap was disabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
The null-means-delete convention is only understood by the entrypoint.sh
shipped alongside this code. An existing project recreates from its own
snapshot image, which carries whatever entrypoint it was built with, and
an older one merges with a plain `.[0] * .[1]` — so the literal nulls
would land in the user's settings.json rather than clearing the keys.
Verified against jq: that produces `"tui": null, "effortLevel": null,
"viewMode": null, "awaySummaryEnabled": null`, risking the whole file
being rejected and taking the user's own `model` and `statusLine` with it.
Split the payload instead. CLAUDE_CODE_SETTINGS_JSON now carries only
keys that have a value and is safe under either merge; the new
CLAUDE_CODE_SETTINGS_CLEAR carries the key names to delete and is
ignored by an entrypoint that predates it. Such a project keeps the old
sticky behaviour until it is migrated or Reset, which is the
pre-existing state rather than a regression.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Two integration fixes after merging the three feature branches.
`scrub_writable_layer` is a `docker exec`, so it only works while the
container runs. `migrate_project_to_base` stops its container one line
before the pre-swap commit, which meant the single largest snapshot
Triple-C ever takes was the one path that committed unscrubbed. Call the
scrub explicitly before the stop instead of relying on the call inside
`commit_container_snapshot`.
Also drop a duplicate `CLAUDE_JSON=` assignment in entrypoint.sh. The
Shift+Enter block re-declared it defensively to avoid a merge conflict
with the awsAuthRefresh block; the conflict did not materialise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Two independent sets of fixes.
## Disk: stop the growth, no UI this round
The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.
* **Scrub the writable layer before every commit** (`docker/container.rs`,
`SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
are still free to drop is before the commit that captures them. Measured on
one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
`/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
`/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
mounts. Failure is a log line — a scrub must never block a snapshot.
* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
anywhere, so containers ran on the daemon's unbounded `json-file` default.
Deliberately *not* wired into `container_needs_recreation`: participating
would recreate every project once, and a recreation costs a commit, which is
the thing being fixed. Picked up on the next natural recreation.
* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
pair provably could not match one — ~11.9 GB observed stranded. Stamping
`triple-c.managed=true` is the whole fix; the sweep needed no change.
`create_container` writes the new `triple-c.base` key explicitly empty, or
Docker's label inheritance plus `docker commit` would make every snapshot
claim to be a base image. `force: false` stays, and now says why.
* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
(a probe pins an image the unforced sweep then refuses), pins second, sweep
last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
the report away — `reclaimed_bytes`, `failed` and `unavailable` included.
* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
age-reaped by scanning the tag pattern rather than trusting the state file —
`migration_store::load` reports an unparseable record as absent, which
stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
record aside so `has_record` is trustworthy. A pin whose migration is still
awaiting confirmation is never reaped at any age. The probe container's
removal was a plain statement after an await, so a dropped future (an app quit
mid-migration) leaked a container pinning a multi-gigabyte image; it is a
`Drop` guard now, with `reap_probe_containers` for the case where the process
itself dies.
* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
directory, and the task runner appended uncapped `claude -p` output.
* **Fix the delete copy.** It said "the container, config volume, and stored
credentials"; it removes *both* volumes and the snapshot image.
No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.
## Claude Code settings: two invented keys, one inverted default, one sticky bug
Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.
* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
`CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
could never turn the recap *off*. The field is renamed to
`session_recap_disabled` rather than reused: reusing the name with the
opposite meaning would have read every stored `enable_session_recap: false` —
which is every project that never touched the control — as "the user turned
this off".
* **The stickiness, which is the important one.** Keys were emitted only when
non-default, and the entrypoint *merges* into a settings.json on a persisted
volume, so switching a setting off omitted its key, the merge preserved the
stale on-value, and the setting stayed on until a destructive Reset. The fix
already existed in the same file — the sandbox block is emitted
unconditionally for exactly this reason — and is now applied to all five keys.
A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
`awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
because a stand-in value is not neutral: `tui: "default"` pins the classic
renderer where unset lets Claude Code choose, and `viewMode: "default"`
overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
bakes container env into the snapshot image, so a `=1` written once rode it
forever. All four are now emitted on every create, extracted into
`claude_code_env_vars` and unit tested. Two use an empty value for "off"
rather than `0`, because they outrank a setting the user can change from
inside their own container and Triple-C's default must not overrule a
`/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
which the always-emitted key makes both necessary and possible.
`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.
Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Three fixes that all land on the same journey: sign in, paste a prompt,
and have the terminal behave the way every other Claude Code host does.
Shift+Enter inserts a newline
-----------------------------
xterm.js does not consult `shiftKey` for Enter (`Keyboard.ts`, case 13),
so Shift+Enter was byte-identical to Enter and submitted the prompt.
Both terminals now send `\x1b\r` (ESC+CR) instead, which Claude Code
parses as return+meta — the same bytes its own `/terminal-setup` writes
into the VS Code, Cursor, Alacritty and Zed keymaps, so this is in-band
rather than a guess. Not `\n`: Claude Code accepts it, but a shell would
run the line, so the two session types would diverge. Bound in Claude
sessions only for that reason.
`entrypoint.sh` sets `shiftEnterKeyBindingInstalled` in `~/.claude.json`
so the CLI stops printing its "run /terminal-setup" tip. Purely
cosmetic — the decoding is unconditional either way.
Alt+Enter has always done the same thing (xterm ESC-prefixes on altKey)
and was simply never documented. It is now, along with the rest.
OAuth login URL truncation
--------------------------
Two producers wrote one toast slot, last-writer-wins. The OSC 7777 relay
delivers the URL base64-encoded and therefore exact; ~300 ms later the
screen-scraper's debounce fired and overwrote it with a truncated guess
at the same link — a URL that parses, points at the right host, and
authorises nothing. The user is the one who has to notice.
Why the scraper truncated: `ANSI_RE` strips OSC sequences wholesale,
including the OSC 8 hyperlink whose parameter carries the complete URL.
Claude Code slices the *visible* text of that hyperlink to the terminal
width while every emission carries the whole URL in its parameter. The
backend already knew this (`commands/auth_token_commands.rs`); the
frontend did not.
- `urlDetector` now reads OSC 8 targets out of the raw buffer before
stripping, filtered by a port of `usable_sign_in_link`, and tags every
candidate with its provenance.
- The prompt slot gained `supersedes`: better provenance always wins,
worse never does, and between equals only a candidate that *extends*
what is showing may replace it. That last rule is `extendsUrl`,
factored out of `pickSignInUrl` rather than copied — same rule, same
reason, one implementation.
- `flatten` splits on a bare `\r` as well as on `\r?\n`, so a
`\r`-repainted TUI frame no longer inflates a line past the width and
suppresses a join that should have happened; and the width is now
sampled at `feed()` rather than read at `scan()`, so a resize inside
the 300 ms debounce cannot reassemble 80-column text against a
120-column rule.
Also corrects the comment claiming `acquire_claude_token` enables the
auth bridge. It deliberately does not, and the module comment in
`auth_token_commands.rs` explains at length why not.
The auth bridge toggle
----------------------
`setAuthBridgeEnabled` and `getAuthBridgeStatus` had zero call sites:
the Rust was complete, the IPC wrapper shipped, and there was nowhere to
click — so the docs told users to "enable the Auth Bridge" for a switch
that did not exist. `AuthBridgeRow` is that switch, in Config → Runtime.
It deliberately does not go through the tab's stopped-only save: the
dedicated command exists so the bridge can be flipped while a login is
hanging in a running container, which is the only moment anyone reaches
for it.
It also subscribes to `auth-bridge-changed`, which the poller has been
emitting to nobody — so a host port the bridge could not take was a
completely silent failure, indistinguishable from a login that hung.
`tunnel.rs` promotes the best-effort `::1` bind failure from debug to a
warning recorded on the port. Half-bound is the failure mode that looks
like success: the status says bridged, and a client that resolves
`localhost` to `::1` without falling back is still refused.
Finally, for a recognised Anthropic sign-in URL the toast now leads with
"In container" and demotes the host "Open". The callback listener is
inside the container, so the container-side browser closes the loop with
no host round trip and no auth bridge; the host button stays as the
fallback. Ordinary URLs are unchanged.
Tests: 402 frontend (was 359), 285 Rust (unchanged).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Rename, an in-app viewer for text and images, host-to-container drag and
drop, New folder, keyboard operation — plus the pre-existing bugs the new
surface would otherwise have been built on top of.
New Tauri commands (file_commands.rs, registered in lib.rs):
* rename_container_path — `mv -n -- <from> <parent>/<name>` through
exec_oneshot_as, so the *exit code* is checked. exec_oneshot discards the
status and interleaves stderr into stdout, which would have made a
permission failure look like a success. `mv -n` on its own is not enough
either: GNU coreutils makes its refusal to clobber silent and exits 0, so
an explicit `test -e` on the destination is what turns a name clash into
an error the user sees. `mv`'s own words are surfaced, since renames
outside /workspace legitimately fail on permissions. The new name is
validated in Rust (no `/`, no NUL, not "." / ".." / empty, ≤255 bytes) —
it is user text going into argv, and a name with a separator would be a
move rather than a rename.
* read_container_file — exact bytes via Docker's archive endpoint, returned
as base64. Deliberately not exec_oneshot, which runs every chunk through
String::from_utf8_lossy and merges stderr, so it would corrupt any
non-UTF-8 file and could splice diagnostics into content. Base64 rather
than Vec<u8> because Tauri serialises a byte vec as a JSON number array.
Capped and truncation-reporting; the caller picks the cap (images get 5
MiB against text's 1 MiB, being the kind that blows a text-sized budget)
and Rust clamps it to 8 MiB regardless.
* create_container_directory — `mkdir` without -p, so a clash is an error
rather than a silent success. Named for its siblings rather than the bare
`create_directory` in the brief.
The tar-extraction half of download_container_file is now the shared
fetch_container_file() both commands use, and it abandons the transfer once
a capped read has what it needs.
Frontend:
* Single click selects, double click opens. Directory navigation moved onto
double click too — a single click used to navigate, which made it
impossible to select a directory in order to rename it. Rows are now
focusable and the table is a real `grid`: Enter opens, F2 renames, arrows
walk the rows. No outline suppression; the global :focus-visible ring is
what shows focus.
* FileViewerModal (built on ui/Modal, the only correct dialog) renders text
in a <pre> and images from a revocable blob: URL. tauri.conf.json's
img-src had neither `data:` nor `blob:`, so an in-app image was blocked by
CSP; `blob:` is added — revocable, and no megabytes of base64 in the DOM.
The asset protocol stays disabled. Anything else gets a "Save to host"
state instead of a broken preview, decided by extension and then by
sniffing the bytes for NUL.
* Host drag-and-drop uses Tauri's native onDragDropEvent, mirroring
TerminalView: HTML5 ondrop carries no paths and is blocked in the webview
on Windows by dragDropEnabled, which the terminal needs. The listener is
window-wide, so it routes by hit-testing the payload position (physical
pixels, hence the devicePixelRatio divide) against the pane's rect — a
hidden pane has a zero-size rect and never matches, which is what keeps
this and the terminal's listener apart. enter/over/leave drive a drop
highlight.
* Per-row Download is now "Save to host…"; directories no longer offer it.
Pre-existing bugs fixed:
* Uploaded files landed root:root with a 1970 mtime. tar::Header::new_gnu()
zeroes uid/gid/mtime and Docker honours the header verbatim, so uploads
were not writable by `claude`. All four single-file tar builds now go
through build_single_file_tar() with the container user's ids, read from
the container because entrypoint.sh remaps them to the host user on Unix
and deliberately does not on Windows.
* Symlinked directories could not be opened: `find -printf '%y'` reports `l`.
The listing now prints `%Y` as well, so is_directory dereferences and a
new is_symlink carries what `%y` used to say. The row labels the link.
* upload_file_to_container had no size cap and did a synchronous fs::read on
an async worker. Now 256 MiB (matching the terminal drop path) with the
read and tar build in spawn_blocking, and the host mtime preserved.
* A directory passed to upload reached fs::read and produced an opaque "Is a
directory". Rejected with an explanation instead — recursive upload is a
larger feature than this panel needs.
* download_container_file wrote the *first tar entry*, so downloading a
directory silently produced a garbage file. Non-regular entries are now an
explicit error.
Tests: 46 new (33 frontend across FilesTab, useFileManager and filePreview;
12 Rust covering the find-output parser and the rename validator, neither of
which had any). 405 frontend / 297 Rust, both green.
No drag-out dependency was added — tauri-plugin-drag is not introduced and
OS drag-out is not attempted; that stays deferred, with "Save to host…" as
the way files leave the container.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
Round 3 found defects in code written an hour earlier. Both reproduced.
**The handshake poll accepted empty output as a completed handshake.**
`[ "$(… | awk '{print $2}')" != 0 ]` is *true* when `wg show` prints nothing —
which it does when the interface has no peer, and when the interface is gone
(that message goes to stderr). `until` suspends `set -e` and `pipefail`, so
nothing else caught it. The poll added last round to make "success without a
tunnel" impossible produced exactly that. Now requires a number greater than
zero, and waits 20s rather than 10 so a slow link is not rolled back needlessly.
**`down` still sat above the key registration.** Last round moved it below the
token and server-list fetches but not below `addKey`, which is the most
failure-prone of the three — one gateway, by CN, pinned certificate. So a
refused registration still tore down a working tunnel. It now runs after the
last fetch; the key is generated before but written after, since `down` deletes
it. SKILL.md said "after every network fetch has succeeded", which was false;
corrected.
**`up --full` made `host.docker.internal` unresolvable — and `status` said DNS
was fine.** That name is answered only by the resolver being replaced; it is not
in `/etc/hosts`. `gateway.rs` hands it to every container for the LiteLLM
gateway, and Ollama and custom endpoints default to it, so an agent running
`up --full` silently removed the project's model backend. The route was already
excluded; only the name was lost. Now resolved with the old resolver and pinned
into `/etc/hosts` before the swap, restored on teardown, and `status` probes it
— PIA answers public names happily, which is precisely why probing only
`api.anthropic.com` reported "ok". Documented as Trap 4.
**The rollback could abort halfway.** The trap's `{ … }` is not exempt from
`set -e`, and `down`'s `cat`/`tac`/`rm` had no `|| true` — so one failure left
the interface up with all traffic captured, after printing "rolling back".
`down` now runs under `set +e`, the trap tolerates its failure, and the
interface is deleted *first*, since that removes every route pointing at it.
**The account password had a real argv window.** curl does blank `-u`, but only
once running: sampling /proc/<pid>/cmdline caught the plaintext in 2 of 400
tries, between exec and the overwrite. Small, but it is the permanent password
and the token already had the fix. Moved onto the same stdin config — 0 of 400.
Review reported this as a 25-second exposure; that was a wrapper's argv, not
curl's.
entrypoint: the skill install stages into `$_dest.new` and swaps, so a failed
copy leaves the previous copy intact instead of a truncated SKILL.md and no
script, root-owned, on a persisted volume. Verified against a size-limited
filesystem.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit moved `down` to the top of `up` to fix a resolv.conf
idempotency bug, and in doing so put it *before* every network fetch that can
fail. Re-review caught it and I reproduced it: with a tunnel up, an `up` that
fails on bad credentials left `pia0` gone and traffic silently back on the real
address, while the error talked only about credentials. A privacy regression
introduced by a correctness fix.
`down` now runs after the token, server list and key registration have all
succeeded — nothing above that line touches the network stack — and still
clears the stale backup it was added for.
Everything after it is covered by a rollback. Note this is an EXIT trap with a
flag, not `trap ... ERR`: my first attempt used ERR and did not fire at all,
because ERR is not inherited by shell functions without `set -E`, so a failure
inside add_route missed it, and `die` exits explicitly, which is not an error.
Verified by forcing a route collision — the tunnel is torn down and DNS is
intact, where before the fix it was left half-configured with DNS dead.
Also from the review:
- **The private key lived on disk for the whole life of the tunnel.**
`commit_container_snapshot` blanks env vars, never files, and nothing tears
the tunnel down before a recreate or migrate — so the `down`-time cleanup
never covered the path that put a key in a snapshot in the first place. It is
now deleted the moment `wg set` has read it; the kernel keeps its own copy,
verified by checking the interface still works afterwards.
- **`up` claimed success without a handshake.** An unreachable peer still
routes — into a black hole — so `up --full` could exit 0 having pointed all
traffic and resolv.conf at a peer that never answered, with `status` printing
"mode: full tunnel". Now polls for a handshake and rolls back if none arrives.
- **`status` needed root and did not check.** `wg show` fails unprivileged and
was swallowed, so an unprivileged run printed "no tunnel up" and then "mode:
full tunnel" in the same breath. An agent reading the first line would re-run
`up` — which, before the fix above, destroyed the tunnel it failed to see.
- **The killswitch bullet was false.** It said `iptables` is not in the image;
it is, so a killswitch is buildable. It stays unbuilt because it would cut
Claude Code's own API traffic — an honest reason, unlike the previous one.
- `install_feature_skill` rejects path-traversal names, not just blank ones —
verified `../skills` would have deleted the whole skills directory including
Mission Control's — and reports `mkdir`/`cp` failures instead of printing a
success line regardless.
- The usage text ended by printing `set -euo pipefail`, off by one line.
- HOW-TO-USE claimed "the container says so on start". entrypoint prints to
PID 1's stdout, which no terminal or UI surfaces — `docker logs` appears
nowhere in the repo. Now points at the migration pre-flight, which does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial review of #29 found bugs I confirmed by reproducing each one.
**Every hand-written error message was unreachable.** `tok=$(curl ...)` is a
plain assignment, so `set -e` acts on the command substitution before the
following `|| die` can run. A wrong password produced exit 22 and no output at
all — the most likely way this gets used wrongly, and the least explained.
All four captures now go through a `run` helper that takes a *description*
rather than echoing the command, because one of them carries the account
password in `-u`.
**`up` was not idempotent, and the second run destroyed DNS.** The resolv.conf
backup was copied unconditionally, so `up --full` twice overwrote the good
backup with PIA's own resolvers; the later `down` then "restored" those and
left the container with no working DNS and no way back. `up` now runs `down`
first. Verified: two `up --full` runs, then `down`, and the backup still holds
the original 192.168.65.7.
**An empty gateway produced total connectivity loss, reported as healthy.**
`$gw` was never validated and `add_route` swallowed every failure to /dev/null.
The two half-routes need no gateway and would succeed, so the tunnel captured
everything while the exclusions keeping DNS and the Docker host reachable
silently did not exist — and `status` still printed "full tunnel". Routes are
now fatal on failure, and a via-less default (`$3` is the literal "eth0") is
rejected.
**The PIA session token was in the process arguments** — confirmed in `ps` and
/proc/*/cmdline, a ~24h bearer credential for the account readable by anything
in the container. It now goes to curl on stdin as a config. Verified: 60 polls
across a full `up`, zero sightings.
**The preflight diagnosed the wrong kernel module.** It checked /dev/net/tun
and blamed the tun module, but kernel WireGuard is a netlink interface and does
not use it — verified by creating one with NET_ADMIN and no tun device. The
check is dropped (the container could not have started without the device
anyway) and `ip link add` now reports the real dependency.
Also: a full tunnel with no DNS servers from PIA used to warn and carry on,
which is a tunnel leaking every lookup while reporting itself healthy — now
fatal. `down` validates the backup before restoring it, so a truncated one
cannot leave the container with no resolver at all. `wg.priv` is shredded on
teardown and created under umask 077, because /run rides `docker commit` into
the snapshot image. A mistyped `up --ful` is rejected instead of silently
giving a test route.
entrypoint: `install_feature_skill` gets `local`, a blank-name guard (the
disabled branch would otherwise `rm -rf` the whole skills directory under a
persisted volume), `-e`/`-L` so a leftover *file* at the destination is cleaned
up, and a chown of the parent so `claude` can still add skills of their own
when Mission Control is off. When the base image predates the skill it now says
so instead of returning silently — and `/opt/triple-c-skills` joins
FEATURE_PROBES so the migration pre-flight reports it. Docs corrected to match:
neither half reaches an existing project without a migration.
`vpn_env_var` extracted and tested, pinning the property the whole removal path
rests on — that the variable is emitted as 0 rather than omitted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`status` probed https://1.1.1.1/cdn-cgi/trace and printed the answer as
"public IP". In test mode 1.1.1.1 is the *only* address routed into the tunnel,
so that line reported a PIA exit while every other packet left directly — a
test tunnel reading exactly like a full one.
Found on a live container: default route still via eth0, one 1.1.1.1/32 route
through pia0, and the old status line claiming a PIA public IP. This is a
plausible route to concluding the VPN is on when it is not, which is close to
the confusion this skill exists to prevent.
Status now names the mode and, in test mode, prints both exits with the real
address called out. 1.0.0.1 serves the same trace endpoint as 1.1.1.1 and is
never routed into the tunnel, so the direct exit can be probed without DNS.
Verified against all four states: no tunnel, test mode on a live tunnel that
was already up, full tunnel, and after teardown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toggle grants CAP_NET_ADMIN and /dev/net/tun and stops there, which users
reasonably read as "turn the VPN on" — the gap between the two is the reported
bug that the default network does not route through a VPN. Close it by giving
the container an agent-usable way to build the tunnel, rather than leaving
each project to rediscover it.
container/skills/ is baked to /opt/triple-c-skills and installed into
~/.claude/skills/ by entrypoint.sh from VPN_SUPPORT_ENABLED, mirroring how
Mission Control installs its own. Staged under /opt because ~/.claude is a
volume mount that would mask an image copy from first start.
Three details that are not incidental:
- The variable is sent as 0 rather than omitted when off, because ~/.claude
persists: entrypoint has to be *told* to remove a skill left by an earlier
run with the toggle on, and an absent variable cannot say that. A stale skill
is worse than none, since it instructs an agent to use a capability the
container no longer has.
- It is reserved in RESERVED_ENV_EXACT alongside MISSION_CONTROL_ENABLED, or a
custom env var of the same name could claim the skill without the capability
behind it. Covered by a test.
- The skill is re-copied on every start, rm -rf'd first, so fixes reach existing
projects and files dropped from a later version do not linger.
The skill itself carries the three things that are easy to get wrong: that a
full tunnel captures the Docker resolver and takes DNS down with it, that an
IP-literal health check cannot see a dead resolver, and that no tunnel survives
a restart while /run state riding the snapshot makes it look as though one did.
It also states what it deliberately does not do — no killswitch, no autostart —
so an agent proposes those as decisions rather than improvising them.
pia-wg.sh preflights CAP_NET_ADMIN by capability bit rather than letting the
first `ip` call fail with a bare EPERM that points nowhere near the setting
that needs changing. Credentials stay in a file (~/pia-creds, PIA_CREDS to
override) rather than the environment, where docker inspect and every process
in the container would see them.
Tested: install/refresh/remove/no-op paths of install_feature_skill against the
real function; preflight with and without the capability; and a full up --full
/ down round trip, confirming DNS via PIA's resolvers, api.anthropic.com
reachable through the exit, and routes and resolv.conf restored on teardown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3 argued the macOS rationale is stale: that Docker Desktop no longer
builds from linuxkit/linuxkit and has enabled nft_fib_ipv4 since 4.35. I could
not confirm or refute that from a Linux host — searching turned up no version
matrix either way.
But the decision does not depend on it, and the comment should not have implied
it did. `xt_CONNMARK`, which the iptables path needs, was present in every
kernel config examined. `nft_fib_ipv4`, which the nft path needs, was absent
from the config read here and may be present in current Docker Desktop. That
asymmetry is the actual argument: nftables' viability varies by Docker Desktop
version in a way nobody here can pin down, iptables' requirement did not vary
anywhere it was checked. If nft_fib_ipv4 is present this costs 1.6 MB and
nothing else; if it is absent it is the difference between a working full
tunnel and none.
Rewritten to say that, and to say plainly what is verified versus assumed —
this is the third round in which the previous round's central premise did not
survive, and a confidently-worded paragraph is what the next round inherits.
Also from review:
- CLAUDE.md still said "`iptables` is deliberately absent", the opposite of what
this PR now does, contradicting the Dockerfile and both other docs.
- The Dockerfile referenced "the pia-vpn skill", which does not exist on this
branch — the third forward reference of that kind, now gone.
- "full tunnels work on native Linux, Mac and WSL2 6.6" was unconditional and
contradicted ten lines later by the `DNS =` concession, which stops them on
every platform. Reordered so the DNS hurdle is named as the first one.
- The WSL2 gap was written as a permanent platform limitation. It is a stale
install: `wsl --update` moves the host to a current kernel that has the
symbol. That remedy was missing from the user-facing doc.
- The migration probe label carried an internal comma, which `joinFeatures`
renders into a comma-joined list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-review overturned the previous commit's package choice, and verifying it
proved the reviewer right.
`wg-quick` picks nft *unconditionally* when it is present (`type -p nft`, line
241), so installing nftables makes the iptables path unreachable. Its nft
ruleset then needs a third expression family the iptables path does not.
Isolating the rules on this host, the two connmark rules install fine and this
is what fails:
nft add rule ... fib saddr type != local drop
Error: Could not process rule: No such file or directory
That decides it, because of how the hosts differ. LinuxKit's kernel config —
Docker Desktop for Mac, identical on x86_64 and aarch64:
CONFIG_NETFILTER_XT_CONNMARK=y <- the iptables path works
# CONFIG_NFT_FIB_IPV4 is not set <- the nft path does not
So nftables would have broken the platform it was added to fix. With iptables,
full tunnels work on native Linux, Docker Desktop for Mac, and WSL2 from 6.6.
Costs 7,203 kB rather than 5,614 kB on amd64.
That also means the mechanism the previous commit documented was wrong: with
nftables installed `xt_CONNMARK` is never consulted, and the real blocker on
that path is `nft_fib_ipv4`. Rewritten around what actually fails.
A second failure neither round had found: `wireguard-tools` only *Suggests*
`openresolv | resolvconf`, so neither is installed, and every provider's stock
config has a `DNS =` line. That fails in `set_dns()` — before any routing — so
it takes split tunnels down too, contradicting what this PR previously claimed:
[#] resolvconf -a sp -m 0 -x
/usr/bin/wg-quick: line 32: resolvconf: command not found EXIT=127
Not fixed, deliberately: `openresolv` has no installation candidate on noble,
and `resolvconf` resolves only by pulling in systemd-resolved — a resolver
daemon and systemd units, into a container with no systemd. Documented instead.
Smaller corrections from the same review:
- the size caveat blamed ~209 kB of libelf1t64; for this package set the real
over-count is libelf1t64 + netbase. Restated, and arm64 now given against the
real base rather than left as a bare-ubuntu figure.
- the manual-install fallback omitted `iproute2`, so it left the user without
`ip` — the command the tunnel needs most.
- "Without it" had been orphaned from its antecedent by inserted paragraphs and
read as referring to configuring a tunnel.
- the migration probe said "VPN support", presenting VPN as a feature gained to
users who never enabled it. Now names the tools and the toggle.
- "What's Inside the Container" gains a row; the key-material-in-snapshot
hazard was in CLAUDE.md only, and is the one genuinely user-facing warning
here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of #28 found the iptables exclusion was justified by a false premise,
and I confirmed it: `wireguard-tools` declares `Recommends: nftables | iptables`,
`--no-install-recommends` strips it, and `wg-quick`'s add_default() shells out
to a firewall backend with no `type -p` guard. Measured on the image as this PR
shipped it:
[#] iptables-restore -n
/usr/bin/wg-quick: line 32: iptables-restore: command not found
wg-quick EXIT=127
That fires for `AllowedIPs = 0.0.0.0/0` — every stock full-tunnel config from
every provider — not for a desktop client's killswitch as the comment claimed.
Split tunnels are unaffected.
Ship `nftables` rather than `iptables`: wg-quick prefers it (`type -p nft`, so
with both installed iptables is dead weight), it is first in the package's own
Recommends, and it is half the size.
The review's proposed fix stopped there; it does not hold. Adding nftables does
not make wg-quick work on this host, and neither does iptables:
Warning: Extension CONNMARK revision 0 not supported, missing kernel module?
`Table=auto` routes by fwmark and needs xt_CONNMARK from the *host* kernel.
WSL2 has none and containers have no /lib/modules to load one from. So this
fixes native Linux and Docker Desktop for Mac — which other WHP users are on —
and cannot fix Docker Desktop for Windows, where the answer is to add routes
with `ip route` directly. Documented rather than left to be rediscovered.
Also from review:
- "`ip` and `wg` are always present" was false. A project keeps the base image
it was first built from, so this reaches new projects only. Reworded to match
the wording already used for the Playwright libraries, and `/usr/bin/wg` added
to FEATURE_PROBES so an existing project is *told* it is missing VPN tooling
and prompted to migrate, rather than finding out via `wg: command not found`.
- "no client is installed" contradicted shipping `wg` four lines earlier. The
true claim is that no tunnel is configured or started.
- The size figure measured against bare ubuntu:24.04, which over-counts by the
~209 kB of libelf1t64 the real base already has, and covered one arch. Now
measured against the current base on amd64 and stated for arm64 too, per the
standard CLAUDE.md sets for the Playwright layer.
- `/run` persistence conflated two mechanisms: same-container files on a
stop/start, `docker commit` on a recreation. Both stated, plus the corollary
that key material written to /run ends up inside a snapshot image — observed,
a `wg.priv` was already sitting in one.
- The DNS bullet presented a Docker Desktop address as the general case. Now
leads with the mechanism, notes 127.0.0.11 on a user-defined network is
unaffected, and adds the two things the advice omitted: a resolver the tunnel
can reach (or it leaks every query), and pinning the endpoint via the old
gateway (or the tunnel routes through itself).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`vpn_support_enabled` hands a project CAP_NET_ADMIN and /dev/net/tun, and the
image then contains no `ip` and no `wg` — a capability with nothing able to
exercise it. Bake `iproute2` and `wireguard-tools` (~4.3 MB with deps).
They belong in the image rather than a runtime install for the reason the
Dockerfile already gives for the Playwright libraries: the writable layer is
lost on base-image migration. A hand-installed `wg` works until an upgrade and
then vanishes, which presents as a tunnel that will not come up rather than as
a missing package. One project only had `ip` at all because MariaDB pulled in
iproute2 as a transitive dependency.
`iptables` stays out. Only a desktop client's killswitch wants it, and those
clients need a GUI the container cannot provide.
Also correct three things the docs left users to discover:
- the toggle grants capability and routes nothing, which is being reported as
the default network "not routing through the VPN automatically"
- no tunnel survives a restart, and `/run` state riding the snapshot makes it
look as though one did while traffic goes out the real address
- a full tunnel captures the Docker resolver, which sits outside the
container's subnet, and takes DNS down with it — Claude Code then reports a
connection failure because it cannot resolve api.anthropic.com, and a health
check aimed at an IP literal passes throughout
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review caught that the device guard was wired to the wrong call. The
daemon does not resolve `--device` at create: verified against Docker
29.7, `docker create --device /dev/does-not-exist` succeeds and prints an
id, and runc only resolves the device — and validates sysctls — when it
builds the container. So on a host with no tun module the create returns
fine and `start` fails, which means the explanation never ran and the
user saw the raw daemon string naming a path they would go looking for on
the wrong machine. The unit tests fed the create-side string straight in,
so they confirmed a function no real failure could reach.
Move the guard onto `start_container`, covering create as well in case a
future daemon checks earlier. It no longer takes `vpn_support_enabled` —
`start_container` has a container id and no project, and nothing else in
Triple-C ever requests a device, so an error naming /dev/net/tun is
unambiguous on its own. The test now uses the daemon's verbatim message
via bollard's real Display format.
Also from review:
* Soften the security claim. Docker does not enable user-namespace
remapping by default, so this is a real CAP_NET_ADMIN in the initial
user namespace with only the network namespace confining it. It
cannot touch host interfaces, but "confers no authority outside the
container" was too strong: within its namespace it can set
promiscuous mode and add addresses, routes and NAT on the shared
docker0 segment, which puts sibling containers — the LiteLLM gateway
among them — within ARP-spoofing reach, and it can flush netfilter
rules sandbox mode may rely on. Said plainly in the code, CLAUDE.md
and HOW-TO-USE.
* Drop Tailscale from the list of clients needing this. Its
--tun=userspace-networking mode needs neither the capability nor the
device, and listing it invites granting NET_ADMIN for nothing.
* Say in the toggle's own hint that changing it recreates the
container, matching how every other recreation-triggering setting is
labelled. The tab's generic "stop the container first" chip does not
tell the user what is about to happen.
* Add RuntimeSection tests: saves on, saves off explicitly rather than
dropping the key, reflects state, is disabled while running, and
carries the recreation warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A VPN client installed in a container today starts, runs, and then hangs
until its connection times out. Nothing reports an error: a default
container has no /dev/net/tun to open and no CAP_NET_ADMIN to add an
interface or a route with, and clients surface that as a generic timeout
rather than a permissions failure.
Add an opt-in per-project "VPN support" switch granting the three things
a tunnel needs. They are useless individually, which is why
vpn_host_config() defines the set in one place and the tests assert all
of it:
* CAP_NET_ADMIN — Docker's default bounding set has net_raw but not
net_admin, so a client can ping but never connect.
* /dev/net/tun — passed through from the host so the kernel's tun
module backs it, rather than mknod-ed inside.
* net.ipv4.conf.all.src_valid_mark — WireGuard's wg-quick sets this and
cannot from inside a container, /proc/sys being read-only, so its
handshakes are dropped by reverse-path filtering.
Off by default and deliberately opt-in: NET_ADMIN lets anything in the
container reconfigure that container's network stack. It is namespaced —
no authority over the host's interfaces or any other container.
Capabilities and devices are fixed when a container is created, so this
is container state and takes the label-and-compare treatment.
triple-c.vpn-support is written unconditionally, false included, for the
usual docker commit reason: a true stamped once would ride the snapshot
image into every future container and make the switch impossible to turn
back off. A missing label reads as false and off is byte-identical to
today, so no existing project is churned.
Requesting the device fails at creation when the host kernel has no tun
module, which would otherwise surface as a project that simply refuses to
start. explain_create_failure() rewrites that one error to name the
switch and the Docker-Desktop-VM-versus-your-machine distinction, and
leaves every other failure untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Linux release upload failed with a bare "exitcode '1'" and no output.
The cause was not the upload: compute-version handed it a version that
had already been released three days earlier.
The patch number was `git rev-list --count <highest tag>..HEAD` — how far
HEAD has drifted from whichever tag sorts highest, which resets to zero
every time a tag is cut. It is not a counter, and the published history
is what the old formula returned at each point:
v0.4.0 -> 3 commits -> v0.4.3 looked fine
v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
v0.4.4 -> 2 commits -> v0.4.2 went backwards
v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
v0.4.6 -> 3 commits -> v0.4.3 already taken
So the line published 0.4.0, 0.4.3, 0.4.4, 0.4.2, 0.4.6 in that order,
never used 0.4.1 or 0.4.5, and then came back round to 0.4.3.
The patch is now one past the highest already used. Suffixed tags count
towards that: create-tag is skipped whenever a platform job fails, so a
run can publish v0.4.7-mac and never create the plain v0.4.7, and reading
only unsuffixed tags would hand the same number out twice. A commit that
is already tagged reuses its own tag, so re-running a build does not mint
a version.
Reusing a number was doing real damage, not just failing. macOS and
Windows delete-then-upload each asset, so they took the duplicate in
their stride and rewrote v0.4.3-mac and v0.4.3-win — public since
Aug 11 — with today's binaries. Linux is the only platform that failed,
and failing was the correct outcome; its v0.4.3 assets are the only ones
still original.
Linux also gets the idempotent get-or-create the other two already had,
plus `set -euo pipefail` and `-fsS`. Its `curl -s` with no `-f` is why a
409 produced no diagnostic at all: the HTTP error was swallowed, the id
grep came back empty, and the step died without ever printing why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A project can be out of date and say nothing about it, in two ways that
compound: the lineage lookup treats "unknown" as an answer, and the
fallback that exists for unknown lineage disappears when its probe fails.
`create_container` always writes triple-c.base-image-id, even when the
value is unknown — deliberately, so an inherited image label cannot ride
a snapshot forever. That makes Some("") the ordinary reading from a
container whose lineage was never established. The lookup filtered for
emptiness only on the final result, so that empty string satisfied the
container branch and skipped the snapshot entirely: a snapshot that had
recorded a real lineage was never consulted, and the project reported
"unknown" with the answer one lookup away. Each source is now filtered
before it can answer, in pick_recorded_lineage, which is a plain function
so the case has a test that fails against the old logic.
A genuinely pre-label project stays unknown, and should: its ancestor is
not knowable, and inventing one would make it look permanently current.
The probe is the intended signal for those — but if the probe failed,
get_container_staleness returned early with nothing populated, the banner
found no gaps and rendered null, and the probe_error it already knew how
to display sat behind a gate that returned before reaching it. Silence
there is indistinguishable from "up to date", and it is likeliest for the
oldest and largest projects, whose manifests are the ones apt to exceed
the inspection limit — one real project measured 6.93 MB against an 8 MB
cap. An unknown-lineage container whose probe failed now says the check
could not be completed, with the reason, under the tone that means
unresolved rather than the one that means something is wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Setting up the browser view failed on every container, and re-running it
reproduced the same broken state, because the setup destroyed its own work.
`install_packages` ran two `npm install --no-save` commands into
/workspace, which has no package.json. With no manifest, npm treats the
command line as the whole statement of what the tree should contain and
prunes the rest, so installing `playwright` second removed the
`@playwright/cli` installed first: "removed 3 packages", leaving an empty
node_modules/@playwright/ behind playwright and playwright-core. That
empty directory is exactly what the pane then reported as missing. The
second install now names both specs; the first one is already present, so
it costs nothing and is only there to stop npm pruning it.
Two failures were waiting behind that one:
Nothing in the tree ever configured the browser, so playwright-cli fell
back to channel `chrome` — system Google Chrome — with the Chromium
sandbox on. These containers forbid unprivileged user namespaces, so it
aborted with "Failed to move to new namespace ... Operation not
permitted"; on a base image without Google Chrome the same default failed
as "Chromium distribution 'chrome' is not found". entrypoint.sh now seeds
~/.playwright/cli.config.json on every start, which is the only way to
reach existing projects: ~/.playwright is inside the home volume, so an
image copy would reach new projects only.
The launch check passed for a configuration the viewer never uses. It
launched bundled chromium with no channel, which resolves to
chromium-headless-shell, while the viewer's config pins
chrome-for-testing — the full chromium build, a separate download. A
container could pass every check and still fail in the pane with 'Browser
"chrome-for-testing" is not installed', which is what a stale
chromium-1217 against a wanted chromium-1237 did. Chromium is now
verified on both channels, the sandbox setting is stated rather than
inherited from a default, and a failure names the channel.
triple-c-playwright-heal repairs all of it on a container that is already
broken, including the missing socat that makes the pane report
"127.0.0.1 sent an invalid response" while the container side is
perfectly healthy. It verifies by launching a browser rather than
trusting the preceding steps — which is how the stale-revision case was
found — and lives in /usr/local/bin so a fix to it can still reach an
existing project.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A run is detached — cron has no terminal, and the app fires it as a detached
exec — so triggering one and watching the log was indistinguishable from
triggering one that died. Worse, `claude -p` writes its answer in a single
burst at the end, so a healthy run shows nothing but its log header for as
long as it is thinking. The honest reading of the old UI was "it stalled".
triple-c-task-runner now publishes a state file per run (pid, start time, log
path) and removes it from an EXIT trap. flock remains what actually prevents
overlapping runs; this is purely observability, so every reader verifies the
pid rather than trusting the file — a container stopped mid-run cannot fire a
trap, and a task stuck on "running" forever would be a worse lie than no
indicator at all. Stale files are cleared on read.
On top of that:
- `list` grows a status column: "running 4m12s" or "idle".
- `status [--id] [--watch]` answers "is it still going?" directly, with
elapsed time and the tail of the log when there is any output yet.
- `run` streams the log instead of blocking silently, and refuses to start a
task that is already running.
- The Automation tab marks a running task, disables its Run now button, and
polls while anything is in flight — including the second or two between
firing a run and the runner registering it, which is the exact window that
used to read as dead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every scheduled task failed with "Not logged in · Please run /login" while
the container's OAuth credential sat there, valid, the whole time.
The entrypoint snapshots the environment into ~/.claude/scheduler/.env so
cron jobs get more than cron's minimal env. It runs as root, and HOME was
in the capture list, so the file recorded HOME=/root. The task runner then
sources that file with `set -a`, overwriting the HOME cron gave the job.
`claude -p` looks for its credential under $HOME, finds no /root/.claude,
and exits 1. Logging still worked — SCHEDULER_DIR is expanded before the
sourcing — which is why this presents as a well-formed log of a task that
never authenticated.
Drop HOME from the captured set and write it explicitly instead; cron does
still need one. Then restore HOME across the source in the task runner too:
.env lives on the home volume, so every project created before this ships
keeps a stale copy of it until its container restarts, and the runner is
what has to survive that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every recreation commits the container to triple-c-snapshot-{id}:latest
and moves that tag; the image it pointed at keeps its layers and loses
its name. Nothing deleted those, so they accumulate — measured on one
real host, 7 orphans holding 7.4 GB, three of them from a single day's
work.
`sweep_orphaned_snapshots` removes them, under two conditions that are
the whole safety argument. Untagged: every image the app depends on
carries a tag, so a project's live `:latest` and a migration's
`pre-migration-*` rollback pin cannot match the filter at all. And
labelled `triple-c.managed=true`, which `docker commit` copies from the
container onto the image — the user's own dangling images are not ours
to delete. Removal is unforced on top of that, so Docker refuses while
any container is still built from the image, including the stopped
containers of projects that are not running; those are counted and left
for the next sweep.
It runs after a recreation, which is when the orphan it just made
becomes removable, and after a migration is accepted, which is the
moment dropping the pin turns the pre-migration snapshot into an orphan.
Both detached: this is housekeeping, and a full disk beats a project
that will not start. Each sweep clears every orphan it finds, so
recreations that predate it are cleaned up too.
The label string is now a constant rather than four literals, and a test
pins both filter conditions in place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two separate faults, both reachable from one screenshot of the Global
Environment Variables editor.
The value input was collapsed to a sliver, so a variable looked like it
had lost its value. `inputClass` carries `w-full`, and the `w-2/5` on the
key input did not beat it — class-attribute order is not what resolves
that conflict, stylesheet order is. The key therefore asked for the whole
row, and the value input, whose `flex-1` gives it a basis of 0 and only
the leftover space, got almost nothing. Widths now live on wrapper divs,
where nothing competes with them.
The fingerprint that detects custom-env changes was a plaintext
`KEY=VALUE` join, and it is written as the `triple-c.custom-env-fingerprint`
label. Labels are readable by anything on the host via `docker inspect`,
`docker commit` copies them onto the project's snapshot image, and the
recreation check logs both sides on a mismatch — so an API token set as a
custom variable was published to all three. It is hashed now, exactly as
`triple-c.git-token-hash` already was. Empty stays empty, so "nothing
configured" still reads as an empty label.
Changing the fingerprint format means every project's label mismatches
once: expect a single container recreation per project on next start.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four feature commits landed after the last doc sweep without reaching the
README, and cc5f691 documented only half of what it shipped. Adds the
sections for browser view, corporate CA certificates, base-image
migration and the LiteLLM model gateway, and refreshes the Key Files
table, which was missing about ten modules.
Fixes what had gone stale: the AWS directory mounts read-only at
/tmp/.host-aws and is copied in by the entrypoint, not mounted at
~/.aws; Project Home has six tabs, not five; the Automation tab creates
tasks as well as running them; Ctrl+Shift+left/right moves the active
tab, and tabs can be dragged.
Structurally, the sections are now grouped under Containers, Models and
Authentication, Bridges to the Host, and Inside a Project, with a
contents list and a table pointing at the other docs. Every section from
before survives, in the same words where nothing changed — the file was
414 lines of internals with no way in and no map of where anything was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither is referenced by the app or the build; branding/archive/README.md
says what each one was and why it did not survive the sizes an app icon
is actually drawn at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The icon is now a container with its right wall opened, so the enclosure
itself is the letter C, holding a >_ prompt: the two things the app is,
in one closed shape. It carries no type, so nothing goes illegible when
the shell draws it small, and it uses the app's own accent tokens rather
than a saturated orange field that fights the chrome behind it.
icon.ico contained a single 16x16 image, which Windows was upscaling into
the taskbar and every other slot — the likely cause of the artefact in
screenshot_for_fix/. It now carries 16, 24, 32, 48, 64, 128 and 256, each
rendered from vector rather than downsampled from one bitmap, and the
entries at 32 and below come from a separate optical source: at that size
the cursor bar closes up against the chevron, so the small variant drops
it, widens the mouth and thickens the strokes. A test asserts the .ico
keeps its small sizes so this cannot regress silently.
Also adds the icon.icns that macOS bundles have been building without,
points the favicon at our own mark instead of the missing /vite.svg, and
puts the SVG sources, the lockups and the regeneration script in
branding/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tab reordering (drag, with a dragged copy under the cursor, or Ctrl+Shift+arrow) and the browser view in a window of its own, with Keep-on-top and match-window.
Also, from using it: open a page in the container's browser at a chosen viewport, reachable from the Browser tab and the terminal's URL prompt; the Playwright split-tree bug that made every require("playwright") fail while the pane read green; the URL relay opening a different URL than the one on screen; preview builds that publish as prereleases, labelled with the version they preview, on the 0.4 line; and one CI run per push instead of two.
Opening a page is a container probe, a browser launch, a page load and
often a viewer start — several seconds during which the only feedback
was the click itself. Worse from a terminal, where the result appears in
a pane the user is not looking at.
So: the backend emits progress on the existing `container-progress`
channel at each step, the Browser tab renders that line whenever it is
set — the progress belongs to the project, not to whoever pressed the
button, which is what lets a terminal-initiated open report anywhere at
all — and the terminal's "In container" now selects the project's
Browser tab before starting, so the line has somewhere to appear.
Selecting a sub-tab from outside needed a route: `ProjectHome` keeps it
in local state, so `openProjectHomeTab` parks a request in the store and
the pane consumes it once. Consumed once, so it cannot fight the user's
own clicking afterwards.
Preview releases now prune themselves to the newest KEEP_PREVIEWS (2),
in a job that runs only if all three platforms published — a
half-finished run must not evict a good older build. The cleanup
workflow's manual sweep stays as the backstop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pointer-driven drag is still a mouse drag as far as the browser is
concerned, so moving a tab highlighted its label blue — something the OS
drag image never did, and the last visible difference between this and
a real drag.
`select-none` on the tab. The rename field gets `select-text` back:
`user-select` inherits, and selecting text is exactly what that field is
for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every push to the PR started two workflows on the same commit.
build-app.yml ran on pull_request and compiled all three platforms —
then published nothing, because every publishing step in it is gated on
`gitea.event_name == 'push'`. build-app-preview.yml compiled the same
three and published them. Six OS builds per push, half of them
unreachable.
So the PR trigger moves to the preview workflow, which was already doing
the identical compilation and has something to show for it.
build-app.yml is now push-to-main and manual dispatch only: releases.
Two things a pull_request event changes, handled rather than inherited:
`gitea.sha` can be the merge ref — not the commit anyone is testing, and
not something to hang a tag on — so the release's target comes from
`git rev-parse HEAD` in the checkout; and `gitea.ref_name` is the PR
number, so the release body uses `gitea.head_ref` when there is one.
The cost is one prerelease per PR commit touching app/**, which the
existing Cleanup Old Releases sweep already prunes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**The drag showed no tab.** Moving to pointer events lost the drag image
the OS used to supply, leaving a dimmed source tab and a 2px line —
which reads as "some setting changed", not "I am holding this tab". A
copy of the tab now follows the cursor, carrying its glyph and its real
label, grabbed at the offset it was picked up by so it sits where the
tab was.
**The URL relay opened a different URL than the one on screen.**
Observed: `repo.anhonesthost.net/…/tag/preview-63f3c54` arrived as
`repo.anhonsthost.nt/…/preview-63f3c54Butitprovesyournitpick…`. The
detector deleted *every* line break to undo PTY hard-wrapping, but a
terminal that wraps at a space emits the break **instead of** the space
— so deleting breaks also deletes the separators, gluing the following
paragraph onto the link and running the match past the host.
Only breaks the terminal inserted may be deleted, and those are exactly
the ones at the column width. The detector now takes a live column
getter and rejoins a line only when it is exactly that wide; every other
break becomes a space, which is also what stops a URL match. Lines
*longer* than the width are left alone — the stream had no break there,
so the one that follows is the application's own.
One case stays ambiguous: a URL whose length is an exact multiple of the
width is indistinguishable from one that was cut. That is pinned in a
test as known behaviour rather than papered over — the candidate is
shown in full and nothing opens without the user pressing Open.
**"In container" opened a page nobody could see.** It bound the browser
and stopped, leaving the user to find the Browser tab and press Start,
with nothing saying so — and from a terminal, no pane on screen at all.
Opening a page now starts the viewer if it isn't running, and the
terminal's prompt raises the pop-out window, because that caller has
nowhere else to put it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two version problems, one of them mine.
**Previews claimed x.y.0.** The preview workflow hard-coded the patch
number, so every preview installer reported 0.3.0 whatever it contained,
while the real build computes the patch from tags. It now runs the same
computation, so a preview is labelled with the version the release it
previews would carry.
**A new minor line started at the wrong number.** `compute-version`'s
fallback for "no tag matches this line yet" counted every commit in the
repository — fine as a bootstrap, wrong the moment a minor version is
bumped: the first 0.4 build would have been 0.4.234. A line nobody has
tagged is a new line, and a new line starts at .0.
With those fixed, VERSION moves to 0.4 — tab reordering, the browser
pop-out, opening pages in the container's browser and the Playwright
install fix are more than a patch bump. The next release is v0.4.0;
today's HEAD would have been 0.3.90 on the old line.
`app/package.json`, `package-lock.json`, `tauri.conf.json` and
`Cargo.toml` follow to 0.4.0. CI patches all four per build, so they are
the dev-time defaults rather than the source of truth — but a local
`tauri dev` shows them, so they should not still say 0.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Workflow artifacts do not work on this Gitea, in two different ways:
* upload-artifact@v4 cannot run at all. @actions/artifact v2's isGhes()
treats any GITHUB_SERVER_URL that is not github.com / *.ghe.com /
*.localhost as GitHub Enterprise Server and throws before making a
single request. act_runner sets it to this instance, so all three
platforms died with GHESNotSupportedError — after paying for the
whole Tauri build (run #265).
* @v3 uploads succeed and the files are downloadable by direct URL,
but Gitea does not *list* them: /api/v1/…/runs/<id>/artifacts returns
total_count 0 and the run page shows nothing (verified on run #267).
A build nobody can find is not a build.
So previews publish the way every other workflow here does: curl to the
releases API. One prerelease per preview, tagged `preview-<sha>`, with
all three platforms' bundles as assets — visible on the Releases page
with stable links.
The release is created in a job the three builds depend on rather than
get-or-created in each. They run concurrently, so per-job creation races
on one tag: the loser gets a 409, and the id parse then yields empty
while the step still reports success — the failure build-app.yml's
macOS job was hardened against after it happened for real. One creator
removes the race instead of handling it.
Asset upload keeps that hardening: delete-then-upload so a re-dispatch
replaces rather than 409s, --http1.1 and retries for the mid-stream
drops the macOS runner has produced (curl exit 92, exit 28), and an
explicit failure when a platform produced no bundles at all.
The `preview-` prefix is load-bearing: cleanup-releases.yml keeps recent
`v<x>.<y>.<z>` releases and separately deletes every release whose tag
does not start with `v[0-9]`, so previews are pruned by the cleanup
already in use and never crowd the real release list. sync-release.yml
is dispatch-only, so none of this reaches GitHub.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pane could only ever watch a browser something else had published.
This opens one: a URL and a viewport, launched inside the container and
bound so the pane picks it up. Two uses, one action — a sign-in page,
where the callback listener is *in* the container and the loop closes
with no host round trip and no auth bridge, and a dev server on container
loopback, which is how you watch a UI Claude is building.
Reachable from both places the question comes up: "Open a page…" in the
Browser tab, and an "In container" button on the terminal's URL prompt.
Verified first, because it decided the design: a second client cannot
join a bound browser. `chromium.connect()` against the published endpoint
times out in every URL form (`ws+unix://…`, with and without the trailing
path) — that socket speaks the dashboard's own transport, not the public
connect protocol. Whoever launches is therefore the only process that can
drive, so the helper is resident and holds the handle, and live resize
applies to pages we opened and never to `@playwright/mcp`'s. Those take
`--viewport-size` / `PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch, which the
docs now say.
The viewport is the interesting half. Resizing the *window* does nothing
to the page — the viewer is a CDP screencast, so a bigger window is the
same pixels drawn larger, which is why pages have been looking like they
were rendered small. `page.setViewportSize()` genuinely reflows: measured
against a `@media (max-width: 900px)` rule, it fires at 800×600 and
clears at 1440×900. Match-window mode pushes the pop-out's settled size
into it, debounced by generation counter because a drag emits `Resized`
continuously and each one costs a container exec.
Control is a polled JSON file in /tmp: no port, no second listener,
nothing added to the proxy's surface, and URLs travel as argv to `node`
so no shell ever parses one. A re-open with a helper already up
navigates instead of relaunching — otherwise the second page would throw
away the session the first one just signed into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Setup installed `playwright@latest` and `@playwright/cli@latest` together.
Verified on a real container, that produces a tree that looks right and is
broken: `@playwright/cli@0.1.18` pins `playwright-core@1.63.0-alpha`, npm
hoists it, and `playwright@latest` (1.62.1) nests its own
`playwright-core@1.62.1` beside it. The two cores want different browser
revisions.
The browser step runs the *resolved* — hoisted — CLI, so it downloads
chromium-1237. Every script Claude writes says `require("playwright")`,
gets the nested 1.62.1, and dies with:
Executable doesn't exist at …/chromium_headless_shell-1234/…
while the pane reports a browser installed, because one is. This is
deterministic, not bad luck: every container set up through the pane
lands in it.
So the viewer package is installed first, and the `playwright` version
installed after it is the one that package pins — read from the manifest
npm just wrote, falling back to `@latest` only if it cannot be read. One
core, one browser revision, both halves agreeing. Re-running "Set up
Playwright" repairs an already-split tree.
Detection now asks the question directly rather than listing a cache: it
asks each resolved copy for `chromium.executablePath()` and whether that
file exists — the viewer's copy *and* the one `require("playwright")`
returns, since those are routinely different. `needs_browser()` covers
"installed but not launchable", and the pane names both halves instead of
saying "install a browser" over a cache that visibly has one.
An absent field is "the probe didn't answer", never "skewed": containers
predating these fields must not be told their browsers are wrong. The
Rust side gets that from Option; the TypeScript mirror needed `!= null`,
which an existing test caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run #265 — this workflow's first ever run — built the app on all three
platforms and then lost every bundle at the upload step:
GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+
and download-artifact@v4+ are not currently supported on GHES.
v4 bundles @actions/artifact v2, whose isGhes() treats any
GITHUB_SERVER_URL that is not github.com, *.ghe.com or *.localhost as
GitHub Enterprise Server and throws before making a single request.
act_runner sets that variable to this Gitea instance, so v4 cannot work
here on any runner or any OS — and it fails *after* the whole Tauri
build has been paid for.
v3 uses the v1 artifact API, which Gitea implements. Both options this
workflow relies on, `if-no-files-found: error` and `retention-days`,
exist in v3.
The other workflows never hit this because they publish by curling the
Gitea releases API instead. Noted at the top of the file, with the
isGhes rule, so the pin is not "upgraded" back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten findings from the review of the previous commit, all applied.
**The tab drag is now pointer events, not HTML5 drag-and-drop.** Two
independent reasons, either one fatal. Tauri's `dragDropEnabled` blocks
HTML5 drag inside the webview on Windows, and it cannot just be turned
off — `TerminalView` needs Tauri's native drag-drop event, which is the
only one that carries dropped *file paths*. And an HTML5 drag carries a
`DataTransfer`: released over any text field in the app, the default
handler types `term:<uuid>` into it, and in Config that is then saved
with the project. Pointer events have neither problem, and the drag is
measured from the tabs on screen rather than from the event target, so
the marker and the drop agree even over the marker itself. Escape
abandons a drag; a press under 4px stays a click; the click that ends a
drag does not select.
**`Ctrl+Shift+←/→` no longer swallows word-wise selection.** It is bound
on `document` in the capture phase, so in any input — the rename field,
Config, Settings — it was taking the OS's extend-selection chord *and*
silently reordering the strip. Guarded by `inTextField()`, which
excludes xterm's helper textarea: that is an input-method shim, and the
terminal is where the shortcut matters most.
**The pop-out's state is read from the window, never remembered.** The
pane is unmounted whenever another Project Home sub-tab is selected, so
"Keep on top" came back Off over a window still floating on top.
`get_browser_view_popout_state` returns both facts from the window
itself, and the change event carries them. `poppedOut` is tri-state:
until the answer arrives the iframe is not mounted, because guessing
"not popped out" is what flashes a second viewer onto the browser.
Also: `popout::close` and the off-status emit in the supervisor are
behind the same epoch guard as the deregistration above them, so a
supervisor whose teardown outlives a restart can no longer destroy the
*new* session's window; `close()` returns its `destroy()` error instead
of logging it and reporting success, since the pane restores its iframe
on success; the drop marker is `pointer-events-none` and is placed
before the first *visible* tab at or past the slot, so it neither
refuses a drop nor vanishes when a `tabOrder` entry renders nothing; and
the "Keep on top" Toggle's accessible name now matches its visible text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the UI couldn't do: rearrange the tab strip, and watch the
browser while working somewhere else.
**Drag to reorder.** `moveTab`/`moveActiveTab` on the store, HTML5 drag on
the strip with a marker showing where the drop lands, `Ctrl+Shift+←/→` for
the same thing without a mouse. Reordering deliberately does not select
what it moves, so a drag aimed at a background tab doesn't yank the main
area away from a terminal mid-run. A tab being renamed is not draggable —
a draggable ancestor swallows the mouse-drag that selects text in its
input.
**Pop the browser view out.** `browser_view/popout.rs` opens the view's
existing token-bearing loopback URL as a second OS window, with a
"Keep on top" toggle so it can float above the app. Window-only: the
viewer, the proxy and the container are untouched, so popping out and
back interrupts nothing.
Three things it rests on:
- No capability lists that window, so it has no IPC surface — right for a
page served out of a container, and it must stay that way.
- The app CSP is irrelevant to it: `frame-src` constrains what the app's
document may *embed*, and this is a top-level document. The port range
and the token gate are what actually protect it, unchanged.
- The window is owned by the session, so the supervisor's teardown closes
it. A window onto a viewer that no longer exists is worse than none.
The pane drops its iframe while popped out — two viewers can both *drive*
the browser, and two cursors on one page is not a feature.
`lib.rs`'s `on_window_event` is now guarded on `label() == "main"`. It
fires for every window and its body stops every container and exits, so
without the guard closing a pop-out would quit the app.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npx playwright install chromium` downloaded ~150 MB of browser that then
died with "error while loading shared libraries: libglib-2.0.so.0" —
verified, not inferred, against the current image. The image shipped none
of Chromium's shared libraries, which is why `apt install
google-chrome-stable` looked like the cure: apt was quietly installing the
same set as Chrome's own dependencies.
Installing them at runtime instead converges on the worst possible state.
The libraries land in the container's writable layer, so they are re-paid
after every Reset and *lost* on base-image migration, which replays apt
from a manifest. The browsers ride in ~/.cache/ms-playwright, inside the
home volume, and survive both — leaving a 400 MB browser present with its
libraries gone. So the libraries are baked and the browsers are not: each
half now lives where it already persists.
The layer runs `npx --yes playwright@latest install-deps chromium` rather
than a hand-written apt list. Ubuntu 24.04's 64-bit-time_t transition
renamed a swathe of these packages (libasound2t64, libatk1.0-0t64,
libglib2.0-0t64, …) and a new Chromium dependency would drift straight back
into the launch failure this exists to prevent; letting Playwright name its
own dependencies is self-maintaining. It sits immediately after Node — npx
is its only prerequisite — and well above the shim COPYs, so editing a shim
does not re-run it.
The `--dry-run` that follows is a build-time assertion, not decoration: on a
platform Playwright has no list for, `install-deps` prints a warning and
returns having installed **nothing, with exit status 0**. Without the
assertion that ships a broken image behind a clean build log.
Measured, on a build of this file with the layer applied over an otherwise
identical image: +99 packages, +334 MiB unpacked and +119 MiB compressed
(2950 → 3284 MiB, 759 → 878 MiB). Two thirds of that is not reachable by
trimming — libgbm1, which Chromium needs, pulls mesa-libgallium, which
pulls libllvm20. A chromium-only apt list measures 247 MiB against
install-deps' 341 MiB; the ~94 MiB difference is xvfb and the CJK/emoji
fonts, kept because the base ships no fonts at all and every page this
feature exists to display would otherwise render as tofu.
Verified on real builds, both architectures: a `--platform linux/arm64`
build of this file installs the same 99 packages and passes the same
assertion. On the new amd64 image, `playwright install chromium` with no
`--with-deps` and no `install-deps` launches headless Chromium 151.0.7922.34
and loads a page; on the old image the identical script fails on
libglib-2.0.so.0.
`install.rs` no longer runs `install-deps` unconditionally — that would be a
minutes-long apt run for nothing on a current image. It asks
`install-deps --dry-run` first and skips the install when everything is
present, saying which of the two happened on the progress stream. The check
is Playwright's rather than a probe of our own for library names, so check
and fix cannot disagree about what the dependency set is. Note that
`--dry-run` exits 0 both when everything is installed and when Playwright
has no list for the platform, so the verdict is read from its output.
Containers on older images stay the normal case until people migrate, and
they still work: on such an image the simulation cannot even resolve the
package names (the index is cleaned in every base image), which reports as
"couldn't tell" and installs — the right answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
Behind a TLS-terminating corporate proxy every HTTPS call inside a container
fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API
requests. There was no mechanism at all: installing the certificate by hand
inside a container is lost on Reset and had to be repeated per project.
A global CA path in AppSettings with a per-project override on Project, taking
either a single certificate file or a directory. It is bind-mounted read-only
at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by
entrypoint.sh on every start, so it survives recreation, migration and Reset.
Four things this gets right that are easy to get wrong:
* update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely
copied in is ignored in silence. Certificates are renamed, by
container_cert_name() in Rust and a mirrored few lines of shell.
* The system store only serves curl/git/apt. Node — and so Claude Code itself —
needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and
Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil
(libnss3-tools, added to the image).
* Those vars are set from Rust at creation, never exported by the entrypoint —
a terminal is a docker exec and sees nothing the entrypoint exported. They are
emitted empty when no CA is configured, since docker commit bakes env into the
snapshot image.
* triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so
a CA rotated in at the same location still forces a recreation.
Verified end to end against a real container and a self-signed CA: curl, node,
python and git all complete a TLS handshake against a server signed by it and
all three fail in the same container without it; the env vars are visible from
a docker exec session; the store is cleaned when the setting is cleared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
Detection missed the npx cache, so a Playwright installed through Claude
Code's MCP setup (`npx @playwright/mcp@latest`, which unpacks into
~/.npm/_npx/<hash>/node_modules and no node_modules at all) was invisible.
The probe now globs that cache alongside the existing roots and reports
every root it consulted.
It also read `has_bind` off whichever manifest resolved first. Verified
that npm does not hoist for global installs and that the `playwright`
wrapper ships no types/types.d.ts, so `npm i -g playwright` made the pane
call a current build "predates browser.bind()". The probe now hops from
the wrapper to its nested playwright-core.
The messages no longer offer `@playwright/mcp` as a way through setup: it
bundles a playwright-core that binds but never `@playwright/cli`, so that
route could not have worked. It is named only for what it does do.
New `install.rs` + two commands do the setup, streaming on the existing
`container-progress` event and re-probing on success:
* playwright + @playwright/cli into /workspace as `claude`, --no-save.
/workspace is not a bind mount (projects mount at
/workspace/{mount_name}), so nothing of the user's is touched, no sudo
is needed, and Node resolves it from scripts in the project.
* A browser, as its own action with the size stated first: apt libraries
as root, then the download, then a real headless launch to prove it
works. The base image ships none of Chromium's shared libraries, which
is why a download could succeed and the browser still not start.
Chromium and the Chrome channel are both offered — @playwright/mcp
asks for `chrome` specifically. A certificate failure is reported as a
container trust-store problem rather than a broken install.
Installing is always user-initiated; opening the tab only probes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
Two compounding bugs made `claude setup-token` unusable, both measured against
2.1.226 under a pty rather than reasoned about.
**The sign-in URL was truncated.** The CLI emits it as an OSC 8 hyperlink and
slices the *visible* text of that hyperlink to the terminal width: a 346
character URL arrives at 80 columns as five separate hyperlink emissions, each
carrying the whole URL in its parameter and 80 characters of it on screen. The
transcript scraper picked up the first slice — a URL that parses, points at
claude.com, and cannot authorise anything. The ANSI stripper now surfaces the
OSC 8 target and `claude-token-link` carries it to the UI, which prefers it over
the scraped text. It still goes through `sanitizeRelayUrl` with the
ANTHROPIC_SIGN_IN_HOSTS allowlist before display and again before `openUrl` — an
OSC 8 parameter is never rendered, which makes it the easier place to hide a
hostile host, not a trusted one. The wrapped-display fallback is kept for CLI
versions that print a bare URL.
**A rejected code hung the flow.** On a bad paste the CLI prints `OAuth error:
Invalid code…` / `Press Enter to retry.` and blocks on stdin instead of exiting;
nothing recognised that, so the exec sat until the 15-minute timeout with the UI
still saying "Finishing sign-in". Given the first bug handed the user a truncated
URL, an invalid code was the likely first outcome. The streamed output is now
scanned for that message, `claude-token-code-rejected` reopens the input with an
explanation, and the Enter is sent so the next code has a prompt to land in —
bounded by MAX_CODE_ATTEMPTS, after which the flow reports a failure. An
undeterminable exec exit status is logged rather than silently read as success.
**A wrapped token was rejected *and* leaked.** `stty cols` fails silently, and an
80-column fallback splits the ~103 character token across two lines: the parser
saw a too-short fragment and failed, while the redactor masked the first line —
which carries the `sk-ant-` marker — and printed the second, the tail of a live
credential, to the UI in clear. `scan_credential_body` now reassembles a run
across hard wraps and both the parser and the redactor use it, so they cannot
disagree about where a credential ends. A join only happens across a break at a
plausible terminal margin (>= 40 columns) and only for a run not already long
enough to be a whole credential — without that second guard a repainting TUI
welds one frame's token onto the next frame's first word. The length floor is
applied to the reassembled body, so a fragment is still never accepted.
Also: `stty cols` raised 200 -> 400 (the URL alone needs ~350), and `ESC ( B` is
handled as the three-byte charset designation it is — it prefixes every repaint
frame, and treating it as two bytes emitted a stray `B` that could glue itself
onto a token and make the parser refuse it.
`submit_claude_token_code`'s single-write behaviour is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
Moves the last hand-made piece of the Windows fix into version control. Verified by deleting the manual junctions from the build VM first, so CI had to recreate them from scratch.
The Windows fix was the only part of it living outside git — two
junctions created by hand on the build VM. Rebuild that VM, add a second
Windows runner, or reset the SYSTEM profile and Windows builds break
again with an error that points nowhere near the cause.
Tauri downloads candle.exe, light.exe and makensis.exe, and all three
are 32-bit. A runner running as SYSTEM has %LOCALAPPDATA% under
C:\Windows\System32\config\systemprofile, and WOW64 redirection serves
32-bit processes reading System32 from SysWOW64, where those directories
do not exist. The bundlers cannot see their own folder: candle exits
0x80131700, makensis reports "Unable to start child process, error 0x2",
and Tauri surfaces neither — only "failed to run candle.exe".
The job now junctions the SysWOW64 view onto the System32 originals when
it detects a profile inside System32, and skips entirely otherwise, so a
runner running as a normal user is unaffected. Idempotent, and written
with goto rather than nested blocks to avoid the delayed-expansion trap
that already bit the MSVC step.
Verified rather than assumed: the hand-made junctions were deleted from
the build VM before this was pushed, so this run has to recreate them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code absorbed MCP natively, so Triple-C stops managing it. Also adds permission modes, Project Home, Tier-1 accessibility fixes, the Auth Bridge, shared Claude authentication and the scheduler UI.
8 commits plus review fixes. 87 frontend tests, 34 Rust tests.
Dropping the MSI did not help: makensis.exe is 32-bit like candle.exe
and failed the same way ("Unable to start child process, error 0x2").
The cause was WOW64 redirection sending 32-bit processes reading
C:\Windows\System32 to SysWOW64, where the toolset directory does not
exist.
The build VM now carries junctions from the SysWOW64 view of
systemprofile\AppData\Local\tauri and systemprofile\.cache to the
System32 originals. Verified on the runner: candle.exe reports WiX
3.14.1.8722 and makensis reports v3.11, both exiting 0 from the path
that previously failed.
Both targets build again, so the .msi comes back. Artifact collection
fails if either installer is missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping the MSI did not help, because the problem was never WiX.
makensis.exe is 32-bit exactly like candle.exe and light.exe, lives in
the same SYSTEM-profile cache, and failed the same way — "Unable to
start child process, error 0x2" instead of 0x80131700.
The cause is WOW64 redirection: a 32-bit process reading
C:\Windows\System32 is served C:\Windows\SysWOW64, where the toolset
directory does not exist, so the bundlers cannot see their own folder.
The build VM now carries two junctions from the SysWOW64 view of
systemprofile\AppData\Local\tauri and systemprofile\.cache to the
System32 originals. Verified afterwards on the runner: candle.exe
reports "WiX Toolset Compiler version 3.14.1.8722" and exits 0, and
makensis reports v3.11 and exits 0 — both from the same path that failed
before.
So both targets build again and the .msi asset comes back. Artifact
collection fails if either installer is missing rather than tolerating
an empty directory.
This is a host-side patch for a runner running as SYSTEM. A runner
running as a normal user has a LOCALAPPDATA outside System32 and needs
none of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WiX's candle.exe/light.exe are 32-bit. On a SYSTEM-run runner Tauri
caches WiX under C:\Windows\system32\config\systemprofile\..., and WOW64
redirection sends 32-bit processes to SysWOW64 where that directory does
not exist, so candle exits 0x80131700. Tauri aborts the whole bundle on
one target's failure, so the MSI was suppressing the NSIS installer too
and Windows produced no artifact at all.
NSIS is what the project already relies on for Windows upgrades. Drops
the .NET 3.5 gate, which existed only for WiX; keeps the MSVC step,
which is what makes the app link. Artifact collection now fails when no
installer is produced rather than tolerating an empty directory.
To restore the MSI, run the runner as a normal user and set
--bundles msi,nsis.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The MSI target needs WiX, whose candle.exe and light.exe are 32-bit. On
a runner running as SYSTEM, Tauri caches the WiX toolset under
%LOCALAPPDATA% = C:\Windows\system32\config\systemprofile\..., and WOW64
redirection points 32-bit processes at SysWOW64, where that directory
does not exist. candle.exe cannot see its own folder, the CLR fails to
start, and it exits 0x80131700 — surfaced only as "failed to run
candle.exe".
Proven by running the identical toolset, as the same SYSTEM identity,
from C:\wixtest (exit 0) versus the systemprofile path (0x80131700).
Because Tauri aborts the entire bundle when one target fails, the MSI
was also suppressing the NSIS installer — so Windows produced no
artifact at all. NSIS is what the project already relies on for Windows
upgrades, so dropping MSI costs the .msi asset and nothing else.
Removes the .NET 3.5 gate, which only existed for WiX. The MSVC step
stays: that is what makes the app link, and it works.
Artifact collection now fails when no NSIS installer is present instead
of tolerating an empty directory with 2>nul, so a silent packaging
regression cannot pass as a green build again.
To restore the MSI later, run the runner as a normal user — whose
LOCALAPPDATA sits outside System32 — and set --bundles msi,nsis.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rust's `dirs` crate resolves LOCALAPPDATA on Windows through
SHGetKnownFolderPath, which reads the process token rather than the
environment, so the override changed nothing and 32-bit candle.exe still
hit WOW64 redirection under the SYSTEM profile.
Removing it rather than leaving a plausible-looking non-fix in the
workflow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It looked right and did nothing. Rust's `dirs` crate resolves
LOCALAPPDATA on Windows through SHGetKnownFolderPath, which reads the
process token rather than the environment, so Tauri still cached the WiX
toolset under the SYSTEM profile and 32-bit candle.exe still hit WOW64
redirection.
Removing it rather than leaving a plausible-looking non-fix in the
workflow. The diagnosis in the previous commit stands; only the remedy
was wrong. Running the runner as a normal user, whose token resolves
LOCALAPPDATA outside System32, is the actual fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WiX's candle.exe/light.exe are 32-bit. A SYSTEM-run runner has
%LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, where
Tauri caches the WiX toolset — and WOW64 redirection sends 32-bit
processes reading System32 to SysWOW64, which has no such directory. The
CLR then fails to start with 0x80131700 and Tauri reports only "failed
to run candle.exe".
Verified: the same binary and identity exits 0 from C:\wixtest and
0x80131700 from the systemprofile path.
Pointing LOCALAPPDATA outside System32 avoids redirection, needs no
stored credential, and is a no-op for runners already running as a
normal user.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build-windows compiled and linked fine but died at bundling with only
"failed to run candle.exe". The real cause was neither .NET nor the
runner identity, both of which I chased first and was wrong about.
candle.exe and light.exe are 32-bit. A runner running as SYSTEM has
%LOCALAPPDATA% = C:\Windows\system32\config\systemprofile\AppData\Local,
which is where Tauri caches the WiX toolset. WOW64 redirection sends any
32-bit process reading C:\Windows\System32 to C:\Windows\SysWOW64 — and
the WixTools directory exists only in the 64-bit view. So candle.exe
could not see its own directory, the CLR failed to start, and the
process exited 0x80131700, surfaced in the Application event log as
".NET Runtime version 4.0.30319.0 - This application could not be
started."
Proven rather than assumed: copying the identical toolset to C:\wixtest
and running it as the same SYSTEM identity exits 0, while the
systemprofile path exits 0x80131700. Test-Path confirms the WOW64 view
of that directory does not exist.
Pointing LOCALAPPDATA at a path outside System32 avoids redirection.
This fixes it for any runner running as a service or as SYSTEM, without
needing a stored user credential, and is a no-op where the runner
already runs as a normal user.
For the record, two earlier theories were wrong. .NET 3.5 was missing
and is now installed from the ISO payload, but candle targets .NET 4.x
(its config uses loadFromRemoteSources, a 4.0-only element) so that was
never the blocker. Adding explicit supportedRuntime entries changed
nothing. Both are documented here so the next person does not repeat
them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
%VSEXIT% and %ERRORLEVEL% inside a parenthesised cmd block are
substituted at parse time, not run time, so the installer's real exit
code was never read. Uses delayed expansion now.
Also checks for the .NET 3.5 runtime before building: WiX candle.exe
needs it, and Tauri aborts the whole bundle when the MSI target fails,
which silently suppresses the NSIS installer too. Fails early with the
exact dism command rather than at bundle time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-ups to the provisioning step.
The exit-code check never worked. %VSEXIT% and %ERRORLEVEL% inside a
parenthesised cmd block are substituted when the block is PARSED, not
when it runs, so the installer's real result was never read — the log
printed "installer failed with " with an empty code, then continued
anyway. It happened to be harmless because the install had in fact
succeeded, but a genuine failure would have sailed past. Now uses
delayed expansion.
Added a .NET 3.5 check. WiX 3.x candle.exe is a .NET 2.0/3.5
application, and Tauri aborts the entire bundle when the MSI target
fails — so a missing runtime silently costs the NSIS installer too, not
just the MSI. Windows 11 ships NetFx3 as DisabledWithPayloadRemoved and
Windows Update could not supply the payload on our runner even across a
reboot; it needed /Source from a mounted ISO. Rather than guess, the job
now fails early with the exact dism command.
Verified on the runner: MSVC Build Tools 2022 installed, the Rust build
completed in 3m59s and produced triple-c.exe, and NetFx3 is now Enabled
with v2.0.50727 present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build-windows failed on this PR with "linker `link.exe` not found",
while build-linux and build-container passed — the code was fine, the
runner environment was not.
The job installs Rust and Node conditionally but assumed the MSVC C++
toolchain was hand-provisioned. A runner without it registers normally,
advertises windows-latest, accepts the job, downloads the entire crate
graph and only then fails at link time. That also means a bare runner
coming online turns a job that would have queued for a capable machine
into a failed build.
Installs the VC++ workload when vswhere cannot find it, matching the
existing conditional Rust and Node steps. rustc locates MSVC through
vswhere and the registry rather than PATH, so no dev-shell activation is
needed. Installer exit 3010 (success, reboot pending) is treated as
success.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial review of the branch produced findings across four areas.
This addresses them, plus the Windows CI environment.
Secrets. commit_container_snapshot baked the container's full env into
the per-project snapshot image, so the shared OAuth token — and the AWS
keys, git token and gateway master key — outlived revocation and were
readable via docker inspect. Verified against Engine 29.6 that a commit
body's config merges over the container's: keys cannot be dropped but
can be overwritten, so all of them now commit as KEY=. clear_claude_token
additionally rewrites images from earlier builds and reports honestly
when a tag could not be rewritten.
The recommendation to move the token out of env entirely was not taken,
with reasoning: apiKeyHelper is a different auth method that outranks
CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no
file-based delivery exists. The durable exposure — the image — is what
is closed here. Separately noted, not fixed: entrypoint.sh captures the
token into the scheduler's .env inside the persisted volume.
URL spoofing. Three call sites reached openUrl with container-controlled
strings, one of which the review missed (the WebLinksAddon handler).
The sign-in URL was scraped from container output with a longest-match
tie-break and no userinfo check, so claude.ai@evil.tld rendered as
"claude.ai…" in a truncating element. There is now one sanitizer in
front of every sink — scheme allowlist, no userinfo, C0/C1 and quote
rejection, host allowlist for the sign-in case, first-match — and the
origin renders un-truncated. The toast is keyed so a changed URL
remounts, closing a bait-and-switch where the user read one URL and
clicked another.
Migration. The rollback pin was best-effort: a tag failure was logged
and the migration continued past remove_container, after which the
final commit overwrote the only copy of the old system layer. It now
aborts before anything destructive and reads the tag back. /var was
destroyed while the ordinary recreate path preserves it — making the
"safe" alternative to Reset more destructive than Reset's alternative;
data-bearing subtrees are now detected and disclosed in the pre-flight
rather than copied, since tarring a live database onto a different
base's packages is a corruption risk. resume_migration now verifies the
migration-state label instead of reporting success for a container that
never swapped. dismiss actually resolves the record rather than leaving
the feature permanently refusing to migrate. Start and Reset are guarded
while a migration is live.
Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and
advertised URL are derived together so they cannot drift. Disabling it
now stops it. App exit runs teardown concurrently under a budget with a
visible shutting-down state instead of blocking for minutes. Auto-starts
retry when Docker is not up yet, and the polling-recovery path now
reconciles, so interrupted migrations are still recovered. Auth-bridge
forwards are capped, closing a container-driven fd exhaustion.
Windows CI. build-windows failed on this branch with "linker link.exe
not found". The runner had no MSVC build tools and the workflow assumed
a hand-provisioned machine, so a bare runner registers, accepts jobs and
fails at link time after downloading the whole crate graph. The job now
installs the VC++ workload when vswhere cannot find it, matching how it
already conditionally installs Rust and Node.
192 Rust tests, 274 frontend tests, both builds clean, zero warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
read_head computed the head terminator index and discarded it, returning
the whole receive buffer. authorize() then split that buffer on CRLF and
treated every colon-bearing line as a header, last occurrence winning —
so any bytes a client sent after the head were promoted to headers.
A cross-site fetch with a text/plain body is CORS-safelisted and not
preflighted, so a body of
a=x\r\nSec-Fetch-Site: same-origin\r\n
overwrote the real cross-site value and the gate returned Allow. The
same trick overrode Host, defeating the anti-rebinding check too. The
result was unauthenticated mouse, keyboard and CDP control of a browser
running inside a container that has passwordless sudo — reachable from
any page the user happened to visit, with the port range being a fixed
8-wide window that is trivially scanned.
read_head now returns the terminator index and the caller authorizes
against that slice only, while still replaying the full buffer into the
tunnel so pipelined bodies are not lost. Duplicate Host, Origin and
Sec-Fetch-Site headers are now refused outright rather than resolved
last-wins, since that resolution is what turns any smuggling primitive
into a full bypass and no legitimate client sends two.
Three regression tests, including a guard assertion that the untruncated
buffer really was accepted before, so the test cannot quietly stop
testing the bypass.
Found by adversarial review, verified by extracting the real authorize()
and running the attack against it.
148 Rust tests, frontend build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Projects were pinned to the image they were first created from. Both
create paths preferred triple-c-snapshot-<id>:latest whenever it
existed, and container_needs_recreation compared the container's live
image against the triple-c.image label — which create_container wrote
from the same image it created from. A tautology that could never fire.
The only escape was Reset, which calls remove_project_volumes and
destroys the login, skills and transcripts.
Measured consequences on this host: real projects are missing socat (so
the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not
work), plus Mission Control and triple-c-sso-refresh, and sit 61
packages behind the base including ca-certificates, openssl and curl.
Detection. create_container now writes triple-c.base-image-id (the image
ID, not RepoDigests, which local-built and custom images do not have)
and triple-c.create-image. container_needs_recreation takes the expected
create-image and compares against the latter, so the check means
something. base-image-id is deliberately NOT compared: a base bump would
otherwise silently recreate from the snapshot, consuming the "you should
migrate" signal without migrating. Staleness is surfaced, never acted on
automatically.
Migration keeps the volumes. /home/claude and ~/.claude are volumes and
the image's copy is seed-only — permanently masked after first mount —
so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH
keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only
root-level state is rebuilt: apt packages are replayed against the new
base rather than copied, so no stale libc is dragged forward, and
/usr/local, /opt and the non-bind-mounted parts of /workspace are copied
verbatim with tar --skip-old-files so they can never clobber a newer
base binary.
docker diff is not used: on a snapshot-derived container it reports only
changes since the last commit. Raw image-vs-image diffing is filtered
through dpkg ownership because it otherwise lies — 8,677 raw path
differences on a real project reduced to 2 genuinely user-authored
files, both loose /workspace-root files.
Crash safety. snapshot:latest keeps pointing at the old image until the
final commit, so any crash before it self-heals on next start. Later
crashes are caught by reconcile_project_statuses. The rollback pin is a
docker tag: 0.057s and 0 bytes. Rollback restores the system layer only
— volumes are never touched — and the UI says so rather than implying a
time machine.
Fixes an infinite recreation loop shipped with the MCP removal. docker
commit propagates labels to the image, so a container created from a
snapshot inherited its non-empty triple-c.mcp-fingerprint and the
one-shot shim recreated it again on every start, forever. Lineage labels
are now always written explicitly.
Documents the second, separate bug this uncovered: Dockerfile changes
under /home/claude never reach an existing project, migration or not,
because the volume masks them. Anything that must stay upgradable
belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh.
145 Rust tests, 227 frontend tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four features, plus a latent bug fix.
llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.
Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.
Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.
URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.
Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.
188 frontend tests, 107 Rust tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the Automation tab: it could list, toggle, run, log and remove
tasks but not create them, so task creation still meant dropping to the
CLI. Adds add_scheduled_task and update_scheduled_task, plus a task
editor with cron presets and a plain-English reading of the expression.
Every field is free user text, so all of it goes to the scheduler as a
bare argv vector through bollard — no shell, no quoting. Validation is
shape-only rather than metacharacter scrubbing: length caps, no control
characters in single-line fields, no leading-dash name, absolute
working_dir. Verified by round-tripping a prompt containing
`; rm -rf /`, `$(id)`, backticks and newlines: it landed byte-for-byte
in the task JSON with nothing executed.
The scheduler CLI has no `edit`, so update is add-then-remove with the
add first — a rejected edit leaves the original intact. The new id is
surfaced in the editor rather than hidden.
Root-cause fix, and the more serious half of this commit:
triple-c-scheduler never validated --schedule, and rebuild_crontab
regenerates the entire crontab and pipes it to `crontab`, which rejects
the whole file if any line is malformed — with the error thrown away by
`2>/dev/null || true`. A single bad schedule therefore silently
unscheduled every other task in the container while reporting success.
Reproduced directly. It matters because the global CLAUDE.md tells
Claude to drive this CLI, so Claude could trigger it unprompted.
`add` now validates the expression and exits non-zero, and
rebuild_crontab reports a rejected crontab instead of swallowing it,
keeping the offending file for inspection. Verified against the real CLI
in this container: a bad schedule is refused without disturbing an
existing task's crontab entry, and `0 9 * * 1-5`, `*/30 * * * *`,
`0,30 8-17 * * *` and `0 0 1 1 *` are all still accepted. The Rust layer
validates independently, agreeing with vixie cron on 23 probed
expressions including `1/2` and `*/0` being invalid.
121 frontend tests, 44 Rust tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reset is destructive in a way its name does not advertise:
rebuild_project_container deletes both project volumes, so it wipes the
claude login, anything installed in the container, and every saved
session transcript. It was a single unconfirmed click in the overflow
menu, while the comparably destructive Remove already confirmed. Adds
ConfirmResetModal, which names each loss and says explicitly that the
host-side mounted folders are untouched.
Docs: the user guides still described the pre-Project-Home UI. Sixteen
factually wrong statements corrected, including "expand the Config
panel" (six sites), the actions table (Reset and Remove are in an
overflow menu, Files is a tab), a progress modal that no longer exists,
a double-click-to-rename gesture ProjectRow never had, the Full
Permissions boolean, an incomplete reserved-env list, and the claim in
TECHNICAL.md that OAuth tokens survive a Reset. Both layout diagrams and
the project tree were rebuilt from the filesystem.
New sections cover permission modes with the exact CLI mapping, Project
Home, Sessions, capability tiles, Automation, shared authentication, the
Auth Bridge and its security posture, and keyboard shortcuts.
Known gap recorded rather than papered over: the Automation tab manages
existing scheduled tasks but cannot create them — no add command is
registered — so task creation remains `triple-c-scheduler add` in the
terminal.
87 frontend tests, 34 Rust tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UI for the shared Claude token: a Settings section showing token state
with Authenticate and Revoke, an acquisition modal built on the shared
Modal (sign-in link handed to the host browser via the opener plugin,
plus the code input that answers `setup-token`'s stdin prompt — the flow
cannot complete without it), and a per-project opt-out toggle shown only
for the Anthropic backend.
Cancellation: acquire_claude_token previously had only two exits,
completion and a 15-minute timeout, and held the single-flight guard for
the whole time. Closing the dialog therefore locked the user out of
retrying for up to 15 minutes. Adds cancel_claude_token, backed by a
oneshot claimed and released in lockstep with the input guard, selected
on in the run loop so it wins the race and tears the exec down. The
dialog's Cancel now calls it and closes either way.
Also refreshes CLAUDE.md, which had drifted: it documented the deleted
ProjectCard, and asserted that new IPC commands need permission grants
in capabilities/default.json — they do not, that file covers plugin
commands only. Adds the conventions that would otherwise bite:
container_needs_recreation() is purely label-based and never diffs env,
so container-affecting state needs its own label; and #[serde(default)]
on a bool yields false regardless of intent.
Corrects the claim that Reset preserves credentials. Reset calls
remove_project_volumes, which deletes both the home and claude-config
volumes, so it wipes ~/.claude, the OAuth token, installed skills and
session transcripts.
84 frontend tests, 34 Rust tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.
Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.
Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.
Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.
This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.
Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.
The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.
Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DESIGN-REVIEW.md is Fable's review of the v0.3.0 UI: token gaps and three
WCAG AA contrast failures, the modal/accessibility audit, and an IA
proposal that promotes the project from a sidebar card to a tabbed
main-area view.
ROADMAP.md covers Claude Code feature coverage — the five settings.json
keys currently surfaced, the gaps worth closing, the ones deliberately
skipped, the authentication handoff design, and phase sequencing.
Also published as an artifact for easier reading.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Permission modes: replaces the binary full_permissions flag with a
PermissionMode enum (Plan/Default/AcceptEdits/Bypass). Flag mapping is
defined once in PermissionMode::cli_args() and used by the terminal, the
web terminal, and the scheduler:
Plan -> --permission-mode plan
Default -> (no flag)
AcceptEdits -> --permission-mode acceptEdits
Bypass -> --dangerously-skip-permissions
Choices verified against `claude --permission-mode` on 2.1.226.
full_permissions is retained and effective_permission_mode() falls back
to it, so existing projects.json needs no migration.
Bug fix: triple-c-task-runner ran `claude -p ... --dangerously-skip-
permissions` unconditionally, ignoring the project's setting entirely.
It now reads TRIPLE_C_PERMISSION_MODE, which is injected into the
container, added to the reserved env blocklist, propagated through the
entrypoint's cron env filter, and tracked by a new
triple-c.permission-mode label so a change forces recreation.
Introspection: new commands/inspect_commands.rs exposes read-only views
into the container over docker exec — Claude sessions (parsed from
~/.claude/projects/<cwd>/<uuid>.jsonl), installed capabilities (skills,
agents, commands, hooks, plugins, natively-configured MCP servers), and
the triple-c-scheduler task list, logs and notifications.
Task/session ids are validated against a strict allowlist and every
parameterized call runs as a bare argv vector via bollard, so no shell
is involved. Stopped containers return empty results rather than errors.
No UI yet; that lands with the Project Home view.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the removal begun in the previous commit.
Backend: deletes models/mcp_server.rs, storage/mcp_store.rs and
commands/mcp_commands.rs, the McpStore on AppState, the four IPC
handlers, Project::enabled_mcp_servers, build_mcp_servers_json(),
compute_mcp_fingerprint(), the MCP_SERVERS_JSON env injection, the
mcp-fingerprint label, and the whole MCP container lifecycle.
create_container() and container_needs_recreation() lose their
mcp_servers/network_name parameters.
Container: entrypoint.sh no longer merges MCP_SERVERS_JSON into
~/.claude.json. MCP_SERVERS_JSON stays in the reserved env blocklist.
Security: the Docker socket is no longer auto-mounted for stdio+Docker
MCP servers — it now mounts only when allow_docker_access is set.
Migration: old containers were created with
network_mode=triple-c-net-<projectId> and refuse to start once that
network is gone. docker/network.rs becomes docker/legacy_cleanup.rs with
label-driven, best-effort removal of leftover MCP containers and the
per-project network, called on both delete and recreate.
container_needs_recreation() now forces a rebuild for any container
carrying a non-empty triple-c.mcp-fingerprint label or attached to a
triple-c-net-* network, moving it onto the default bridge. Both can be
dropped a release later.
Docs: drops the MCP sections from README/HOW-TO-USE/TECHNICAL and adds a
short note pointing at Claude Code's native `claude mcp` / `/mcp` /
.mcp.json instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code manages MCP natively now (`claude mcp add/list/remove`,
`.mcp.json`, `/mcp`), so Triple-C's own MCP server library is redundant.
Deletes components/mcp/, hooks/useMcpServers.ts, the MCP sidebar tab and
rail icon, the per-project enable checkboxes on ProjectCard, the
mcpServers slice of the Zustand store, the four IPC wrappers, and the
McpServer/McpTransportType types.
Rust backend is untouched in this commit; the commands simply become
unreachable. Backend removal and the legacy container/network cleanup
follow separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
git rejects `--global` and `--file` together ("error: only one config
file at a time"), so the credential helper and user.name/user.email
were never written — /home/claude/.gitconfig was left nonexistent and
containers had no git identity or HTTPS token helper.
Drop `--global` and keep `--file /home/claude/.gitconfig`, which is the
intended target: the entrypoint runs as root at that point, so
`--global` would have resolved to /root/.gitconfig, and the existing
`chown claude:claude /home/claude/.gitconfig` already assumes the
--file path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Capture the non-obvious implementation gotchas from PR #7
(terminal-layout-statusbar) in TECHNICAL.md: wrapper-vs-host xterm
padding, global StatusBar controls, recordingSessionIdRef transcript
pinning, active-only Jump-to-Current state, and the Zustand
object-merge rule for publishing action callbacks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The transform used `s,^\./,workspace/,`, which rewrites the workspace
*contents* (`./foo` -> `workspace/foo`) but leaves tar's root member as a
bare `./`. That `./` entry carries the source root's mode/mtime, and on
extraction tar stamps them onto the extraction directory itself.
Match the leading `.` instead (`s,^\.,workspace,`) so the root member is
renamed `./` -> `workspace`, giving the archive a proper `workspace/`
directory entry and no bare `./`. The extraction directory is left
untouched. Contents, hidden files, excludes, symlink targets and the
`flags=rh` hardlink handling are unchanged.
Verified in-container: archive top level is exactly `workspace/` +
`home-claude/`, no `./` member, node_modules excluded, extraction into a
0755 dir leaves it 0755, workspace/.git preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review caught that `flags=r` disables rewriting of both symlink AND
hardlink target names. Leaving symlink targets alone is intended, but a
hardlink's stored target is an archive-internal reference to another
member's name — when member names become `workspace/...` but the
hardlink target stays `./hard_link`, extraction fails hard:
tar: workspace/file.txt: Cannot hard link to './hard_link':
No such file or directory
`flags=rh` rewrites regular member names and hardlink target names
together (keeping the pair consistent) while still leaving symlink
targets untouched. Verified in-container: extract exit 0, symlink target
preserved, hardlink pair shares one inode, nesting under workspace/ intact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The backup archive placed the workspace at the archive root (`./...`)
while the sanitized home config sat under `home-claude/`. On extraction
the workspace files scattered loose into the extraction directory and
only `home-claude/` showed up as a distinct folder, so the backup read
as "config only, workspace missing" — and some archive viewers didn't
surface the root-level entries at all.
Add `--transform='flags=r;s,^\./,workspace/,'` so the workspace nests
under `workspace/`, parallel to `home-claude/`. `flags=r` scopes the
rewrite to member names only, leaving symlink targets (relative and
absolute) intact. Excludes still match the pre-transform names.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- F2: upload_host_file_to_container now reads the dropped file into a Vec
inside the blocking task and sizes the tar entry from those exact bytes,
rather than stat-then-stream where a file changing size between the
stat and the read could desync the tar header and silently corrupt the
archive. Still runs off the async worker; memory stays bounded by the
256 MiB drop cap.
- F4: the "Backup saved" confirmation now auto-clears after 8s (guarded
against clobbering a newer status message) instead of lingering in the
project card's status line indefinitely.
F1 (claimed AWS CLI regression from empty-env neutralization) was a false
positive: verified against aws-cli 2.35 that an empty AWS_ACCESS_KEY_ID is
treated as absent and botocore falls through to ~/.aws/credentials (the
call reached AWS and returned InvalidClientTokenId for the file's key, not
PartialCredentialsError). No change needed.
cargo check / tsc / vitest all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- L-e: route terminal file drops purely by a bounds hit-test instead of
the `active` flag. Inactive panes are display:none (zero-size rect) so
they never match; a zero-size guard makes that explicit. Correct for
the current tabbed layout and future-proof for split panes, where a
drop on a visible-but-unfocused pane previously matched no handler.
- L-f: stream the dropped file straight into the upload tar inside a
blocking task (new exec::upload_host_file_to_container) instead of
reading the whole file into a Vec and then re-packing it. Peak memory
drops from ~2x to ~1x the file size, and the synchronous file IO no
longer runs on the async worker.
cargo check / tsc / vitest all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- M1 (race): don't mount the host AWS dir for static-credential Bedrock.
sync_bedrock_credentials() is the sole writer of ~/.aws/credentials in
that mode, and mounting /tmp/.host-aws let the entrypoint's
`rm -rf ~/.aws; cp -a` race that write at startup (only when a global
aws_config_path was also set). Static keys + AWS_REGION env are
self-sufficient and don't need the host config, so skipping the mount
removes the dual-writer entirely.
- L-a: exit codes are now read via wait_for_exec_exit(), which polls
inspect_exec until the exec reports finished, so a non-zero tar/cred
exit isn't missed by reading exit_code too early. The backup only fails
on a definitively non-zero code (falls back to the empty-output check
if undeterminable).
- L-b: fixed two comments that referenced the old
write_bedrock_static_credentials name (now sync_bedrock_credentials).
- L-c: entrypoint only rewrites ~/.claude.json when awsAuthRefresh is
actually present, avoiding a needless jq reformat on every non-SSO
start.
- L-d: backup script traps EXIT to remove its mktemp staging dir even
when tar fails, so failed backups don't accumulate temp dirs (with the
sanitized config copy) in the container.
L-e (drop routing) is a non-issue: the layout is tabbed, so only one
terminal pane is ever visible; the active-guard routing is correct.
Verified the race fix, trap cleanup, grep guard, and exit-code polling.
cargo check / tsc / vitest all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- L4: sync_bedrock_credentials (renamed from write_bedrock_static_
credentials) now also clears a stale ~/.aws/credentials when the
project no longer uses static-credential Bedrock, so static keys don't
linger unused in the persistent home volume after switching backends.
Skipped when /tmp/.host-aws is mounted (host-managed ~/.aws). HOME is
also set explicitly on the exec env for robustness.
- M1: the Backup button now has a tooltip and the success toast notes
that the archive includes MCP/config which may contain MCP-embedded
API keys (OAuth tokens are excluded) — keep it private.
- L2: backup now uses async file IO (tokio::fs::File + AsyncWriteExt,
tokio::fs::remove_file) instead of blocking std::fs between awaits;
dropped-file reads use tokio::fs::metadata/read.
- L5: upload_host_file_to_terminal explicitly `mkdir -p`s
/tmp/triple-c-drops instead of relying on Docker's tar extractor to
create the parent dir.
Verified L4 cleanup guard, L5 mkdir, async IO, and exit-code paths
against real containers. cargo check / tsc / vitest all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes from the code review of this branch:
- Backup requires a running container (it runs via `docker exec`, which
can't run on a stopped one). Removed the misleading "Backup" button
from the stopped-project actions, added an explicit running check with
a clear error, and corrected the doc comment. (H1)
- jq sanitization fallback no longer leaks secrets: if ~/.claude.json
can't be parsed, the backup substitutes an empty object and warns to
stderr instead of copying the raw file (which held primaryApiKey /
oauthAccount). Verified the raw key never reaches the archive. (H2)
- Dropped-file paths typed into the terminal are now always single-quoted
(with '\'' escaping), not only when they contain whitespace — a name
like `foo$(whoami).txt` was previously sent raw into the shell. (M2)
- write_bedrock_static_credentials checks the exec exit code via the new
exec_oneshot_env_status and fails loudly on a write/chmod error instead
of silently reporting success. exec_oneshot keeps its
ignore-exit-code behavior so list_container_files is unaffected. (M4)
- Backup removes a partial/truncated archive on any stream error and
treats a non-zero tar exit code as failure (a truncated gzip was
previously reported as success). (L1)
- Dropped files are capped at 256 MiB to avoid ballooning host RAM
(the file is read fully into memory then re-tarred). (M3)
- Stopped excluding .git/objects from the backup so git history,
including unpushed commits, is preserved faithfully. (L3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop files onto a terminal pane and they're copied into the container and
their in-container paths typed into the prompt, so Claude Code can read
them for reference — mirroring the existing image-paste flow.
Backend: upload_host_file_to_terminal reads the dropped host file and
writes it under /tmp/triple-c-drops/<name> in the session's container,
returning that path. Rejects directories and unreadable paths.
Frontend: TerminalView subscribes to Tauri's webview onDragDropEvent
(OS file drops are intercepted at the webview level, so HTML5 ondrop
wouldn't expose paths). The window-wide event is guarded by the pane's
`active` flag plus a bounds hit-test so a drop only affects the terminal
it landed on; multiple files are uploaded and their paths inserted
space-separated (quoted when they contain spaces).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends download_container_backup to also capture the container's home
config so MCP servers, settings, and skills set up directly via Claude
Code (stored in ~/.claude.json / ~/.claude, on the home/config volumes
that a Reset wipes) survive a backup/restore cycle.
Secrets are stripped per the "exclude secrets" choice: ~/.claude.json is
filtered through jq to drop primaryApiKey/oauthAccount/customApiKeyResponses
(mcpServers and settings are kept), and ~/.claude/.credentials.json (the
OAuth tokens) is omitted. Staged config is archived under home-claude/ in
the tarball. Verified on the Ubuntu/jq container base.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a manual backup button on each project card (next to Start/Reset
when stopped, and next to Files when running) that saves a gzipped
tarball of the container's /workspace to a host path via the native save
dialog.
Backend: download_container_backup runs `tar czf -` inside the container
(so excludes + compression happen there rather than streaming a 16 GB
workspace) and pipes stdout straight to the chosen file. Regenerable
build artifacts (node_modules, target, .git/objects) are excluded so the
archive stays restore-sized. Returns bytes written; stderr is captured
for error reporting and a zero-byte result is treated as failure.
Works whether the container is running or stopped (only requires that it
exists). Verified on the Ubuntu/GNU-tar container base.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a project switched backends (e.g. Bedrock -> Anthropic), the
recreated container kept authenticating against Bedrock, and SSO kept
firing after switching away. Three root causes, all fixed:
1. Recreation builds the new container from a `docker commit` snapshot.
commit always bakes the previous container's full ENV into the image
(an empty commit Config does NOT strip it, and the commit API cannot
remove env). So CLAUDE_CODE_USE_BEDROCK=1 / AWS_* survived into the
new container. Fix: create_container now explicitly clears every
managed auth key the active backend does not set (MANAGED_AUTH_KEYS),
so create-time env overrides the stale baked-in values.
2. awsAuthRefresh was written into ~/.claude.json (persisted home
volume) and never removed, so Claude Code kept invoking
triple-c-sso-refresh after switching to a non-SSO backend. Fix:
entrypoint now deletes awsAuthRefresh when AWS_SSO_AUTH_REFRESH_CMD
is unset, idempotent both ways.
3. Static/session AWS creds were baked into Config.Env at create time,
so a stop/start kept stale creds and rotated keys never refreshed
without a full recreation. Fix: static creds are no longer injected
as env vars; write_bedrock_static_credentials() writes
~/.aws/credentials (0600, secrets via exec env not argv) on every
start, and removes a stale ~/.aws/config left from a prior profile/SSO
session. Static creds also dropped from the bedrock fingerprint so a
key rotation refreshes in place instead of forcing recreation.
Adds exec_oneshot_env() for env-carrying one-shot execs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to PR review on terminal-layout-statusbar:
- [Major] Pin STT transcripts to the originating terminal. The single
useSTT instance is bound to the live active session, which can change
mid-recording. Capture the session id at recording start in a ref and
inject the transcript there instead of the live sessionId, so text
always lands in the terminal where recording began.
- [Minor] Clear the status-bar scroll state when the active terminal
unmounts, and null out termRef on dispose, so scrollActiveToBottom
can't point at a disposed terminal. Tab switches don't unmount, so
this only fires when the active session is actually closed.
- [Nit] Fix the terminal padding comment to match the symmetric value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Terminal layout fixes for the xterm pane:
- Stop the terminal grid from clipping its rightmost column / bottom
row. The padding was on the element xterm mounts into, which the
FitAddon measures; the grid overhang got clipped. Padding now lives on
a wrapper and the xterm host fills it with no padding.
- Move the STT mic from a floating bottom-left overlay into the status
bar (far right). A single useSTT instance bound to the active session
now lives in App; Ctrl+Shift+M routes through the store.
- Move "Jump to Current" from a floating terminal overlay into the
status bar. The active TerminalView surfaces its scroll state and
scroll action via the store.
- Tighten terminal padding (was 8/12/48/16) now that nothing floats over
it, so the terminal claims as much area as possible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cmd-batch upload step POSTed to /releases unconditionally. On a
re-run the v{VERSION}-win tag already exists, so Gitea returns 409, the
findstr id parse yields an empty RELEASE_ID, and uploads go to a
malformed .../releases//assets URL -- all silently swallowed by cmd and
`curl -s`, so the step reported success while attaching no assets.
Rewrite in PowerShell mirroring the macOS job: look the release up by
tag first and create only on 404, throw if the id can't be resolved,
delete same-named assets left over from partial runs before re-upload,
and fail loudly (ErrorActionPreference=Stop, curl.exe -fsS with retries,
$LASTEXITCODE check).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a time-bounded `claude update` to entrypoint.sh that runs as the
claude user before the container is marked ready, so every terminal
session launches the latest CLI. Non-fatal and capped at 120s so an
offline/slow network never blocks container readiness; PATH covers both
~/.claude/bin and ~/.local/bin install locations.
Add flex-shrink-0 to the FileManagerModal header/footer so a long file
list can't squeeze them and the scroll region stays robust.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Right-click a tab (or double-click) to rename. Renamed labels show
as "ProjectName: CustomName" and are stored in the project's
renamed_session_names map. The entry is cleared on tab close.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New fields: GlobalAwsSettings.default_model_id, plus
GlobalOllamaSettings and GlobalOpenAiCompatibleSettings (base_url +
default_model_id each). When a per-project base_url or model_id is
blank, the container env vars and config fingerprints fall back to
the global value. Container recreation is triggered whenever the
resolved value changes, so editing a global default updates existing
projects on next start.
UI: added the new fields to AwsSettings and two new global settings
components, slotted into the Backends accordion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multiple-open accordion with per-section state persisted to
localStorage. Sections: General, Backends, Container, Git/SSH,
Tools, Updates. General is open by default; the rest are collapsed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persist collapsed state in localStorage. When collapsed, render a
narrow rail with Projects/MCP/Settings icon buttons that expand the
sidebar to that view on click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls in 15 upstream commits since the April 3 bundling snapshot
(msieurthenardier/mission-control). Notable changes:
- agentic-workflow rewritten as the "fast" variant: per-leg design and
implement, single review and commit across the whole flight
- New Skill-Project Boundary section: skills no longer read or write
project-owned artifacts by literal heading
- routine-maintenance scoped to post-mission only; adds state-machine
reachability and cache freshness audits
- Test metrics capture threaded through debrief, maintenance, and flight
- Crew prompts no longer carry skill-required instructions; SKILL.md is
the protocol
- Worktree git strategy removed; standardized on {target-project}
- Jira artifact template removed upstream
Local URL correction in init-project/README.md preserved
(anthropics/flight-control -> msieurthenardier/mission-control).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous fix only addressed the network flake; a re-run after any
upload failure still tripped over the leftover release record. The
naive POST /releases got 409 from Gitea, the grep-pipe parser yielded
an empty RELEASE_ID, and pipefail aborted with an opaque exit 1.
Now:
- Look up the release by tag first; reuse on 200, create on 404, fail
loudly on anything else.
- Validate RELEASE_ID is non-empty and surface the response body if
parsing fails.
- Before uploading each asset, check whether the release already has
an asset with that name (from a partial prior run) and DELETE it so
the POST is replace-not-conflict.
- Set -euo pipefail explicitly so the script's failure modes are
predictable rather than dependent on the runner's default flags.
Network hardening from the previous commit (HTTP/1.1, retries, -f) is
preserved. Linux and Windows blocks unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
macOS upload has been intermittently failing with curl exit 92
("HTTP/2 stream not closed cleanly") for several releases (v0.3.12,
v0.3.10, v0.3.1 all landed with empty asset arrays despite the per-tag
release record being created). It is not a size issue — Linux uploads
the 81MB AppImage on the same Gitea instance without trouble while the
Mac dmg is only 13.6MB.
Adds `--http1.1` to sidestep HTTP/2 stream multiplexing flakes on the
macOS runner, `-f` so HTTP errors no longer fail silently under `-s`,
and `--retry 5 --retry-all-errors --retry-delay 5 --max-time 600` to
absorb transient drops. Linux and Windows blocks unchanged; an inline
note in the YAML calls out where to mirror this if those start
failing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mac/Windows release builds failed the Tauri version-mismatch check:
tauri (2.11.0) vs @tauri-apps/api (2.10.1). The Linux fix only updated
the Rust lockfile; the npm lockfile was still at 2.10.x. Both lockfiles
now resolve to 2.11.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's pre-build version check failed: tauri (2.10.2) vs @tauri-apps/api
(2.11.0). Both the Cargo.toml and package.json caret-pin to 2, so this is
purely a lockfile resolution fix — `cargo update -p tauri --precise
2.11.0` brings the Rust side up to match. Schema regeneration is included
since the gen/schemas/ output is keyed to the Tauri version.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sandbox mode: new per-project toggle that turns on Claude Code's bash
sandbox inside the container. Adds `bubblewrap` and `socat` to the
Dockerfile (the two Linux deps required by the sandbox), and emits a
managed `sandbox` block into `~/.claude/settings.json` via the existing
CLAUDE_CODE_SETTINGS_JSON entrypoint merge:
- `enabled` mirrors the Triple-C toggle and is always emitted, so the
entrypoint's recursive jq merge clears any prior on-state from the
persisted named volume — Triple-C is authoritative.
- `enableWeakerNestedSandbox: true` because we run inside Docker without
privileged user namespaces.
- `allowUnsandboxedCommands: false` to disable the `dangerouslyDisableSandbox`
escape hatch — opting into the sandbox shouldn't come with a runtime
bypass.
When sandbox is on, a SANDBOX_INSTRUCTIONS section is appended to
CLAUDE_INSTRUCTIONS so Claude can guide users through allowing extra
paths/domains, excluding `docker *`/`watchman *` from the sandbox, and
the rule that `sandbox.enabled` is owned by Triple-C. The Claude-Code
settings fingerprint includes sandbox state (only when on, to avoid
spuriously flagging existing containers for recreation on upgrade).
Bedrock service tier: new optional field on the per-project Bedrock
config. When set, exported as ANTHROPIC_BEDROCK_SERVICE_TIER (added in
Claude Code 2.1.122) and included in the Bedrock fingerprint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the deprecation notes on dialog `ask`/`confirm` permissions
(now aliased to `allow-message`/`deny-message` and slated for removal
in Tauri v3). No behavior change — generated artifacts only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trigger is workflow_dispatch exclusively so builds happen only when
explicitly requested from the Actions UI, not on every branch push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors build-app.yml's three-platform matrix (Linux/macOS/Windows)
but uploads the bundles as workflow artifacts instead of creating
Gitea releases or syncing to GitHub, so feature branches can be
smoke-tested without cluttering the release streams.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When Docker isn't detected on startup, surface a dialog offering a
one-click install (pkexec + get.docker.com on Linux, brew cask on
macOS, winget on Windows) with a graceful fallback to manual steps
and a link to official documentation. Install output streams back
to the UI via a tauri event.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add bottom padding to terminal containers so FitAddon proposes one
fewer row, leaving visible space below Claude Code's mode indicator.
Previously the bottom status line (e.g. "bypass permissions on") was
clipped against the container edge in fullscreen TUI mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix wasn't enough: the NodeSource setup_22.x script runs its
own internal `apt-get update` without retries. When that hit the Ubuntu
mirror-sync issue (stale Packages.gz with mismatched hash), the script
silently bailed without configuring the NodeSource repo. The next
`apt-get install -y nodejs` then installed Ubuntu's default nodejs 18,
which ships without npm, breaking the `npm install -g pnpm` step.
Changes:
- Replace the `curl ... | bash -` NodeSource setup with manual GPG key +
repo file configuration, giving us direct control over apt-get update
retries.
- Add the same 5-attempt retry loop (with 10s sleep and lists cleanup)
to the Python 3 and Docker CLI steps, since both also do an
apt-get update and would hit the same failure mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:50:42 -07:00
531 changed files with 165964 additions and 7437 deletions
# prerelease: true keeps it off "latest" — this is a branch build,
# not something anyone should install by accident.
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
rem Tauri links with MSVC, so rustc needs link.exe and the Windows SDK.
rem This job previously assumed a hand-provisioned runner; a runner
rem without them registers fine, advertises windows-latest, accepts the
rem job, downloads the whole crate graph and only then fails at link
rem time with "linker `link.exe` not found".
rem
rem rustc finds MSVC via vswhere and the registry rather than PATH, so
rem installing is enough - no dev-shell activation needed here.
rem
rem Delayed expansion is required: %VAR% inside a parenthesised block
rem is substituted when the block is PARSED, not when it runs, so both
rem %ERRORLEVEL% and %VSEXIT% would read as their pre-block values.
setlocal enabledelayedexpansion
set "VCPATH="
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
if exist "%VSWHERE%" (
for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCPATH=%%i"
)
if defined VCPATH (
echo MSVC build tools already present at !VCPATH!
) else (
echo MSVC build tools not found - installing Visual Studio Build Tools
rem 3010 means installed, reboot pending - a success for our purposes.
if not "!VSEXIT!"=="0" if not "!VSEXIT!"=="3010" (
echo Visual Studio Build Tools installer failed with exit code !VSEXIT!
exit /b 1
)
echo Visual Studio Build Tools installed
)
endlocal
- name:Work around WOW64 redirection for 32-bit bundlers
shell:cmd
run:|
rem Tauri downloads its bundlers - candle.exe, light.exe and
rem makensis.exe - and every one of them is 32-bit. When the runner
rem runs as SYSTEM its %LOCALAPPDATA% is under
rem C:\Windows\System32\config\systemprofile, and WOW64 redirection
rem serves any 32-bit process reading System32 from SysWOW64 instead -
rem where those directories do not exist. The bundlers then cannot see
rem their own folder: candle exits 0x80131700 and makensis reports
rem "Unable to start child process, error 0x2". Tauri surfaces neither,
rem only "failed to run candle.exe", which is why this is worth a
rem comment this long.
rem
rem Junctioning the SysWOW64 view onto the System32 originals makes the
rem redirected path resolve to the same files. A runner running as a
rem normal user has a profile outside System32 and skips all of this.
echo.%LOCALAPPDATA%| find /I "\system32\" >nul
if errorlevel 1 goto skipwow
if not exist "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri" mkdir "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local" mkdir "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local"
if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
if not exist "%WINDIR%\System32\config\systemprofile\.cache" mkdir "%WINDIR%\System32\config\systemprofile\.cache"
if not exist "%WINDIR%\SysWOW64\config\systemprofile\.cache" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\.cache" "%WINDIR%\System32\config\systemprofile\.cache"
echo WOW64 junctions in place for the SYSTEM profile
goto :eof
:skipwow
echo Runner profile is outside System32 - WOW64 junctions not needed
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project can optionally enable full permissions mode (`--dangerously-skip-permissions`), giving Claude unrestricted access within the sandbox.
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
This file is the architectural tour: what each subsystem is and why it works the way it does.
| Document | For |
|---|---|
| [HOW-TO-USE.md](HOW-TO-USE.md) | Using the app — first launch, projects, settings, troubleshooting |
| [BUILDING.md](BUILDING.md) | Building from source on Linux, macOS and Windows |
| [TECHNICAL.md](TECHNICAL.md) | Technology choices and the dependency inventory |
| [ROADMAP.md](ROADMAP.md) | Claude Code feature parity, gaps and sequencing |
| [CLAUDE.md](CLAUDE.md) | Working *on* this repo, for Claude Code |
| [branding/](branding/README.md) | The mark, the palette, and how the icons are generated |
## Contents
- [Architecture](#architecture) — layout, tabs, shortcuts, Project Home
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
| **Files** | Browse, view, rename and create folders inside the container, upload host files into the directory on screen, and save one file back out to the host — see [Host File Transfers](#host-file-transfers). A whole tree still comes out through **Back up container** |
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
progress modal.
## Permission Modes
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Five states,
mapped to CLI flags by `PermissionMode::cli_args()`:
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to
the legacy `full_permissions` flag (`true` → Bypass) for records written before the change. Changing
the mode affects terminals opened **from then on** — a running `claude` process keeps the argv it
was launched with.
Scheduled tasks honour it too. The mode is injected as `TRIPLE_C_PERMISSION_MODE` (via
`as_env_value()`) and written as the `triple-c.permission-mode` container label; the entrypoint
snapshots it into `~/.claude/scheduler/.env`, and `container/triple-c-task-runner` translates it
back into flags for its headless `claude -p` run. Because it travels as container env, a mode change
only reaches the scheduler after the container is recreated on its next start (the label mismatch
forces that).
## Containers
### Container Lifecycle
1.**Create**: New container created with bind mounts, env vars, and labels
2.**Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers, injects Claude Code settings
3.**Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY
4.**Stop**: Container halted (filesystem persists in named volume); MCP containersstopped
5.**Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
6.**Reset**: Container removed and recreated from scratch (named volume preserved)
1.**Create**: New container created with bind mounts, named volumes, env vars, and labels
2.**Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, installs any CA certificates, injects Claude Code settings, rebuilds the scheduler crontab
3.**Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
4.**Stop**: Container halted (its filesystem layer and both named volumes persist)
5.**Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
6.**Migrate**: The project is moved onto a newer base image without losing its volumes — see below
Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed
at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots`
clears those after a recreation and after a migration is accepted. It only ever removes images that
are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a
migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so
Docker itself refuses while any container — including a stopped project's — is still built from the
image.
7.**Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
### Base-Image Migration
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
every recreation re-commits it. So without an explicit act, a project stays on the base image it was
first built from **forever** — it never picks up a new `/usr/local/bin` shim, a new `socat`, or a
security update. **Update container base…** (Project Home → overflow menu) is the non-destructive
way out; Reset is the destructive one. `docker/migration.rs` owns it.
- **Staleness is surfaced, not acted on.** `triple-c.base-image-id` records the lineage and
`get_container_staleness` reports it as a banner, but it is deliberately *not* compared in
`container_needs_recreation`. Comparing it there would recreate every project *from its own
snapshot* on the next base bump: churn on the old base, and the "you should migrate" signal
consumed without migrating. A missing lineage label means "unknown, probe instead" — never
"stale".
- **What comes across**: the apt package delta and user-authored files, computed by diffing two
filesystem manifests through dpkg ownership and presence-in-the-new-base. (`docker diff` is
useless here — on a snapshot-derived container it only reports changes since the last commit.
Measured on a real project, manifest diffing turned 8,677 raw path differences into 2 genuinely
user-authored ones.) Both named volumes are untouched at every step, so `$HOME`, the OAuth login,
skills, transcripts and scheduler tasks simply re-attach.
- **What does not**: `/etc` is reported but never copied — the old lineage has
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
having both breaks every `apt-get update`. `/var` is not copied either, and that is the one way
migration is *more* destructive than an ordinary recreate: a database under `/var/lib` rides along
on a recreate, but a migration builds from the base and the apt replay hands back an empty
cluster. `unpreserved_data()` names those directories in the pre-flight, the banner and the final
report.
- **Crash-safety**: `:latest` keeps pointing at the old lineage until the final commit, so any
failure before that self-heals — the next start just recreates from the old snapshot. After the
container swap, a `triple-c.migration-state=in-progress` label plus a persisted state file let the
app offer **resume** or **rollback**. Rollback restores the system layer only; work done in
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers |
| `/tmp/.host-aws` | AWS config directory | Bind | Read-only; entrypoint copies to `~/.aws`; for Bedrock auth |
| `/tmp/.host-ca` | CA certificate file or directory | Bind | Read-only; entrypoint installs into the system and NSS stores |
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
### Authentication Modes
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
removal, and by nothing else.
Each project can independently use one of:
### Corporate CA Certificates
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
- **OpenAI Compatible**: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
A global **Certificates** setting (`AppSettings::ca_cert_path`) with a per-project override
(`Project::ca_cert_path`), accepting a single certificate file **or** a directory. It follows the
SSH/AWS host-mount pattern — read-only bind mount at `/tmp/.host-ca`, applied by the entrypoint on
every start — so it survives recreation, migration and Reset.
> **Note:** Ollama and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
- **Certificates are renamed to `.crt`.** `update-ca-certificates` globs `*.crt`, case-sensitively;
a `.pem` merely copied into `/usr/local/share/ca-certificates/` is ignored in total silence.
`container_cert_name()` in Rust does the renaming, mirrored in a few lines of shell in the
entrypoint. A single-file mount lands at `/tmp/.host-ca/<name>.crt`, so the entrypoint only ever
sees a directory.
- **The system store is not enough.** Only curl, git and apt read it. Node — and therefore Claude
Code itself — needs `NODE_EXTRA_CA_CERTS`; Python and requests need
`REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`; Chromium reads neither and wants its own NSS database at
`~/.pki/nssdb`, seeded with `certutil` (from `libnss3-tools`). The NSS step warns and continues
rather than failing the start.
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
session is a `docker exec`, which inherits the container's configured env and sees nothing the
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. They are emitted
**empty** when no CA is configured, because `docker commit` bakes env into the snapshot image.
- **`triple-c.ca-fingerprint` covers the certificate bytes, not the path.** Replacing a rotated CA
at the same location still forces the recreation that copies it in. Clearing the setting actively
**removes**`triple-c-*.crt` from the container — `/usr/local/share` rides the project's snapshot,
so turning the feature off has to undo, not merely stop.
### Container Spawning (Sibling Containers)
@@ -60,26 +237,256 @@ When "Allow container spawning" is enabled per-project, the host Docker socket i
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
### MCP Server Architecture
### Docker Socket Path
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers as a Beta feature. MCP servers extend Claude Code with external tools and data sources.
The socket path is OS-aware:
- **Linux/macOS**: `/var/run/docker.sock`
- **Windows**: `//./pipe/docker_engine`
**Modes**: Each MCP server operates in one of four modes based on transport type and whether a Docker image is specified:
Users can override this in Settings via the global `docker_socket_path` option.
| Mode | Where It Runs | How It Communicates |
|------|--------------|---------------------|
| Stdio + Manual | Inside the project container | Direct stdin/stdout (e.g., `npx -y @mcp/server`) |
| Stdio + Docker | Separate MCP container | `docker exec -i <mcp-container> <command>` from the project container |
| HTTP + Manual | External / user-provided | Connects to the URL you specify |
| HTTP + Docker | Separate MCP container | `http://<mcp-container>:<port>/mcp` via Docker DNS on a shared bridge network |
## Models and Authentication
**Key behaviors**:
- **Global library**: MCP servers are defined globally in the MCP sidebar tab and stored in `mcp_servers.json`
- **Per-project toggles**: Each project enables/disables individual servers via checkboxes
- **Auto-pull**: Docker images for MCP servers are pulled automatically if not present when the project starts
- **Docker networking**: Docker-based MCP containers run on a per-project bridge network (`triple-c-net-{projectId}`), reachable by container name — not localhost
- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation
- **Config injection**: MCP server configuration is written to `~/.claude.json` inside the container via the `MCP_SERVERS_JSON` environment variable, merged by the entrypoint using `jq`
### Authentication Modes
Each project can independently use one of:
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. Triple-C can run that gateway for you — see [Model Gateway](#model-gateway-litellm-sibling-container).
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
> they get first-class backends of their own rather than going through a translation layer.
#### Model alias variables
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
Triple-C therefore sets all four:
| Variable | Value |
|---|---|
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
A local server usually serves exactly one model, so pointing every alias at it is the right
default. If you run a second, smaller model for cheap background work, set **Background model**
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
### Model Gateway (LiteLLM sibling container)
For providers that only speak OpenAI's API, Triple-C can run **LiteLLM** as a sibling container
(`docker/gateway.rs`, `gateway-container/`) that gives Claude Code the Anthropic-format front end it
Python's `webbrowser` — simply fails. The URL relay forwards the *request* to the host, where the
user's real browser is. Nothing is rendered or forwarded from the container; only the URL travels.
It complements the Auth Bridge below: the relay gets the login page open, the bridge lets the
callback land.
**Transport — an OSC escape sequence, following `osc52-clipboard`.**`container/triple-c-open`
writes
```
ESC ] 7777 ; open ; <base64(url)> BEL
```
to **`/dev/tty`**, and `TerminalView.tsx` picks it up with `term.parser.registerOscHandler(7777, …)`.
`/dev/tty` rather than stdout is the whole point: the shim usually runs as a grandchild of
something that captures its children's output (Claude Code invoking `gh auth login` as a tool
call), so a printed sentinel line — the `###TRIPLE_C_SSO_REFRESH###` approach — would be swallowed
by the intermediate process and never reach the terminal. A control sequence on the controlling
terminal always arrives, and is invisible to terminals that don't know it. Base64 keeps a `;`,
`BEL` or `ESC` inside the URL from breaking out of the sequence.
**Container side** — `container/triple-c-open`, installed as `xdg-open`, `sensible-browser`,
`www-browser`, `x-www-browser`, `gnome-open`, `gvfs-open`, `kde-open`, `open`, and exported as
`$BROWSER`. Ubuntu 24.04 ships a real `/usr/bin/sensible-browser` (from `sensible-utils`), so that
one is `dpkg-divert`ed rather than merely shadowed by a `/usr/local/bin` symlink; `www-browser` and
`x-www-browser` are registered through `update-alternatives` and pinned with `--set`, because
`sensible-browser` probes them by absolute path and because a later `apt install firefox` must not
be able to steal them. `xdg-open` is diverted pre-emptively so installing `xdg-utils` inside the
container cannot displace the relay. `BROWSER` is an image-level `ENV` — terminal sessions are
separate `docker exec`s and never see what the entrypoint exported — and the entrypoint also
forwards it into the scheduler's cron environment file.
**No terminal attached** (cron-driven scheduled tasks, or a plain `docker exec` from outside
Triple-C): there is no handshake and nothing to wait for, so the shim never blocks. The write to
`/dev/tty` fails, and it prints the URL in plain text on its own line and exits 0 — which lands in
the scheduler task log where a human can still act on it.
**Security posture — the container is the untrusted side.**`app/src/lib/urlRelay.ts` validates
before anything reaches `openUrl`: `http:`/`https:` only (`file:`, `javascript:`, `data:` and every
registered protocol handler rejected), no embedded credentials, no control characters or
whitespace, length-capped, and returned WHATWG-normalized so the prompt shows exactly what will
open. Nothing opens automatically — the user confirms in the existing `UrlToast`, and prompts are
rate-limited (5 per 10 s, repeats of the same URL collapsed) so a loop in the container cannot bury
the UI.
**Web terminal** — deliberately *not* a copy of the desktop behaviour. The browser there belongs to
a remote viewer, possibly on a phone across a tunnel, so `terminal.html` renders the relayed URL as
a tap-to-open link banner with the same scheme allowlist and rate limit, and opens nothing by
itself. The OSC handler is registered regardless so the sequence is consumed rather than painted as
garbage.
### Auth Bridge
Browser-based logins run *inside* a container (`claude login`, `aws sso login`, Concourse
`fly login`) start an ephemeral HTTP listener on the container's loopback and expect the host
browser's redirect to reach it. `auth_bridge/` closes that gap:
- Listeners are discovered by parsing `/proc/net/tcp{,6}` every 2 seconds — the image ships no
`ss`, `netstat` or `lsof`. Only `TCP_LISTEN` rows bound to loopback are considered; wildcard
binds are deliberately ignored (that is the port-mappings feature's job).
- Each discovered port is bound on the host at **the same port number**, on `127.0.0.1` (required)
and `[::1]` (best effort) — never a wildcard address. Node resolves `localhost` to IPv6 first, so
`claude login` often binds `::1` alone; the bridge follows the family it actually finds.
- Traffic is carried in over the Docker API by an attached exec running `socat`, because container
IPs are not routable from the host on Docker Desktop.
- Ports already covered by the project's port mappings are skipped, and a host port that is already
in use is reported as a conflict rather than fought over.
Opt-in per project (`auth_bridge_enabled`, default `false`), purely host-side, so toggling it never
recreates the container. The poller stops on its own when the container stops.
**Security posture:** the host side binds loopback only. Everything reachable through it is an
unauthenticated service inside the container, so widening those addresses would publish container
internals to the LAN. Nothing else on the network can reach a bridged port.
### Browser View
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
per project.
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and
has to enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`.
- **Pop out** puts the same URL in a second OS window (`popout.rs`), so the view can be watched on
another monitor or pinned on top while the main window is used for work. No capability lists that
window, so it has **no IPC surface**; the app CSP does not apply to it either, because it is a
top-level document rather than a frame — the token gate is what protects the port in both cases.
The window is owned by the *session*, so the supervisor's teardown closes it. The pane drops its
iframe while popped out, and both viewers can drive the browser.
- **Open page…** launches a browser in the container at a URL and viewport you choose and binds it,
so the pane shows it (`page.rs`). This is what serves container-side auth — the OAuth callback
listener is *in* the container, so a container-side browser closes the loop with no host round
trip and no auth bridge — and dev servers on container loopback. Re-opening with a helper already
up *navigates* rather than relaunching, so a session signed in on one page survives to the next.
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger window
is the same pixels drawn larger. `page.setViewportSize()` is what reflows, and match-window mode
pushes the pop-out's settled size into it, debounced by generation counter because a drag emits
continuously and each event costs a container exec.
- **Setup is two clicks, and nothing installs itself.** Detection has to look past `node_modules` —
`claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx/<hash>/node_modules` — and
hops from a wrapper `playwright` to its **nested**`playwright-core`, because npm does not hoist
for global installs and the wrapper ships no type definitions to read a version from. Installing
puts Playwright in `/workspace` with `--no-save` (not a bind mount, so it touches nothing of
yours) and browsers in `~/.cache/ms-playwright`, which is inside the home volume and so survives
recreation *and* migration.
- **`@playwright/mcp` can never satisfy this pane** on its own: it bundles a `playwright-core` that
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
once Playwright is present — not a setup route.
### Host File Transfers
Four routes move files across the boundary: **Upload…** and the per-row **Save to host…** in the
Files tab, a file dropped onto the Terminal tab, and **Back up container**. All four share one path
policy in `commands/file_commands.rs`.
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy
as the page. Be precise about the limit, though — host paths still travel *outward* in error text,
canonical ones included, so this closes the inbound direction and not both.
- **The dialog's pre-filled name is sanitized, because a container authored it.** On Windows the
save dialog parses its name box as a path, and a container can name a file
`..\..\Users\you\…\Word\STARTUP\x.dotm` — one POSIX segment, so nothing upstream objects.
`suggested_save_name` replaces every separator and every character NTFS refuses, so the string
cannot be a path on any platform this ships to.
- **One policy for every host path.** A source or destination whose path passes through a hidden
folder (`~/.ssh`, `~/.cache`, `~/.local/share`, anything dot-prefixed) or a system location is
refused, and the check is applied both to the path as written and to what it resolves to after
symlinks. It over-catches deliberately, so it will occasionally refuse somewhere a person
genuinely meant — `~/.config`, say — and the refusal is a sentence naming the folder that tripped
it, not an errno.
- **Uploads are capped at 256 MB per file**; past that the answer is a mount, not a copy. One
dialog's selection is handled file by file, so a folder or an oversized file among the selection
is reported by name and does not stop the others. Uploaded files land owned by the container user,
not root. A cancelled dialog is silent — `Ok(None)`, not an error.
- **`download_container_file` is one file and files only** — no button on a folder row. A directory
is what `download_container_backup` is for. There is no drop target on the Files pane; the
Terminal tab keeps the one it has.
## Inside a Project
### Container Introspection (Capability Tiles)
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
### Mission Control Integration
@@ -99,75 +506,122 @@ The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stor
### Speech-to-Text (Voice Mode)
Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the bottom-left corner of each terminal view.
Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the StatusBar whenever a terminal session is active.
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
- **Port**: Default `9876` (configurable)
- **Input device**: Selectable in Settings when the host exposes more than one microphone
- **Language**: Optional language hint for transcription
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
### Docker Socket Path
The socket path is OS-aware:
- **Linux/macOS**: `/var/run/docker.sock`
- **Windows**: `//./pipe/docker_engine`
Users can override this in Settings via the global `docker_socket_path` option.
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) plus **Upload…** and per-row **Save to host…**; imports no dialog plugin — the dialogs are Rust's |
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings → `tui`, `effortLevel`, `viewMode`, `autoScrollEnabled`, `showThinkingSummaries`, `awaySummaryEnabled`, plus the env-var flags (scrub, 1h caching). Every managed key is re-emitted on each start, `null` meaning "delete". |
### Frontend — settings, terminal and hooks
| File | Purpose |
|---|---|
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, certificates, gateway, web terminal, STT, shared auth and global settings |
| `app/src/components/settings/CertificateSettings.tsx` | Corporate CA certificate path (global), with `CaCertPathInput` |
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) and the host transfers (upload, save one file out); never handles a host path |
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir), the host transfers `upload_files_to_container` and `download_container_file` — each opening its own OS dialog here in Rust — plus `download_container_backup`, and the hidden-folder path policy all of them share |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, CA path, Claude Code settings, web terminal, STT, gateway) |
| `app/src-tauri/src/models/gateway_settings.rs` | Gateway provider, models, port and API base |
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that when running with`--dangerously-skip-permissions`, Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that even in its most permissive mode —`--dangerously-skip-permissions` — Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
---
@@ -57,6 +57,19 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
- **Web links addon** — `@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
#### Terminal Layout & StatusBar Controls
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`.
- **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.
- **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)
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
@@ -113,7 +126,8 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
@@ -150,22 +164,179 @@ 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, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, then runs `sleep infinity` to keep the container alive.
2.**Terminal open**: `docker exec` launches `claude --dangerously-skip-permissions` with a PTY in the running container.
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. All installed tools and configuration persist.
5.**Reset**: The container is removed and recreated from the image. This is a clean slate — the nuclear option when the container state is corrupted.
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.
5.**Reset**: `rebuild_project_container` closes live exec sessions, removes the container, removes the snapshot image, calls `remove_project_volumes` to delete **both** named volumes, then starts fresh from the clean base image.
The `.claude` configuration directory uses a **named Docker volume** (`triple-c-claude-config-{projectId}`) so OAuth tokens from `claude login` persist even across container resets.
Two named volumes exist per project and they are the only ones it owns:
| `triple-c-claude-config-{projectId}` | `/home/claude/.claude` | Claude Code config: OAuth credential, settings, skills/agents/commands, session transcripts, scheduler state. Nested inside the home volume; Docker gives the more specific mount precedence. |
`remove_project_volumes` names those two volumes explicitly (no prefix sweep) and is called from
exactly two places: `remove_project` and `rebuild_project_container`. Ordinary container removal
passes `v: false`, so stop/start and recreation never touch the volumes — **only Reset and project
removal delete them.** A Reset therefore destroys the `claude login` credential, installed skills,
session transcripts and scheduled tasks; it does not touch host bind mounts, the project record, or
host keychain secrets.
### Permission Modes
`PermissionMode` (`models/project.rs`) is a five-state enum replacing the earlier `full_permissions`
boolean. It reaches Claude Code by two different routes:
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
`None` from the legacy `full_permissions` flag, so records written before the change keep behaving
the same way.
**Interactive path.**`build_terminal_cmd()` evaluates `cli_args()` when a session is created, so
the flags are fixed for the life of that `claude` process. Changing the mode affects terminals
opened afterwards, not running ones. The same applies to `resume_session_command`, which builds
`claude <flags> --resume <id>` server-side.
**Scheduler path.** Cron jobs run with a minimal environment, so the mode travels as
`TRIPLE_C_PERMISSION_MODE` in the container's env; the entrypoint snapshots the allowlisted
variables into `~/.claude/scheduler/.env`, and `triple-c-task-runner` sources that file and maps the
value back to flags for its `claude -p` run. Container env can only change at create time, so
`container_needs_recreation()` compares a `triple-c.permission-mode` label and forces a recreation
on the next start. A mode change therefore reaches new terminals immediately but the scheduler only
after a stop/start. `TRIPLE_C_PERMISSION_MODE` is a reserved env key so it cannot be hand-set.
### Authentication Modes
Each project independently chooses one of two authentication methods:
Each project independently chooses one backend:
| Mode | How It Works | When to Use |
| Backend | How It Works | When to Use |
|------|-------------|-------------|
| **Anthropic (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via URL detection. Token persists in the `.claude` config volume. | Default — personal and team use |
| **AWS Bedrock** | Per-project AWS credentials (static keys, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
| **Anthropic** | Either the shared `CLAUDE_CODE_OAUTH_TOKEN` injected from the OS keychain, or a per-container `claude login` whose credential persists in the `.claude` config volume. The OAuth URL opens in the host browser via URL detection. | Default — personal and team use |
| **AWS Bedrock** | Per-project AWS credentials (static keys, named profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only; SSO sessions are validated before launching Claude for profile auth. | Enterprise environments using Bedrock |
| **Ollama** | `ANTHROPIC_BASE_URL` points at an Ollama server; `ANTHROPIC_AUTH_TOKEN` is set to the placeholder `ollama`. Ollama implements `POST /v1/messages` natively. | Local models (best-effort) |
| **llama.cpp** | `ANTHROPIC_BASE_URL` points at a `llama-server` (default port 8080); `ANTHROPIC_AUTH_TOKEN` is set to the placeholder `llama.cpp`, which `llama-server` ignores unless started with `--api-key`. `llama-server` implements `POST /v1/messages` and `/v1/messages/count_tokens` natively. | Local models (best-effort) |
| **OpenAI Compatible** | `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` point at a gateway. **Despite the name, the endpoint must implement the Anthropic Messages API** — Claude Code only ever sends `POST /v1/messages?beta=true`, never `/v1/chat/completions`. LiteLLM works; a bare OpenAI-only server does not. | Anthropic-shaped gateways (best-effort) |
#### Model aliases on custom endpoints
`Backend::uses_custom_endpoint()` (Ollama, llama.cpp, OpenAI Compatible) gates the emission of
`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL`, computed by
`docker::container::compute_model_aliases`. All four default to the backend's resolved model id;
each backend carries an optional `haiku_model_id` override, because the Haiku alias is what Claude
Code uses for background work. Anthropic and Bedrock emit none of them and keep Claude Code's
defaults; the four names are in `MANAGED_AUTH_KEYS`, so switching away from a custom endpoint
blanks the values baked into the snapshot image. The resolved alias set is folded into each
backend's `triple-c.*-fingerprint` label, since `container_needs_recreation` is label-based and
never diffs env. `ANTHROPIC_SMALL_FAST_MODEL` is deprecated and unused.
### Shared Claude Authentication Token
`commands/auth_token_commands.rs` runs `claude setup-token` on a PTY inside a running container.
Contrary to the loopback pattern most CLI logins use, `setup-token` redirects to an Anthropic-hosted
page and then blocks on a stdin paste prompt, so the flow needs a way to feed the pasted code back
in — hence `submit_claude_token_code`. The flow is single-flight (the token is global, so two
concurrent logins would race to overwrite each other's keychain entry) and times out after 15
minutes.
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
frontend, never written to a log, and no command accepts or returns it.
- **The sign-in URL comes from the OSC 8 parameter, not the screen.** The CLI emits the URL as a
hyperlink and slices the *visible* text of it to the terminal width — measured against 2.1.226, a
346-character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the
whole URL in its parameter and 80 characters of it on screen. Scraping the visible text yields a
URL that parses, points at `claude.com`, and cannot authorise anything, so the ANSI stripper
surfaces the hyperlink target and `claude-token-link` carries it to the UI. The frontend applies
the `ANTHROPIC_SIGN_IN_HOSTS` allowlist to it before display and again before `openUrl` — an OSC 8
parameter is container output that is never rendered, which makes it the *easier* place to hide a
hostile host, not a trusted one. `stty cols 400` (up from 200, which the URL still overflowed)
removes wrapping as a variable elsewhere, but it is not the fix: that line fails silently.
- **A rejected code is recoverable, not a hang.** On a bad paste the CLI prints
`OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin rather than exiting.
The streamed output is scanned for that, `claude-token-code-rejected` reopens the input with an
explanation, and the Enter is sent so the next code has a prompt to land in — bounded by
`MAX_CODE_ATTEMPTS`, after which the flow reports a failure. Without this the exec sat until the
15-minute timeout with the UI still saying "Finishing sign-in".
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
could still grow into a secret across a chunk boundary. A credential split across a hard line
wrap is reassembled by both the parser and the redactor from the same `scan_credential_body`, so
the two cannot disagree about where a credential ends — previously a wrapped token was rejected
as too short *and* its second line, which carries no `sk-ant-` marker, was printed to the UI in
clear. A run is only joined across a break that sits at a plausible terminal margin and is not
already long enough to be a whole credential; otherwise a repainting TUI would weld one frame's
token onto the next frame's first word.
- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
value baked into a snapshot image by `docker commit` is actively cleared.
- **Rotation** — a random UUID minted on each store is mirrored into the
`triple-c.claude-token-version` label. It is deliberately *not* a hash of the token: labels are
readable by anything that can run `docker inspect`, and a hash would be an offline verification
oracle. A label mismatch forces container recreation on the next start, which is when a container
picks up or loses the token.
### Auth Bridge
CLIs that log in through a browser (`claude login`, `aws sso login`, `fly login`) start an ephemeral
HTTP listener on an unpredictable loopback port and hand the provider a `http://localhost:<port>/…`
redirect. Run inside a container, that listener is unreachable from the host browser and nothing can
be pre-published at container-creation time. `auth_bridge/` bridges it at runtime:
- **Discovery** (`proc_net.rs`) — a `docker exec` reads `/proc/net/tcp` and `/proc/net/tcp6` every
two seconds. The image ships no `ss`, `netstat` or `lsof`. Only rows in state `0A` (`TCP_LISTEN`)
bound to loopback are kept; wildcard binds are ignored on purpose, since publishing those is the
port-mappings feature's job.
- **Family handling** — a `::1`-only listener genuinely cannot be reached over `127.0.0.1`, and Node
resolves `localhost` to IPv6 first on Linux, so `claude login` frequently binds `::1` alone. The
socat target follows the family actually observed; IPv4-mapped rows in `/proc/net/tcp6` are
treated as IPv4.
- **Host bind** (`tunnel.rs`) — the same port number is bound on the host: `127.0.0.1` is required,
`[::1]` is best-effort. **The host side binds loopback only, never a wildcard address** —
everything behind it is an unauthenticated in-container service that bound loopback precisely
because it expected to be unreachable.
- **Transport** — each accepted connection is proxied by an attached exec running
`socat - TCP:127.0.0.1:<port>`, because container IPs are not routable from the host under Docker
Desktop. It goes through the same `create_attached_exec()` helper as terminal sessions, with
`tty: false` so socat's stderr is demultiplexed away from the proxied byte stream.
- **Policy** — ports appearing in the project's port mappings are skipped, and a host bind failure
is recorded as a conflict and retried later rather than fought over.
- **Lifecycle** — opt-in per project (`auth_bridge_enabled`, default `false`). It is purely
host-side, so it deliberately has no container-recreation label. The poller stops itself when the
project is gone, the flag is cleared, or the container is no longer running, and `stop()` awaits
it so host ports are provably released.
### Container Introspection
`list_container_capabilities` (`commands/inspect_commands.rs`) executes a read-only shell script in
a running container and returns counts and item lists for skills, agents, commands, hooks, plugins
and MCP servers, across user scope (`/home/claude/.claude`) and project scope
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Everything is computed in-container with
`find`/`awk`/`jq`; only the JSON summary crosses the wire, and a stopped container yields zeros
rather than an error.
The script writes nothing. Claude Code owns this configuration and has its own tooling for it
(`/agents`, `/hooks`, `/plugins`, `/mcp`); Triple-C surfaces counts and opens a terminal rather than
rebuilding those editors as forms. `list_claude_sessions` and the scheduler commands
`run_scheduled_task_now`, `remove_scheduled_task`, `clear_scheduler_notifications`) live in the same
module; the mutating ones shell out to `triple-c-scheduler` rather than editing its state files.
### Main-Area Tab Model
The frontend keeps a single ordered `tabOrder` array in the Zustand store holding two tab kinds,
`home:<projectId>` and `term:<sessionId>`, rendered by `components/layout/MainTabs.tsx`.
`activeSessionId` is *derived* from `activeTabKey`, so exactly one thing is current and a Project
Home tab and a terminal cannot both claim focus. Project configuration is a main-area view
(`components/projects/home/`), not a modal; the sidebar row is select-only.
### UID/GID Remapping
@@ -177,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
@@ -195,10 +366,12 @@ This avoids the common Docker problem where bind-mount permissions can't be chan
| Claude config/tokens | Named Docker volume | `triple-c-claude-config-{projectId}` |
| Container filesystem | Docker container layer | Preserved across stop/start, cleared on reset |
| Claude config, sessions, scheduler state | Named Docker volume | `triple-c-claude-config-{projectId}` |
| Container home directory | Named Docker volume | `triple-c-home-{projectId}` |
| Container filesystem | Docker container layer, preserved into `triple-c-snapshot-{projectId}:latest` on recreation | Survives stop/start and recreation; destroyed by Reset |
The projects store uses **atomic writes** (write to `.json.tmp`, then `rename()`) to prevent data corruption if the app crashes mid-write. Corrupted files are backed up to `.json.bak` before being replaced.
@@ -220,98 +393,159 @@ The `TerminalView` component works around this with a **URL accumulator**:
triple-c/
├── README.md # Architecture overview
├── TECHNICAL.md # This document
├── HOW-TO-USE.md # User guide
├── HOW-TO-USE.md # User guide (also served by the in-app Help dialog)
├── BUILDING.md # Build instructions
├── CLAUDE.md # Claude Code instructions
├── DESIGN-REVIEW.md # UI/UX review notes
├── ROADMAP.md # Planned work
│
├── container/
├── container/ # Sandbox image
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
"description":"Default capabilities for Triple-C",
"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…\".Noneofthefourtouchesthispermission.TheFilestab'stwoareworthseparatingouthere,becausetheyaretheonlyhost-pathcommandsintheappwhosedialogisopenedby**Rust**ratherthanbythewebview—`pick_save_path`and`pick_files_to_upload`in`commands/file_commands.rs`drive`tauri-plugin-dialog`fromthebackend,soacompromisedwebviewcanaskforapickerandnothingmore:itcannotnameahostpathasan*input*toeithercommand.Bepreciseaboutthelimitofthatclaim—hostpathsdostilltraveloutwardinerrortext(`Failedtocreate/home/j/Documents/x.txt.triple-c-part-1a2b3c4d:Permissiondenied`),includingcanonicalizedones,whichdisclosesymlinktargets.Thatisaccepted;theappalreadyhandsthewebviewtheprojectpaths.Whatisclosedisthedirectionthatmattered—thewebviewnamingwherebytesgo.ThatistheshapeanearlierrevisionofthisfilenamedasthehonestoneiftheFilestabeverregainedhostI/O,anditistheshapeitregaineditin.The`dialog:allow-open`/`dialog:allow-save`grantsbelowaretherefore*not*whatthosetwouse;theyremainforthefrontendpickersinAddProject,theConfigtab'sworkspaceandaccesssections,theCA-certificatefieldandBackup.TwocommandsstillLine truncated
"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.",
{"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"]},"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-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-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Deniestheremove_data_storecommandLine truncated
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-aws-keys-connection":{"identifier":"allow-add-aws-keys-connection","description":"Enables the add_aws_keys_connection command without any pre-configured scope.","commands":{"allow":["add_aws_keys_connection"],"deny":[]}},"allow-add-aws-profile-connection":{"identifier":"allow-add-aws-profile-connection","description":"Enables the add_aws_profile_connection command without any pre-configured scope.","commands":{"allow":["add_aws_profile_connection"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-connection-identity":{"identifier":"allow-aws-connection-identity","description":"Enables the aws_connection_identity command without any pre-configured scope.","commands":{"allow":["aws_connection_identity"],"deny":[]}},"allow-aws-connection-sso-login":{"identifier":"allow-aws-connection-sso-login","description":"Enables the aws_connection_sso_login command without any pre-configured scope.","commands":{"allow":["aws_connection_sso_login"],"deny":[]}},"allow-aws-policy-document":{"identifier":"allow-aws-policy-document","description":"Enables the aws_policy_document command without any pre-configured scope.","commands":{"allow":["aws_policy_document"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-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":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-Line truncated
{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url"]}}
{"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…\".Noneofthefourtouchesthispermission.TheFilestab'stwoareworthseparatingouthere,becausetheyaretheonlyhost-pathcommandsintheappwhosedialogisopenedby**Rust**ratherthanbythewebview—`pick_save_path`and`pick_files_to_upload`in`commands/file_commands.rs`drive`tauri-plugin-dialog`fromthebackend,soacompromisedwebviewcanaskforapickerandnothingmore:itcannotnameahostpathasan*input*toeithercommand.Bepreciseaboutthelimitofthatclaim—hostpathsdostilltraveloutwardinerrortext(`Failedtocreate/home/j/Documents/x.txt.triple-c-part-1a2b3c4d:Permissiondenied`),includingcanonicalizedones,whichdisclosesymlinktargets.Thatisaccepted;theappalreadyhandsthewebviewtheprojectpaths.Whatisclosedisthedirectionthatmattered—thewebviewnamingwherebytesgo.ThatistheshapeanearlierrevisionofthisfilenamedasthehonestoneiftheFilestabeverregainedhostI/O,anditistheshapeitregaineditin.The`dialog:allow-open`/`dialog:allow-save`grantsbelowaretherefore*not*whatthosetwouse;theyremainforthefrontendpickersinAddProject,theConfigtab'sworkspaceandaccesssections,theCA-certificatefielLine truncated
// 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 —
// otherwise a wedged daemon would wedge the supervisor, and `stop()` waits
// on the supervisor.
letscript=format!(
r#"const q=require("http").get({{host:"127.0.0.1",port:{},path:"/",headers:{{host:"127.0.0.1:{}"}}}},r=>{{process.stdout.write("\n{}"+r.statusCode+" "+(r.headers.location||"/")+"\n");r.resume();process.exit(0);}});q.on("error",e=>{{process.stderr.write(String(e.message));process.exit(1);}});q.setTimeout({},()=>{{process.stderr.write("timed out waiting for the viewer");q.destroy();process.exit(1);}});"#,
Loaded 100 of 531 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.