Commit Graph
19 Commits
Author SHA1 Message Date
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 168b61d632 Close two cross-file handoffs from the round-3 fixes
The terminal's drop target derived the tar entry name from the host path
*after* symlink resolution, so dropping ~/Downloads/latest.log — where
latest.log is a symlink — landed the file in the container under the
target's name. Nothing errored; the user got a name they never typed. The
Files pane had the identical bug and was fixed with `host_upload_name`;
the terminal now calls the same helper, so the two drop targets cannot
drift. It also drops the "dropped-file" fallback, which silently renamed
anything the old `file_name()` could not parse.

`migration_store::load` told the user a backup existed when it had
deliberately not written one. `load` runs on every reconcile, survey and
reaper pass, so a persistently corrupt record reaches MAX_CORRUPT_BACKUPS
within seconds; from then on the copy was skipped while the log still
read "(a copy was kept at <path>)". Three outcomes are now distinct, and
the "enough already" case says so rather than naming a file that is not
there — that being the message someone reads immediately before going to
look for their data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 15:52:49 -07:00
shadow-testandClaude Opus 5 c6086b0ab3 Stop the file panel refusing ordinary paths, and hanging on a FIFO
H6 (HIGH) — `resolve_host_path` canonicalised the host path and then re-ran
the *lexical* policy over the answer, hidden-component rule included. Because
canonicalisation resolves through symlinks, that rule started judging where a
path happens to live rather than where the user pointed: uploading out of a
dependency under pnpm (`node_modules/pkg` → `node_modules/.pnpm/…`) was
refused, and so was every download into, or upload out of, a visible directory
that leads to `~/.local/share`, `~/.cache`, `~/.var/app`, `~/.nvm` or
`~/.cargo`. None of it was refused before the H4 fix landed.

The two questions are now separate functions. `validate_host_path` judges the
string the user chose, unchanged. `validate_resolved_host_path` judges the
canonical form for the things only it can answer — the system roots (a Mac's
`/etc` *is* `/private/etc`), the login-item directories, and a new
`HOST_CREDENTIAL_DIRS` list. That last one is what keeps H4's escape closed:
`Downloads/pub` → `~/.ssh` with a leaf of `authorized_keys` is refused because
of where it lands, not because of how the directory is spelled. macOS handling
is untouched — `/private/tmp` stays out of `HOST_SYSTEM_ROOTS` and
`/var/folders` stays in the exceptions.

H8 (HIGH) — the upload reservation claimed its destination with
`sh -c 'set -C; : > "$0"'`, and the comment claiming that is `O_EXCL` was
wrong for a destination that is not a regular file. Against a FIFO the shell
opens it and blocks in `open(2)` forever; `exec_oneshot_raw` has no timeout, so
`upload_file_to_container` never returned and the Files pane sat on
"Uploading…" for the session with the rest of the batch abandoned. Verified in
a fresh ubuntu:24.04: the old form times out and the blocked `sh` stays in
`ps`; the new form answers in 35 ms.

The reservation is now a `link(2)` — it claims a name atomically, never opens
anything, and `EEXIST` is immediate whatever is in the way. A staging file at
an unguessable name in the same directory is linked into place and unlinked,
under a `trap … EXIT`. `exec_oneshot_as_within` adds a wall-clock ceiling as
the second line of defence, opt-in per call site so migration's `apt-get` is
unaffected. The upload contract is unchanged: default-refuse,
`overwrite: Option<bool>`, and `FILE_EXISTS: <full container path> already
exists`.

Also fixed, all in the same surface:

* A dangling symlink destination was a permanent dead end — `set -C` refused,
  the confirming `test -e` followed the link and said no, and raw shell text
  came back with no Replace on offer. `link(2)` does not follow the new-path
  link, and the script confirms with `[ -L ]`, so it reports as a collision.
* An upload through a symlink renamed the file: the leaf came off the
  *resolved* path, so `~/Downloads/latest.log` landed as `2026-08-23.log` and
  the collision prompt named a file the user never chose. The name now comes
  from the path the user gave; the resolved path is still what gets opened.
* `download_container_backup` leaked its partial file when the descriptor
  check fired. It now tracks `created` the way `stream_container_file_to_host`
  already did.
* The failed-upload cleanup was `rm -f` on a path that, the reservation having
  succeeded, held whatever was written in the interim — a host file under
  `/workspace/…`. It now removes only an empty regular file, and the comment
  says what it is doing.
