Commit Graph
44 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 5 3a49a67c1f Fix terminal input reordering and Linux terminal rendering
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m25s
Build App (Preview) / build-windows (pull_request) Successful in 5m32s
Build App (Preview) / prune-previews (pull_request) Successful in 8s
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
2026-08-28 12:51:18 -07:00
shadow-test 97e58db3c1 Close gateway-secret desync, TOCTOU, and undisclosed custom-image gaps
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m53s
Build App (Preview) / build-linux (pull_request) Successful in 7m5s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
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.
2026-08-27 14:24:06 -07:00
shadow-test a606e3ab20 Validate settings imports before writing secrets; disclose base URLs
Secret Scan / scan (push) Successful in 14s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m43s
Build App (Preview) / build-windows (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 7m29s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
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.
2026-08-27 13:13:48 -07:00
shadow-testandClaude Sonnet 5 925e51e435 Fix a real credential-leak vector a review found, plus four smaller issues
Secret Scan / scan (push) Successful in 12s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m51s
Build App (Preview) / build-linux (pull_request) Successful in 5m12s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
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
2026-08-27 12:16:43 -07:00
shadow-testandClaude Sonnet 5 722d9aeff1 Add password-encrypted settings export/import
Secret Scan / scan (push) Successful in 8s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 9s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 6m29s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
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
2026-08-27 11:57:16 -07:00
shadow-testandClaude Opus 5 39934299f9 Stop the snapshot retry being an unconfirmed revoke, and unbrick legacy configs
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
2026-08-23 15:42:20 -07:00
shadow-testandClaude Opus 5 ed91423666 Hold back the Disk panel and OS drag-out from the ship branch
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
2026-08-23 15:20:22 -07:00
shadow-testandClaude Opus 5 6a8972980d Stop an untouched secret field from deleting the stored credential
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
2026-08-23 13:23:00 -07:00
shadow-test 7bbb699e4e Merge branch 'r2/front' into integration/round-1 2026-08-23 13:15:02 -07:00
shadow-testandClaude Opus 5 e70a40507c Stop granting an unscoped host-file read, and make a refused credential scrub recoverable
`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
2026-08-23 13:07:07 -07:00
shadow-testandClaude Opus 5 5926a52ff6 Stop the terminal and Files panes refusing drops onto their own chrome
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
2026-08-23 13:03:57 -07:00
shadow-testandClaude Opus 5 4f6c012071 Make a rollback pin outliving its project visible and deletable
`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
2026-08-23 12:18:04 -07:00
shadow-testandClaude Opus 5 7e1f8df1ff Reconcile the frontend with the round-1 backend contracts
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
2026-08-23 12:02:34 -07:00
shadow-testandClaude Opus 5 d6f065a2b6 Fix HIGH and MEDIUM frontend defects
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
2026-08-23 11:11:43 -07:00
shadow-testandClaude Opus 5 611f67cca7 Fix what review found in the Disk section
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
2026-08-23 09:51:09 -07:00
shadow-testandClaude Opus 5 77ef2291d7 Add a Disk section: see where the bytes went, and get them back
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
2026-08-23 09:39:54 -07:00
shadow-test 77567ac2ae Merge remote-tracking branch 'origin/main' into feature/corporate-ca
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m20s
Build Container / build-container (pull_request) Successful in 9m58s
Build App / build-linux (pull_request) Successful in 5m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
# Conflicts:
#	app/src/lib/tauri-commands.ts
2026-08-10 11:20:23 -07:00
Claude 7a5823cb2b Inject the corporate CA certificate into containers
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
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
2026-08-10 10:40:21 -07:00
shadow-testandClaude Opus 5 c3f92674b1 Fix the shared Claude auth flow: whole sign-in URL, recoverable rejected code
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / build-linux (pull_request) Successful in 5m50s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
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
2026-08-10 09:57:16 -07:00
shadow-testandClaude Opus 5 2de00b3c55 Fix review findings: secrets in snapshots, URL spoofing, migration data loss
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>
2026-08-09 19:35:39 -07:00
shadow-testandClaude Opus 5 cc5f691677 Add llama.cpp backend, model gateway, URL relay and browser view
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>
2026-08-09 16:55:28 -07:00
shadow-testandClaude Opus 5 d95ba54a69 Add shared-auth-token UI and make cancelling actually cancel
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>
2026-08-09 11:49:03 -07:00
shadow-testandClaude Opus 5 01a2f6aec8 Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
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>
2026-08-09 11:35:42 -07:00
shadow-testandClaude Opus 4.7 5b1c801cf1 Add global backend defaults with runtime fallback
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>
2026-05-24 08:49:06 -07:00
shadow-testandClaude Opus 4.7 9b78b4bc62 Group Settings panel into accordion sections
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>
2026-05-24 08:42:18 -07:00
shadow-testandClaude Opus 4.6 d6ac3ae6c6 Add Claude Code settings infrastructure, TUI mode, session naming, and global defaults
Adds first-class support for Claude Code CLI features (2.1.71-2.1.110):

- New ClaudeCodeSettings struct with per-project and global defaults for
  TUI mode, effort level, focus mode, thinking summaries, session recap,
  auto-scroll, env scrub, and 1-hour prompt caching
- Settings injected as env vars (CLAUDE_CODE_NO_FLICKER, etc.) and
  ~/.claude/settings.json entries via entrypoint.sh merge block
- New ClaudeCodeSettingsModal component for configuring settings
- Session naming support (-n flag passed to claude CLI, shown in tabs)
- Relaxed reserved prefix filter: CLAUDE_CODE_* env vars now allowed in
  custom env vars UI for power users
- Global SSH key path, git name, and git email now used as fallbacks
  when per-project values are not set, with UI in SettingsPanel
- Fingerprint-based change detection triggers container recreation when
  Claude Code settings change
- Updated README, HOW-TO-USE, and CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:46:03 -07:00
shadow-testandClaude Opus 4.6 532de77927 Add speech-to-text feature using Faster Whisper container
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m28s
Build STT Container / build-stt-container (pull_request) Successful in 3m18s
Build App / build-windows (pull_request) Successful in 4m40s
Build App / build-linux (pull_request) Failing after 1m46s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Adds a mic button to the terminal UI that captures speech, transcribes
it via a Faster Whisper sidecar container, and injects the text into
the terminal input. Includes settings panel for model selection
(tiny/small/medium), port config, and container lifecycle management.

- stt-container/: Dockerfile + FastAPI server for Whisper transcription
- Rust backend: STT container management, transcribe_audio IPC command
- Frontend: useSTT hook, SttButton, SttSettings, WAV encoder
- CI: Gitea Actions workflow for multi-arch STT image builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:02:39 -07:00
shadow-testandClaude Opus 4.6 922543cc04 Add web terminal for remote tablet/phone access to project terminals
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m56s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Adds an axum HTTP+WebSocket server that runs alongside the Tauri app,
serving a standalone xterm.js-based terminal UI accessible from any
browser on the local network. Shares the existing ExecSessionManager
via Arc-wrapped stores, with token-based authentication and automatic
session cleanup on disconnect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:31:16 -07:00
shadow-testandClaude Opus 4.6 b17c759bd6 Fix remaining repo.anhonesthost.net references in user-facing code
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 4m0s
Build App / build-linux (push) Successful in 4m35s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 14s
- help_commands.rs: fetch HOW-TO-USE.md from GitHub raw instead of Gitea
- DockerSettings.tsx: display GHCR image address in settings UI
- HOW-TO-USE.md: update registry description to ghcr.io

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:31:46 -07:00
shadow-testandClaude Opus 4.6 cc163e6650 Add help dialog and tooltip indicators throughout the UI
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m29s
Build App / build-linux (push) Successful in 5m43s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 13s
- Add circled ? help button in TopBar that opens a dialog with HOW-TO-USE.md content
- Create reusable Tooltip component with viewport-aware positioning
- Add 32 tooltip indicators across project config and settings panels
- Covers backend selection, Bedrock/Ollama/LiteLLM fields, Docker, AWS, MCP, and more

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:35:04 -07:00
shadow-testandClaude Opus 4.6 38082059a5 Rename AuthMode to Backend, fix LiteLLM variant typo, add image update alerts, clean up Settings
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m14s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
- Fix serde deserialization error: TypeScript sent "lit_llm" but Rust expected "lite_llm"
- Rename AuthMode enum to Backend across Rust and TypeScript (with serde alias for backward compat)
- Add container image update checking via registry digest comparison
- Improve Settings page: fix image address display spacing, remove per-project auth section
- Update UI labels from "Auth" to "Backend" throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:26:58 -07:00
shadow-testandClaude Opus 4.6 c023d80c86 Hide mic toggle UI until upstream /voice WSL detection is fixed
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m57s
Build App / build-linux (push) Successful in 5m17s
Build App / sync-to-github (push) Successful in 11s
Claude Code's /voice command incorrectly detects containers running
on WSL2 hosts as unsupported WSL environments. Remove the mic button
from project cards and microphone settings from the settings panel,
but keep useVoice hook and MicrophoneSettings component for re-enabling
once the upstream issue is resolved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:12:35 -08:00
shadow-testandClaude Opus 4.6 c5e28f9caa feat: add microphone selection to settings
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m42s
Build App / sync-to-github (push) Successful in 18s
Adds a dropdown in Settings to choose which audio input device to
use for voice mode. Enumerates devices via the browser's
mediaDevices API and persists the selection in AppSettings.
The useVoice hook passes the selected deviceId to getUserMedia().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:15:47 -08:00
shadow-testandClaude Opus 4.6 2e81b52205 Add container-native scheduled task system with timezone support
Build App / build-linux (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m43s
Build Container / build-container (push) Successful in 16s
Introduces a cron-based scheduler that lets Claude set up recurring and
one-time tasks inside containers. Tasks run as separate Claude Code agents
and persist across container recreation via the named volume.

New files:
- container/triple-c-scheduler: CLI for add/remove/enable/disable/list/logs/run/notifications
- container/triple-c-task-runner: cron wrapper with flock, logging, notifications, auto-cleanup

Key changes:
- Dockerfile: add cron package and COPY both scripts
- entrypoint.sh: timezone setup, cron daemon, crontab restore, env saving
- container.rs: init=true for zombie reaping, TZ env, scheduler instructions, timezone recreation check
- image.rs: embed scheduler scripts in build context
- app_settings.rs + types.ts: timezone field
- settings_commands.rs: detect_host_timezone via iana-time-zone crate
- SettingsPanel.tsx: timezone input with auto-detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:57:22 +00:00
shadow-testandClaude Opus 4.6 da078af73f Remove Anthropic API key authentication support
Build App / build-windows (push) Successful in 2m28s
Build App / build-linux (push) Successful in 3m13s
API key auth only provides short-lived session tokens (8hrs or until
session restart) with no refresh mechanism, unlike OAuth which persists
via .credentials.json. Remove the non-functional API key settings UI
and all supporting code (frontend state, Tauri commands, keyring
storage, container env var injection, and fingerprint-based recreation
checks) to avoid user confusion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:59:58 +00:00
shadow-testandClaude Opus 4.6 c2736ace90 Fix API key changes not triggering container recreation
Build App / build-linux (push) Successful in 2m45s
Build App / build-windows (push) Successful in 4m15s
The container was only recreated when the auth mode changed, not when
the API key value itself changed. This meant saving a new key required
a manual container rebuild. Now we store a hash of the API key as a
Docker label and compare it on start, so a key change automatically
recreates the container (preserving the claude config volume).

Also adds a note to the global AWS settings UI that changes require a
container rebuild.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:22:34 +00:00
shadow-testandClaude Opus 4.6 5a59fdb64b Add UX enhancements: modals for env vars and instructions, global env vars, taskbar icon fix
Build App / build-linux (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m5s
- Fix Windows taskbar icon by loading icon.ico instead of icon.png (ICO contains
  multiple sizes native to Windows taskbar/title bar/alt-tab)
- Add "Container must be stopped to change settings" warning banner in config panel
- Move per-project Environment Variables and Claude Instructions into modal dialogs
  for more editing space, with buttons in the config panel to open them
- Move global Claude Instructions into a modal in Settings panel
- Add default global Claude instruction recommending git initialization
- Add global environment variables support (full stack: Rust model, TS types,
  container creation with merge logic where project overrides global for same key,
  fingerprinting for recreation checks, and Settings UI with modal)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:21:33 +00:00
shadow-testandClaude Opus 4.6 7e1cc92aa4 Add app update detection and multi-folder project support
Build App / build-linux (push) Successful in 2m54s
Build App / build-windows (push) Successful in 4m18s
Build Container / build-container (push) Successful in 1m30s
Feature 1 - Update Detection: Query Gitea releases API on startup (3s
delay) and every 24h, compare patch versions by platform, show pulsing
"Update" button in TopBar with dialog for release notes/downloads.
Settings: auto-check toggle, manual check, dismiss per-version.

Feature 2 - Multi-Folder Projects: Replace single `path` with
`paths: Vec<ProjectPath>` (host_path + mount_name). Each folder mounts
to `/workspace/{mount_name}`. Auto-migrate old single-path JSON on load.
Container recreation via paths-fingerprint label. AddProjectDialog and
ProjectCard support add/remove/edit of multiple folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:18:33 +00:00
shadow-testandClaude Opus 4.6 82f159d2a9 Fix frontend UX: debounce saves, Zustand selectors, init race, dialog
- Debounce project config saves: use local state + save-on-blur instead
  of firing IPC requests on every keystroke in text inputs
- Add Zustand selectors to all store consumers to prevent full-store
  re-renders on any state change
- Fix initialization race: chain checkImage after checkDocker resolves
- Fix DockerSettings setTimeout race: await checkImage after save
- Add console.error logging to all 11 empty catch blocks in ProjectCard
- Add keyboard support to AddProjectDialog: Escape to close,
  click-outside-to-close, form submit on Enter, auto-focus

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:42:40 +00:00
shadow-testandClaude Opus 4.6 82c487184a Add custom env vars and Claude instructions for projects
Build App / build-windows (push) Successful in 3m24s
Build App / build-linux (push) Successful in 5m36s
Build Container / build-container (push) Successful in 56s
Support per-project environment variables injected into containers,
plus global and per-project Claude Code instructions written to
~/.claude/CLAUDE.md inside the container on start. Reserved env var
prefixes are blocked, and changes trigger automatic container recreation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:39:20 -08:00
shadow-testandClaude Opus 4.6 60842befde Fix UI padding and text flush against container edges
Build App / build-windows (push) Successful in 3m16s
Build App / build-linux (push) Successful in 4m16s
- Remove global * { padding: 0 } reset that was overriding all Tailwind
  padding classes (unlayered CSS beats Tailwind v4 @layer utilities)
- Add color-scheme: dark to fix native form controls (select dropdowns)
  rendering with white backgrounds
- Make sidebar responsive (25% width, min 224px, max 320px)
- Increase internal padding on TopBar, Sidebar, ProjectList, StatusBar
- Add flex-shrink-0 to TopBar status indicators to prevent clipping
- Allow project action buttons to wrap on narrow sidebars
- Increase terminal view padding for breathing room

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:31:27 -08:00
shadow-testandClaude Opus 4.6 0f188783e1 Add container registry pull, image source settings, and global AWS config
Build Container / build-container (push) Successful in 1m59s
Support pulling images from registry (default: repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest),
local builds, or custom images via a new settings UI. Add global AWS configuration
(config path auto-detect, profile picker, region) that serves as defaults overridable
per-project for Bedrock auth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:22:49 +00:00
shadow-testandClaude Opus 4.6 ca51e73924 Add AWS Bedrock auth mode with per-project configuration
Build Container / build-container (push) Successful in 3m29s
Introduces a third auth mode alongside Login and API Key, allowing
projects to authenticate Claude Code via AWS Bedrock. Includes support
for static credentials, profile-based, and bearer-token auth methods
with full UI controls. Also adds a URL accumulator to the terminal to
reassemble long OAuth URLs split across hard newlines, and installs
the AWS CLI v2 in the container image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:29:40 +00:00
shadow-testandClaude Opus 4.6 97a0745ead Initial commit: Triple-C app, container, and CI
Tauri v2 desktop app (React/TypeScript + Rust) for managing
containerized Claude Code environments. Includes Gitea Actions
workflow for building and pushing the sandbox container image,
and a BUILDING.md guide for manual app builds on Linux and Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 04:29:51 +00:00