93 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 5 436b6dd470 Send a lone CR through the newline transform, and say what pinned is
`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
2026-09-01 13:47:01 -07:00
shadowdao 5a8e24ccbe Extract the Claude newline sequence and the session display name 2026-09-01 12:57:29 -07:00
shadowdao cd3160b1cd Add the notes hook and its IPC wrappers 2026-09-01 12:38:33 -07:00
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 Sonnet 5 61bdbc4a5b Close the crash-window gap and exec-session leak a third review found
Secret Scan / scan (push) Successful in 16s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / build-linux (pull_request) Successful in 6m17s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
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
2026-08-27 09:47:45 -07:00
shadow-testandClaude Sonnet 5 439ef16f07 Fix two new bugs a second review found: stale container id, orphaned record
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
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
2026-08-27 09:09:46 -07:00
shadow-testandClaude Sonnet 5 d8bb5ab262 Address review findings: durability, stale container ids, honest toasts
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
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 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 6m18s
Build App (Preview) / build-linux (pull_request) Successful in 7m48s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
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
2026-08-27 08:36:27 -07:00
shadow-testandClaude Sonnet 5 4827170715 Report and retry Docker resources remove_project could not delete
Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-linux (pull_request) Successful in 6m5s
Build App (Preview) / build-macos (pull_request) Successful in 2m45s
Build App (Preview) / build-windows (pull_request) Successful in 5m42s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
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
2026-08-27 08:18:41 -07:00
shadow-testandClaude Opus 5 2c9482a67d Give the Files tab back its uploads and downloads
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 1m35s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m50s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
`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
2026-08-25 10:00:21 -07:00
shadow-testandClaude Opus 5 4d1a5a2417 Remove list_sibling_containers, which nothing called
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
2026-08-23 17:15:38 -07:00
shadow-test e9902f0564 Merge branch 'r4/narrow' into ship/core 2026-08-23 17:08:04 -07:00
shadow-testandClaude Opus 5 06ccb4d818 Ship the Files tab container-side only
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
2026-08-23 17:05:56 -07:00
shadow-testandClaude Opus 5 9472cb3c4c Resolve host paths before mounting them, and stop reading stored data as choice
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
2026-08-23 17:02:03 -07:00
shadow-test 47960e46df Merge branch 'r3/drop' into ship/core 2026-08-23 15:48:19 -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
shadowdaoandClaude Opus 5 f7db4323be Make the drop gate a state question, not a geometry one
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
2026-08-23 15:31:13 -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-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 1768240861 Unstick a project row whose lifecycle command was refused
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
2026-08-23 12:12:50 -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-test 0003793abb Merge branch 'feat/disk-ui' into integration/round-1 2026-08-23 09:51:44 -07:00
shadow-test 2b9bf56f25 Merge branch 'feat/drag-out' into integration/round-1 2026-08-23 09:51:44 -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-testandClaude Opus 5 1c834a0b08 Drag a file out of the Files tab onto the host desktop
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
2026-08-23 09:12:59 -07:00
shadow-testandClaude Opus 5 0a022dfcf0 Let a project turn a globally-enabled Claude Code setting back off
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
2026-08-23 09:05:39 -07:00
shadow-test df6d2f1ca4 Merge branch 'feat/file-manager' into integration/round-1 2026-08-23 08:35:51 -07:00
shadow-test dacc1157ec Merge branch 'feat/disk-and-settings' into integration/round-1 2026-08-23 08:35:51 -07:00
shadow-testandClaude Opus 5 dd2894cc60 Stop Docker disk growth, and fix the Claude Code settings that never worked
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
2026-08-23 08:35:10 -07:00
shadow-testandClaude Opus 5 22d142c70d Shift+Enter newline, OAuth URL truncation, and the auth bridge toggle
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
2026-08-23 08:31:39 -07:00
shadow-testandClaude Opus 5 15e05e2197 Turn the Files tab into a real file manager
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
2026-08-23 08:30:48 -07:00
shadow-testandClaude Opus 5 65a3d4eb29 Let a project's container run a VPN client
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-linux (pull_request) Successful in 7m10s
Build App (Preview) / build-windows (pull_request) Successful in 6m33s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
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>
2026-08-14 08:06:39 -07:00
shadow-testandClaude Opus 5 fa4940dd7d Say when a scheduled task is running
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build Container / build-container (pull_request) Successful in 2m53s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m2s
Build App (Preview) / build-linux (pull_request) Successful in 6m53s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
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>
2026-08-12 06:33:49 -07:00
shadow-testandClaude Opus 5 f239fa1c82 Fix three things found by actually using it
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m16s
Build App / build-linux (pull_request) Successful in 5m25s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
**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>
2026-08-11 10:36:30 -07:00
shadow-testandClaude Opus 5 f68d9c5788 Open a page in the container's browser, at a viewport you choose
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m14s
Build App / build-windows (pull_request) Successful in 5m56s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
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>
2026-08-11 09:15:12 -07:00
shadow-testandClaude Opus 5 bd72781482 Install one Playwright tree, and notice when a container has two
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>
2026-08-11 09:04:06 -07:00
shadow-testandClaude Opus 5 a41d93ea46 Fix the review's findings: drag on pointer events, read the window back
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-linux (pull_request) Successful in 5m11s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
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>
2026-08-11 07:40:51 -07:00
shadow-testandClaude Opus 5 d73096c937 Reorder tabs by dragging, and pop the browser view into its own window
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
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>
2026-08-11 06:50:40 -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
jknapp 247f03b48c Merge pull request #17: Browser view — find every Playwright, set one up in two clicks, bake the runtime libraries
Build App / compute-version (push) Successful in 6s
Build Container / build-container (push) Successful in 1m18s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 5m25s
Build App / build-linux (push) Successful in 5m19s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
2026-08-10 18:19:25 +00: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 a5bcc462a7 Browser view: find every Playwright, and set one up in two clicks
Build App / compute-version (pull_request) Successful in 14s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m18s
Build App / build-linux (pull_request) Successful in 6m42s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
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
2026-08-10 10:15:02 -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