* `resolve_container_dir` parsed a combined stdout+stderr buffer as a path.
  It uses the split-stream helper, like the listing next to it.
* `verify_opened_path` failed open on a readlink error (`if let Ok(actual)`).
  A check that cannot see is not a check that saw nothing wrong; the macOS
  compile-time no-op is now spelled out too.
* A trailing slash on a write path resolved to the directory itself.

Nine new tests, all mutation-checked against the pre-fix behaviour. Two more
cases added to the ignored live-Docker test: a FIFO and a dangling symlink,
both timed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 15:40:05 -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 f2a84c18f9 Judge a host path by where it leads, not by how it is spelled
`validate_host_path` was a string test. Nothing in the module called
`canonicalize`, `read_link` or `O_NOFOLLOW`, so a path whose components are
all visible could still land somewhere hidden: with `~/Downloads/pub` a
symlink to `~/.ssh`, a `host_path` of `~/Downloads/pub/authorized_keys` has no
hidden component, no `..` and no system root — and writes into `~/.ssh`. The
container end is not hypothetical: `/proc/self/mountinfo` inside a Triple-C
container spells the host's project paths out verbatim, so code in there knows
both where to plant the link and what host path to ask for. The same bypass
read host files back the other way.

So the policy now runs twice: once on the string, and once on what the OS says
the string resolves to. A write resolves the parent and keeps the caller's
leaf, because the leaf is never followed — the partial file is created with
`O_EXCL` and the download finishes with a rename, which replaces a link rather
than writing through it. A read resolves the whole path, because the whole
path is opened. On Linux the descriptor is then checked against the path that
was validated (`/proc/self/fd`), which is what closes the window between
resolving and opening; elsewhere that window stays open and the comment says so.

Also here:

  * The upload's overwrite guard is a guard again. `noOverwriteDirNonDir`
    refuses only dir-over-non-dir and the reverse — file-over-file extraction
    proceeds, which is exactly the `.credentials.json` case (verified against a
    live daemon). The probe and the write are now one `set -C` exclusive
    create, with the path travelling as `$0` rather than as script. The
    `FILE_EXISTS: <path> already exists` contract with the frontend is
    unchanged, and now pinned by a test — as is the claim the old comment made.
  * Windows normalisation stopped being a string swap: `\\?\`, `\\?\UNC\` and
    administrative shares all reach the same places and are compared as such,
    and the rules are pure functions over a string, so the Windows entries are
    exercised on any platform. The old test passed on Linux only because
    `Path::is_absolute` was false for a Windows path.
  * Container write roots are resolved inside the container too, and the
    comment no longer claims more than the check does.
  * A failed download can no longer delete a pre-existing file that happened to
    collide with the partial's name.
  * One-shot exec output is buffered as bytes and decoded once, so a filename
    split across two Docker frames survives; stdout and stderr are tellable
    apart, so `find`'s diagnostics stay out of the listing parser; and a
    directory too big to buffer is described as one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 13:13:35 -07:00
shadow-testandClaude Opus 5 3329e07d3d Fix the file command surface: argument injection, unbounded reads, unchecked paths
`list_container_files` could delete the user's files. It builds
`["find", path, "-mindepth", …]`, and GNU find ends its starting-point
list at the first argument beginning with `-`, so a `path` of `-delete`
gave it zero starting points (defaulting to `.`, which for an exec that
sets no working_dir is the container's WorkingDir — the bind-mounted
project) and an expression starting with `-delete`. Verified against a
live container on findutils 4.10.0: files and empty directories went out
of the host bind mount, and because `exec_oneshot` discards the exit
code the panel then reported an empty folder.

The rest of the module had the same shape of hole:

* Every path parameter — `path`, `from_path`, `parent_path`,
  `container_dir`, `container_path`, `host_path` — arrived over IPC
  unchecked. There is now one validator for container paths (absolute,
  no `..`, no NUL, length-capped), a second for the ones that *change*
  something (contained in /workspace, /home/claude or /tmp), and one for
  host paths, which refuses traversal, system locations and hidden
  components. The `save()`/`open()` dialog in front of these commands is
  a UI convention, not a boundary.

* `download_container_file` passed `None` for the fetch cap, so the cap
  was inert: the whole transfer was buffered in host RAM twice, and the
  directory refusal came *after* the buffer, so `/` meant buffering the
  container's filesystem before erroring. Downloads now stream through a
  bounded channel into the tar reader, which refuses a non-regular entry
  and an oversize one before the host file is created at all. Verified
  against a real container: a 300 MiB download peaks at 10 MiB RSS, a
  9 GiB sparse file is refused in 0.01s with nothing written.

* Both download paths (file and backup) used to create — i.e. truncate —
  the user's destination up front and delete it on a stream error, which
  is precisely the wrong order for a path that already holds something.
  They now write beside it and rename on success.

* `upload_file_to_container` silently clobbered: no existence check
  anywhere in the stack. It now refuses by default with a FILE_EXISTS
  marker the frontend turns into a Replace/Skip prompt, and takes an
  `overwrite` flag for the retry.

* `exec_oneshot_inner` read an undeterminable exit status as 0, so
  rename and mkdir reported success for an exec nobody could read the
  outcome of. It fails closed now.

* A tab in a filename forged the type/size/permission columns of a
  listing row, and a newline forged a whole row. `find` now prints the
  name last with NUL-terminated records.

While verifying the size ceiling against a real container, the tar
header's size field turned out to be unusable past ustar's 8 GiB octal
limit — Docker's Go writer puts the real size in a PAX record — so both
readers take it from `entry.size()` instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 11:34:04 -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 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 d0bb631d4d Remove MCP backend, entrypoint injection, and docs; add migration shim
Completes the removal begun in the previous commit.

Backend: deletes models/mcp_server.rs, storage/mcp_store.rs and
commands/mcp_commands.rs, the McpStore on AppState, the four IPC
handlers, Project::enabled_mcp_servers, build_mcp_servers_json(),
compute_mcp_fingerprint(), the MCP_SERVERS_JSON env injection, the
mcp-fingerprint label, and the whole MCP container lifecycle.
create_container() and container_needs_recreation() lose their
mcp_servers/network_name parameters.

Container: entrypoint.sh no longer merges MCP_SERVERS_JSON into
~/.claude.json. MCP_SERVERS_JSON stays in the reserved env blocklist.

Security: the Docker socket is no longer auto-mounted for stdio+Docker
MCP servers — it now mounts only when allow_docker_access is set.

Migration: old containers were created with
network_mode=triple-c-net-<projectId> and refuse to start once that
network is gone. docker/network.rs becomes docker/legacy_cleanup.rs with
label-driven, best-effort removal of leftover MCP containers and the
per-project network, called on both delete and recreate.
container_needs_recreation() now forces a rebuild for any container
carrying a non-empty triple-c.mcp-fingerprint label or attached to a
triple-c-net-* network, moving it onto the default bridge. Both can be
dropped a release later.

Docs: drops the MCP sections from README/HOW-TO-USE/TECHNICAL and adds a
short note pointing at Claude Code's native `claude mcp` / `/mcp` /
.mcp.json instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:31:18 -07:00
shadow-testandClaude Opus 4.8 26adccce5b Rename backup archive root so extraction dir mode isn't clobbered
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m14s
Build App / build-windows (pull_request) Successful in 4m26s
Build App / build-linux (pull_request) Successful in 5m0s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The transform used `s,^\./,workspace/,`, which rewrites the workspace
*contents* (`./foo` -> `workspace/foo`) but leaves tar's root member as a
bare `./`. That `./` entry carries the source root's mode/mtime, and on
extraction tar stamps them onto the extraction directory itself.

Match the leading `.` instead (`s,^\.,workspace,`) so the root member is
renamed `./` -> `workspace`, giving the archive a proper `workspace/`
directory entry and no bare `./`. The extraction directory is left
untouched. Contents, hidden files, excludes, symlink targets and the
`flags=rh` hardlink handling are unchanged.

Verified in-container: archive top level is exactly `workspace/` +
`home-claude/`, no `./` member, node_modules excluded, extraction into a
0755 dir leaves it 0755, workspace/.git preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:33:02 -07:00
shadow-testandClaude Opus 4.8 5cd528a4ef Use flags=rh so intra-workspace hardlinks survive the transform
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 4m24s
Build App / build-linux (pull_request) Successful in 5m3s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Review caught that `flags=r` disables rewriting of both symlink AND
hardlink target names. Leaving symlink targets alone is intended, but a
hardlink's stored target is an archive-internal reference to another
member's name — when member names become `workspace/...` but the
hardlink target stays `./hard_link`, extraction fails hard:

  tar: workspace/file.txt: Cannot hard link to './hard_link':
       No such file or directory

`flags=rh` rewrites regular member names and hardlink target names
together (keeping the pair consistent) while still leaving symlink
targets untouched. Verified in-container: extract exit 0, symlink target
preserved, hardlink pair shares one inode, nesting under workspace/ intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:22:14 -07:00
shadow-testandClaude Opus 4.8 c3fc029b1d Nest workspace under workspace/ in project backup
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m17s
Build App / build-linux (pull_request) Successful in 5m7s
Build App / build-windows (pull_request) Successful in 5m9s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The backup archive placed the workspace at the archive root (`./...`)
while the sanitized home config sat under `home-claude/`. On extraction
the workspace files scattered loose into the extraction directory and
only `home-claude/` showed up as a distinct folder, so the backup read
as "config only, workspace missing" — and some archive viewers didn't
surface the root-level entries at all.

Add `--transform='flags=r;s,^\./,workspace/,'` so the workspace nests
under `workspace/`, parallel to `home-claude/`. `flags=r` scopes the
rewrite to member names only, leaving symlink targets (relative and
absolute) intact. Excludes still match the pre-transform names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:18:08 -07:00
shadow-testandClaude Opus 4.8 0945e21eb1 Address second review pass: fix start-time race + minor cleanups
Build App / compute-version (pull_request) Successful in 9s
Build Container / build-container (pull_request) Successful in 1m48s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m11s
Build App / build-linux (pull_request) Successful in 4m51s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- M1 (race): don't mount the host AWS dir for static-credential Bedrock.
  sync_bedrock_credentials() is the sole writer of ~/.aws/credentials in
  that mode, and mounting /tmp/.host-aws let the entrypoint's
  `rm -rf ~/.aws; cp -a` race that write at startup (only when a global
  aws_config_path was also set). Static keys + AWS_REGION env are
  self-sufficient and don't need the host config, so skipping the mount
  removes the dual-writer entirely.
- L-a: exit codes are now read via wait_for_exec_exit(), which polls
  inspect_exec until the exec reports finished, so a non-zero tar/cred
  exit isn't missed by reading exit_code too early. The backup only fails
  on a definitively non-zero code (falls back to the empty-output check
  if undeterminable).
- L-b: fixed two comments that referenced the old
  write_bedrock_static_credentials name (now sync_bedrock_credentials).
- L-c: entrypoint only rewrites ~/.claude.json when awsAuthRefresh is
  actually present, avoiding a needless jq reformat on every non-SSO
  start.
- L-d: backup script traps EXIT to remove its mktemp staging dir even
  when tar fails, so failed backups don't accumulate temp dirs (with the
  sanitized config copy) in the container.

L-e (drop routing) is a non-issue: the layout is tabbed, so only one
terminal pane is ever visible; the active-guard routing is correct.

Verified the race fix, trap cleanup, grep guard, and exit-code polling.
cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:59:48 -07:00
shadow-testandClaude Opus 4.8 d65872dc94 Address remaining review items: L4/M1 + L2/L5 cleanup
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 57s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m54s
Build App / build-linux (pull_request) Successful in 5m44s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- L4: sync_bedrock_credentials (renamed from write_bedrock_static_
  credentials) now also clears a stale ~/.aws/credentials when the
  project no longer uses static-credential Bedrock, so static keys don't
  linger unused in the persistent home volume after switching backends.
  Skipped when /tmp/.host-aws is mounted (host-managed ~/.aws). HOME is
  also set explicitly on the exec env for robustness.
- M1: the Backup button now has a tooltip and the success toast notes
  that the archive includes MCP/config which may contain MCP-embedded
  API keys (OAuth tokens are excluded) — keep it private.
- L2: backup now uses async file IO (tokio::fs::File + AsyncWriteExt,
  tokio::fs::remove_file) instead of blocking std::fs between awaits;
  dropped-file reads use tokio::fs::metadata/read.
- L5: upload_host_file_to_terminal explicitly `mkdir -p`s
  /tmp/triple-c-drops instead of relying on Docker's tar extractor to
  create the parent dir.

Verified L4 cleanup guard, L5 mkdir, async IO, and exit-code paths
against real containers. cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:37:33 -07:00
shadow-testandClaude Opus 4.8 edf0698774 Address PR review: backup correctness/security + drop hardening
Build App / compute-version (pull_request) Successful in 8s
Build Container / build-container (pull_request) Successful in 51s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m52s
Build App / build-linux (pull_request) Successful in 6m5s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Fixes from the code review of this branch:

- Backup requires a running container (it runs via `docker exec`, which
  can't run on a stopped one). Removed the misleading "Backup" button
  from the stopped-project actions, added an explicit running check with
  a clear error, and corrected the doc comment. (H1)
- jq sanitization fallback no longer leaks secrets: if ~/.claude.json
  can't be parsed, the backup substitutes an empty object and warns to
  stderr instead of copying the raw file (which held primaryApiKey /
  oauthAccount). Verified the raw key never reaches the archive. (H2)
- Dropped-file paths typed into the terminal are now always single-quoted
  (with '\'' escaping), not only when they contain whitespace — a name
  like `foo$(whoami).txt` was previously sent raw into the shell. (M2)
- write_bedrock_static_credentials checks the exec exit code via the new
  exec_oneshot_env_status and fails loudly on a write/chmod error instead
  of silently reporting success. exec_oneshot keeps its
  ignore-exit-code behavior so list_container_files is unaffected. (M4)
- Backup removes a partial/truncated archive on any stream error and
  treats a non-zero tar exit code as failure (a truncated gzip was
  previously reported as success). (L1)
- Dropped files are capped at 256 MiB to avoid ballooning host RAM
  (the file is read fully into memory then re-tarred). (M3)
- Stopped excluding .git/objects from the backup so git history,
  including unpushed commits, is preserved faithfully. (L3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:28:00 -07:00
shadow-testandClaude Opus 4.8 10e689eaa6 Include sanitized home config in project backup
Extends download_container_backup to also capture the container's home
config so MCP servers, settings, and skills set up directly via Claude
Code (stored in ~/.claude.json / ~/.claude, on the home/config volumes
that a Reset wipes) survive a backup/restore cycle.

Secrets are stripped per the "exclude secrets" choice: ~/.claude.json is
filtered through jq to drop primaryApiKey/oauthAccount/customApiKeyResponses
(mcpServers and settings are kept), and ~/.claude/.credentials.json (the
OAuth tokens) is omitted. Staged config is archived under home-claude/ in
the tarball. Verified on the Ubuntu/jq container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:04:19 -07:00
shadow-testandClaude Opus 4.8 d07dcdfea9 Add "Backup" action to download a project's /workspace as .tar.gz
Adds a manual backup button on each project card (next to Start/Reset
when stopped, and next to Files when running) that saves a gzipped
tarball of the container's /workspace to a host path via the native save
dialog.

Backend: download_container_backup runs `tar czf -` inside the container
(so excludes + compression happen there rather than streaming a 16 GB
workspace) and pipes stdout straight to the chosen file. Regenerable
build artifacts (node_modules, target, .git/objects) are excluded so the
archive stays restore-sized. Returns bytes written; stderr is captured
for error reporting and a zero-byte result is treated as failure.

Works whether the container is running or stopped (only requires that it
exists). Verified on the Ubuntu/GNU-tar container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:48:04 -07:00
shadow-testandClaude Opus 4.6 bf8ef3dba1 Fix file manager listing current folder as entry within itself
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 4m39s
Build App / sync-to-github (push) Successful in 10s
The find command included the starting directory in results (e.g., listing
"workspace" inside /workspace). Replace `-not -name "."` with `-mindepth 1`
which correctly excludes the starting path from output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:38:48 -08:00
shadow-testandClaude Opus 4.6 ab16ac11e7 Add bash shell tab and file manager for running containers
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 3m52s
Build App / build-linux (push) Successful in 4m53s
Build App / sync-to-github (push) Successful in 12s
Adds two new features for running project containers:

1. Bash Shell Tab: A "Shell" button on running projects opens a plain
   bash -l session instead of Claude Code, useful for direct container
   inspection, package installation, and debugging. Tab labels show
   "(bash)" suffix to distinguish from Claude sessions.

2. File Manager: A "Files" button opens a modal file browser for
   navigating container directories, downloading files to the host,
   and uploading files from the host. Supports breadcrumb navigation
   and works with any path including those outside mounted projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:32:53 -08:00