Compare commits

...
10 Commits
Author SHA1 Message Date
shadow-testandClaude Opus 5 016de8f641 Close the blockers from the fifth audit
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 10m5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 4m31s
Build App (Preview) / build-linux (pull_request) Successful in 5m21s
Build App (Preview) / build-windows (pull_request) Successful in 19m1s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Docs and disclosure. HOW-TO-USE.md's settings table still described the
pre-fix behaviour — and help_commands.rs fetches that file from GitHub
main at runtime, ahead of the embedded copy, so it would have reached
every user's Help dialog the moment this merged. The Config tab named
three settings that need a base-image update; there are four, and the
omitted one (Session recap) is the one that fails *without* the "won't
switch off" symptom the warning teaches. Both now also state the cost
nobody had written down: changing any of these recreates the container,
which commits a layer.

Two stale comments that told a reviewer the code was safe when it was
not. compute_claude_code_settings_fingerprint still claimed the
historical fingerprint is preserved so an upgrade cannot churn every
container — carried over from before the widening, false since the
format string changed. And capabilities/default.json, which is the
reviewed threat model of record, described a "Save to host…" action this
branch deletes.

Security and correctness. update_settings validated env vars and nothing
else, so the *global* default_ssh_key_path — the fallback for every
project without an override — took `/` and read-only bind-mounted the
host, which entrypoint.sh then copies into the home volume. classify_
mount_source ran canonicalize on the raw string, which resolves a
relative path against Triple-C's own cwd, so `.` and `..` were accepted
or refused depending on where the app was launched; the daemon then
refuses the mount and the project can never start. Its test passed only
because its examples did not exist under app/src-tauri.

bind_mount_exclusions still derived a path from every row while
project_path_mounts had learned to skip unmountable ones, so a legacy
row made /workspace/<name> ordinary container content that a migration
would then exclude from staging and destroy. The skip is also logged now
rather than silently dropping a folder.

The terminal's file-in path checked is_dir() but not file type, so a
dropped FIFO blocked forever with no timeout — and it is the only route
in now. The web terminal labelled sessions from a global set at request
time, so two quick opens swapped them; harmless until Shift+Enter became
type-dependent, at which point a mislabelled Claude session submitted a
half-written prompt. Opened now carries the type.

Every ~/.claude.json write goes through one atomic helper. The
awsAuthRefresh branches still truncated in place — the same corruption
the Shift+Enter block was fixed for twenty lines later, and its own
comment said so. Demonstrated: a failed write now leaves the original
byte-identical.

And the registration test I added yesterday could pass while the
property was false: an audit got five real unregistered commands past its
exact-string attribute match, and "exactly once" was in its name but not
its body. Mutation-checked against all six shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 18:45:11 -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 a323047964 Merge branch 'r4/scrub' into ship/core 2026-08-23 17:12:34 -07:00
shadow-testandClaude Opus 5 11216c45e3 Assert every command is registered, and every registration exists
This is the shape of the bug behind the original OAuth-callback report:
`set_auth_bridge_enabled` existed, worked, and had a typed frontend
wrapper — with zero call sites. The switch the docs told users to flip
was wired to nothing, so every login callback was refused. Both halves
compiled, so nothing noticed.

The reverse direction is the sharper one: a command that is registered
but reachable from nowhere is still IPC surface a compromised webview
can call. `list_sibling_containers`, which returns every container on the
daemon including the user's unrelated work, sits in exactly that state.

Mutation-checked both ways: removing a registration fails the test,
restoring it passes. The first parser I wrote split the list on commas,
which glued each `// Docker` style comment to the command after it and
then dropped that command as a comment — silently, once per group, 17 in
total. Line-based now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 17:12:34 -07:00
shadow-testandClaude Opus 5 913aa85805 Stop the pre-commit scrub running with its guards silently absent
Two fail-open gaps, both latent on the shipped image and both live on an
ImageSource::Custom one.

`--one-file-system` is the only bound on the scrub's `rm -rf`, and it was
probed into `$rmopt` with `rm --one-file-system --help`. BusyBox answers
that with `unrecognized option` and exits 1, so on any busybox- or
toybox-derived image the variable was empty and the delete ran as root
across mount boundaries — reported as a completed `Reclaimed(n)`.
Measured in `busybox:latest` with a named volume one level below the
match at /tmp/claude-x/inner: emptied, `###TRIPLE-C-SCRUBBED 102423`. It
is a prerequisite now, probed by `rm --one-file-system -f -- ''` — the
code path that matters, with the one operand no filesystem can name — and
an image without it prints `###TRIPLE-C-SCRUB-UNAVAILABLE` and deletes
nothing. That costs Alpine and busybox images their scrub, which is the
cheaper half of the trade: declining costs disk, proceeding costs the
mount.

The second is that resetting `PATH` was never the whole of command
lookup. `bash` builds functions out of `BASH_FUNC_<name>%%` environment
variables, function lookup precedes `PATH` entirely, and `command -v`
reports a function as found — so a planted `stat` passed the prerequisite
probe and then answered the containment checks. Every in-shell answer is
itself importable: `unset`, `command`, and — the point that settles it —
`[`, `pwd` and `cd`, which are checks 0 to 2 rather than merely the
tools. So the script is no longer run by the shell that read the
environment. The exec's argv is a bootstrap using only reserved words,
parameter expansions and command words containing a `/` (which `bash`
refuses to import a function for), and it hands the script as `$1` to a
second `/bin/sh` started by `env -i`. Measured on `/bin/sh -> bash` with
`BASH_FUNC_stat%%` set on the container and a volume mounted at the
match: the old invocation emptied it and printed 306565, the bootstrap
left it intact and printed 65536. `/usr/bin/env` then `/bin/env`, because
H3's lesson about hardcoded coreutils locations applies to `env` too.

The comment claiming the `PATH` reset "defeats it just as completely as
spelling /usr/bin/stat out" is corrected, as is the one claiming a
sudo-written /usr/bin/stat does not survive a container restart — it
lands in the writable layer, which is exactly what the commit this runs
in front of captures.

Also here: a stored project path row with an empty host_path or
mount_name no longer becomes a mount. The first sends `field Source must
not be empty` back for the whole create, so the project cannot start at
all until the row is gone, and no amount of save-time validation reaches
a record already on disk; the second mounts over /workspace itself and
the daemon then creates the other rows' mount points inside the user's
real folder.

And in migration_commands, the deferred-reconcile claim is RAII rather
than a trailing statement — a panic inside `reconcile_migration_now`
stranded it for the rest of the process — and `await_release` looks
before it sleeps, so a project released a moment later no longer costs a
full twenty seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 17:09:47 -07:00
shadow-test e9902f0564 Merge branch 'r4/narrow' into ship/core 2026-08-23 17:08:04 -07:00
shadow-test 7488fc5b70 Merge branch 'r4/host' 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-testandClaude Opus 5 dd23a52b41 Fix four upgrade-path defects the coherence audit found
The ~/.claude.json write was `printf ... > "$CLAUDE_JSON"`, which
truncates before it writes. A write that fails part-way — a full home
volume, which is the exact condition half this release exists to prevent
— leaves the file unparseable, and it never self-heals: the next start's
jq fails on the corrupt file, MERGED is empty, and the guard skips the
write that would have repaired it. That file holds the OAuth account, so
the failure mode is a permanently lost login, in service of a cosmetic
flag that suppresses a tip. Demonstrated: old pattern loses the
credential, new tmp+rename leaves the original intact. The correct
pattern was already in triple-c-task-runner.

The web terminal scoped its xterm key handler to Claude sessions but not
its mobile input bar or its dedicated newline button, so both sent ESC+CR
into `bash -l`, where readline has no binding for it. Silent no-op, and
worse from a button that stays on screen looking live. Both now consult
the active session's type, and the button is disabled with a reason on a
shell tab.

The Config tab claimed "Off overrides a global On" without qualification.
True for the env-var-driven settings, false for TUI mode, Effort level
and Focus mode, whose off state is *removing* a key — an older base
image's entrypoint ignores the instruction to remove it. The copy now
says so and points at the base-image update.

HOW-TO-USE.md said there is no add-task form; AutomationTab renders a
"New task" button. That file is fetched from GitHub at runtime by
help_commands.rs, so the error was live in every user's Help dialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 16:45:20 -07:00
38 changed files with 3224 additions and 3146 deletions
+28 -8
View File
@@ -79,14 +79,34 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
modals — see "UI conventions" below.
- **Files takes drops *in*, and that path does not use HTML5 drag.** Dropping into the pane
is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by a
hit-test of the physical-pixel payload position against the pane's rect ÷
`devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and
`TerminalView`'s listener both firing. Keep `lib/dropTarget.ts` and both listeners.
- **Getting a file *out* is "Save to host…", and there is no other route.** OS drag-out —
`tauri-plugin-drag`, `stage_container_file_for_drag` and its host staging directory — was
removed from the ship branch and held back for separate hardening; it lives on
- **Files is container-side only. It does no host filesystem I/O, and must not grow any.**
The tab lists, opens (text and image viewer), renames and creates folders *inside* the
container: `list_container_files`, `read_container_file`, `rename_container_path`,
`create_container_directory`. There is no upload button, no "Save to host…", and no
drop-into-the-pane. Four successive audits found that host filesystem paths crossing IPC
were where the criticals lived — a caller-named host destination for container-controlled
bytes, an arbitrary host source read into the container, a `link(2)` upload reservation
that succeeded against a directory and failed forever on any filesystem without hard
links — so the feature was narrowed rather than fixed a fifth time. If a host path ever
needs to reach this pane again, the honest shape is for the *backend* to drive
`tauri-plugin-dialog`, so no host path arrives over IPC at all.
- **A file gets *in* by being dropped on the Terminal, and *out* through "Back up
container".** Those two are the whole host↔container story, they predate the Files work,
and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent` is Tauri's
native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for *whose*
drop it is, a document-wide `dropIsBlocked` for whether the app should accept one at all;
keep both halves and keep `PaneVisibility`). Backup is
`file_commands::download_container_backup`.
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
after canonicalisation.** That includes the general hidden-component rule, which
deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
through a planted symlink with a perfectly visible name. The two callers left are
occasional, so over-refusing is the cheaper mistake.
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
host staging directory were held back for separate hardening and live on
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
a compromised webview start a drag on *any* host path the user can read, and the staging
+61 -21
View File
@@ -228,7 +228,7 @@ buttons. Below that are six tabs:
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
| **Files** | Browse, download and upload files inside the container |
| **Files** | Browse, view and rename files inside the container — see [Files](#files) for how files get in and out |
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
### Sessions
@@ -351,7 +351,7 @@ it. The sidebar row carries only the two hover controls.
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files |
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container |
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
@@ -615,18 +615,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
| Setting | What It Does |
|---------|-------------|
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
| **Session Recap** | Provides context when returning to a session after being away |
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
| **Focus Mode** | Summarises tool *calls* to one line each, showing the last prompt and the final response. **Needs the fullscreen renderer** — set TUI Mode to Fullscreen or this does nothing |
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
| **Session Recap** | A one-line recap when you return to the terminal after a few minutes away. **On by default** — the switch is how you turn it off |
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
| **Env Scrub** | Strips credentials from subprocess environments for security |
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
| **Prompt Caching (1h)** | Requests a 1-hour prompt cache TTL instead of the default 5 minutes |
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
Each switch has three states on a project: **Global** (follow Settings), **On**, and **Off**. Off is a
real choice — it overrides a global On, which a project could not previously do.
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
> These map to Claude Code environment variables and `~/.claude/settings.json` keys, and are applied
> when the container starts. Changing one stops and recreates the container.
>
> **Two caveats on an existing project.** Changing any of these recreates the container, and a
> recreation commits a new image layer — so flipping switches repeatedly costs disk. And
> **TUI Mode, Effort Level, Focus Mode and Session Recap cannot be returned to Global** until the
> project's base image is updated: those four are cleared by *removing* a key, and an older image's
> startup script ignores the instruction to remove it. Update the base image from the project's
> Overview tab first. The other switches work on any image.
### MCP Servers
@@ -1161,16 +1170,43 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
### Files
The **Files** tab of Project Home browses inside a running container. You can:
The **Files** tab of Project Home browses inside a running container. It works entirely on the
container side — it never reads or writes anything on your own machine. You can:
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation
- **Save to host…** — copy any file out to a location you pick. This is the way to get a file out
of a container; there is one button per file entry, and the file viewer offers it too
- **Upload file** from your host into the current container directory — or **drop files straight
onto the pane** from your desktop, which uploads them into the directory on screen
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
between rows and Enter opens the selected one
- **View** a file — double-click it, or press Enter. Text files and images render in a read-only
viewer
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
file between folders
- **New folder** in the directory on screen
- **Refresh** the directory listing at any time
The listing shows file names, sizes, and modification dates.
The listing shows file names, sizes, and modification dates, and marks symbolic links.
#### Getting files in and out
The Files tab deliberately does **not** copy files between your computer and the container. There
are two supported routes, and they are the ones to use:
- **To get a file in:** drag it from your desktop and **drop it onto the Terminal tab**. The file
is copied into the container and its path is typed into the terminal for you, ready to hand to
Claude Code. (You can also drop it onto the terminal's *Following* toggle — the whole pane is a
drop target.)
- **To get files out:** use **Back up container** in Project Home's **⋯** overflow menu. It writes
a `.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose.
For a single file, `cat` it in a terminal, or work in a project folder that is mounted from your
host in the first place — those files are already on both sides.
Both routes refuse a destination whose path passes through a hidden folder — anything with a
component beginning with `.`, such as `~/.ssh`, `~/.local/bin` or `~/.config`. That rule catches
more than it strictly needs to (a path that happens to resolve through `~/.cache` or
`node_modules/.pnpm` is refused as well), and the refusal says which folder tripped it. Choose a
visible location such as `~/Documents` or `~/Downloads`.
If you already keep the project in a folder mounted into the container, the simplest answer is
usually neither of the above: edit the file on your host and it is already inside.
### Terminal Rendering
@@ -1216,10 +1252,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
that was used.
### Creating Tasks (In the Container)
### Creating Tasks
There is no "add task" form in the app. Create tasks from a terminal in the container — either type
the commands yourself in a **Shell** session, or just ask Claude to do it.
The quickest route is the **New task** button on a project's **Automation** tab, which gives you a
form for the name, the schedule and the prompt.
You can also create tasks from a terminal in the container — type the commands yourself in a
**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to
work out the schedule or the prompt for you, and it is what the rest of this section covers.
### Create a Recurring Task
+5 -5
View File
@@ -105,7 +105,7 @@ configuration. Per-project configuration lives in the Config tab rather than in
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
| **Files** | Browse, download and upload files inside the container |
| **Files** | Browse, view and rename files inside the container. Container-side only: to get a file *in*, drop it on the Terminal tab; to get files *out*, use **Back up container** |
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
@@ -513,7 +513,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) |
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
@@ -536,7 +536,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) |
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
@@ -547,7 +547,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| File | Purpose |
|---|---|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
@@ -561,7 +561,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir) plus `download_container_backup` |
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
+1 -1
View File
@@ -504,7 +504,7 @@ triple-c/
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
│ ├── aws_commands.rs # AWS profile/region discovery
│ ├── docker_commands.rs # Docker status, image ops
│ ├── file_commands.rs # File browser (list/download/upload)
│ ├── file_commands.rs # File browser (browse, view, rename)
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
│ ├── install_helper_commands.rs # Guided Docker installation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"identifier": "default",
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is now \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API and never touches this permission; the Files tab is browse, view and rename only. An earlier version of this sentence pointed at a \"Save to host…\" action, which was removed in the same round that removed drag-out — this file is the reviewed threat model of record, so a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
"windows": ["main"],
"permissions": [
"core:event:allow-listen",
File diff suppressed because one or more lines are too long
@@ -37,20 +37,3 @@ pub async fn get_container_info(
docker::get_container_info(&project).await
}
#[tauri::command]
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
let containers = docker::list_sibling_containers().await?;
let result: Vec<serde_json::Value> = containers
.into_iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"names": c.names,
"image": c.image,
"state": c.state,
"status": c.status,
})
})
.collect();
Ok(result)
}
File diff suppressed because it is too large Load Diff
+169 -30
View File
@@ -1071,21 +1071,54 @@ fn reconcile_retries() -> &'static std::sync::Mutex<std::collections::HashSet<St
RETRIES.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
/// One project's place in [`reconcile_retries`], handed back on drop.
///
/// RAII for the reason [`crate::project_lock::ProjectGuard`] sets out, and this
/// claim is the case that proves the rule: the release used to be a trailing
/// statement at the bottom of the spawned task in
/// [`defer_migration_reconcile`], sitting after an `.await` on
/// [`reconcile_migration_now`]. A panic in there — or the future simply being
/// dropped, which is what happens to every in-flight task at shutdown — skips
/// the statement, and nothing else ever removes an id from that set. The
/// project is then fenced off from *every* later deferral for the rest of the
/// process: each `reconcile_project_statuses` pass finds it held, fails to
/// claim, and returns, so the phase stays un-normalised, no resume or rollback
/// is offered, and the `:pre-migration-*` pin stays `Claimed`. That is the
/// session-long silence deferring was written to end, reintroduced one panic
/// later and lasting until the app is restarted.
///
/// Dropping this hands the claim straight back, so a caller that discards the
/// value has claimed nothing while reading as though it had; `#[must_use]`
/// makes that a compile warning rather than a second waiter on one record.
#[must_use = "the claim is handed back the moment this guard drops; bind it inside the waiting task, for the whole task"]
struct ReconcileRetryClaim {
project_id: String,
}
impl Drop for ReconcileRetryClaim {
fn drop(&mut self) {
// `into_inner` past poisoning, as in `project_lock`: the only thing
// ever done while holding this mutex is a single `HashSet` insert or
// remove, so a panic on another thread cannot have left it half
// written — and declining to release here would strand the project
// permanently, which is the exact failure the guard exists to stop.
reconcile_retries()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&self.project_id);
}
}
/// Claim the right to be the one deferred reconcile for `project_id`.
/// `false` means somebody else already is.
fn claim_reconcile_retry(project_id: &str) -> bool {
/// `None` means somebody else already is.
fn claim_reconcile_retry(project_id: &str) -> Option<ReconcileRetryClaim> {
reconcile_retries()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(project_id.to_string())
}
/// Give the claim back, so a later `reconcile_project_statuses` can defer again.
fn release_reconcile_retry(project_id: &str) {
reconcile_retries()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(project_id);
.then(|| ReconcileRetryClaim {
project_id: project_id.to_string(),
})
}
/// Come back to a project that was held when [`reconcile_migration`] reached it.
@@ -1108,13 +1141,19 @@ fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
if !migration_store::has_record(&project.id).unwrap_or(true) {
return;
}
if !claim_reconcile_retry(&project.id) {
let Some(claim) = claim_reconcile_retry(&project.id) else {
return;
}
};
let project = project.clone();
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
// Moved in and bound for the whole body, rather than released by a
// statement at the bottom: everything below this line can panic or be
// dropped mid-await, and a claim that only comes back on the happy path
// is a claim that eventually does not come back at all. See
// [`ReconcileRetryClaim`].
let _claim = claim;
let released =
await_release(&project.id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await;
if released {
@@ -1133,28 +1172,42 @@ fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
RECONCILE_RETRY_INTERVAL.as_secs() as usize * RECONCILE_RETRY_ATTEMPTS / 60
);
}
release_reconcile_retry(&project.id);
});
}
/// Wait for `project_id` to stop being held, up to `attempts` looks
/// `interval` apart. `true` means it was released, `false` that the budget ran
/// out with it still held.
/// Wait for `project_id` to stop being held: `attempts` looks, the first
/// immediate and the rest `interval` apart. `true` means it was released,
/// `false` that the budget ran out with it still held.
///
/// Split out of [`defer_migration_reconcile`] so the waiting can be tested
/// against a real [`crate::project_lock`] guard on a paused clock — the part
/// that is easy to get wrong is "gives up while still holding the claim" and
/// "never looks again", neither of which is visible from the constants.
/// against a real [`crate::project_lock`] guard on a paused clock — the parts
/// that are easy to get wrong are "gives up while still holding the claim",
/// "never looks again", and the ordering of the look against the sleep, none of
/// which is visible from the constants.
async fn await_release(
project_id: &str,
interval: std::time::Duration,
attempts: usize,
) -> bool {
for _ in 0..attempts {
tokio::time::sleep(interval).await;
for attempt in 0..attempts {
// Look first, sleep second. Sleeping first charged every deferral a
// full interval before anyone read the map even once, and the common
// case is a holder that has already let go: `held()` is sampled in
// `reconcile_migration`, a task is spawned, and by the time it is first
// polled the Reset that was on its last step is frequently finished.
// That bought nothing and cost twenty seconds of a startup pass waiting
// on a lock nobody holds, in front of a check that is one `HashMap`
// lookup.
if crate::project_lock::held(project_id).is_none() {
return true;
}
// And no sleep after the final look: nothing reads the map again
// afterwards, so it is twenty seconds of delay in front of a `false`
// that has already been decided. The budget is still `attempts` looks,
// which is what the constants above are chosen against.
if attempt + 1 < attempts {
tokio::time::sleep(interval).await;
}
}
false
}
@@ -2217,6 +2270,34 @@ mod tests {
assert!(budget >= std::time::Duration::from_secs(15 * 60), "{:?}", budget);
}
/// MEDIUM: an unheld project is reconciled now, not in twenty seconds.
///
/// The wait slept before its first look, so a holder that let go between
/// `reconcile_migration` sampling `held()` and this task being polled — the
/// *common* case, since a deferral is only taken when something was on its
/// way out — still cost a full `RECONCILE_RETRY_INTERVAL` of a startup pass
/// waiting on a lock nobody held. On a paused clock the assertion is exact:
/// the fixed shape returns without the clock moving at all, the sleep-first
/// shape cannot return before it has advanced one interval.
#[tokio::test(start_paused = true)]
async fn an_unheld_project_is_seen_without_waiting_out_an_interval() {
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
assert!(
crate::project_lock::held(&id).is_none(),
"a fresh uuid is not held"
);
let before = tokio::time::Instant::now();
assert!(await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
let waited = tokio::time::Instant::now() - before;
assert_eq!(
waited,
std::time::Duration::ZERO,
"an already-released project cost {:?} before anyone looked",
waited
);
}
#[test]
fn only_one_deferred_reconcile_waits_per_project() {
// Every "Docker became available" walks every project, so without the
@@ -2224,14 +2305,72 @@ mod tests {
// per call — all of which then reconcile the same record in a row.
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
let other = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
assert!(claim_reconcile_retry(&id));
assert!(!claim_reconcile_retry(&id), "a second waiter was allowed in");
assert!(claim_reconcile_retry(&other), "the claim is not per-project");
release_reconcile_retry(&id);
assert!(claim_reconcile_retry(&id), "the claim was never handed back");
release_reconcile_retry(&id);
release_reconcile_retry(&other);
// Releasing something that was never claimed is not an error.
release_reconcile_retry(&id);
let first = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
assert!(
claim_reconcile_retry(&id).is_none(),
"a second waiter was allowed in"
);
let other_claim =
claim_reconcile_retry(&other).expect("the claim is not per-project");
drop(first);
// Bound rather than discarded: the guard releases on drop, so
// `claim_reconcile_retry(&id);` as a bare statement would test nothing
// — which is what `#[must_use]` is there to catch in real callers.
let retaken = claim_reconcile_retry(&id).expect("the claim was never handed back");
drop(retaken);
drop(other_claim);
// And the other project's claim was never the same claim.
drop(claim_reconcile_retry(&other).expect("released independently"));
}
/// MEDIUM: the claim survives the task that holds it dying badly.
///
/// The release used to be a trailing statement after
/// `reconcile_migration_now(...).await` at the bottom of the spawned task,
/// so a panic anywhere in that call — or the future being dropped at
/// shutdown — skipped it and left the id in the set with no task behind it.
/// Nothing removes it afterwards, so that project could never be deferred
/// again for the rest of the process: exactly the state deferring was added
/// to prevent, now permanent instead of one pass long. Fails against the
/// trailing-statement shape, which is the point.
#[tokio::test]
async fn a_panicking_deferred_reconcile_hands_its_claim_back() {
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
let claimed = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
// Spawned, not just called: the real claim is held across an await
// inside a `tauri::async_runtime::spawn`, and a task panic is caught by
// the runtime rather than unwinding the caller.
let task = {
let id = id.clone();
tokio::spawn(async move {
let _claim = claimed;
tokio::task::yield_now().await;
panic!("reconcile_migration_now blew up on '{}'", id);
})
};
assert!(task.await.is_err(), "the task was supposed to panic");
let after = claim_reconcile_retry(&id);
assert!(
after.is_some(),
"a panicking reconcile stranded the claim — this project can never be \
deferred again for the rest of the process"
);
drop(after);
// The other half of the same failure: a task that is simply dropped
// mid-flight, which is every in-flight task at shutdown.
let claimed = claim_reconcile_retry(&id).expect("released above");
let never_finishes = tokio::spawn(async move {
let _claim = claimed;
std::future::pending::<()>().await;
});
never_finishes.abort();
let _ = never_finishes.await;
assert!(
claim_reconcile_retry(&id).is_some(),
"a dropped task stranded the claim"
);
}
}
+443 -22
View File
@@ -195,7 +195,9 @@ pub(crate) fn load_secrets_for_project(project: &mut Project) {
/// entire host filesystem read-write into a container whose agent has
/// passwordless sudo. Anything short of a filesystem root is the user choosing
/// a folder — the Browse button and the free-text field lead to the same place
/// — so only the roots themselves are refused.
/// — so only the roots themselves are refused, and *root* is answered by
/// resolving the path rather than by reading it: `/..` is spelled like a folder
/// and is the root. See [`classify_mount_source`].
///
/// This is the **whole** rule set, and it belongs to `add_project`, where every
/// row is new by definition. `update_project` runs
@@ -228,18 +230,32 @@ fn validate_one_path(p: &ProjectPath) -> Result<(), String> {
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
}
check_mount_name_stays_under_workspace(&p.mount_name)?;
if p.host_path.is_empty() {
// Trimmed: a host path of spaces is not a folder, and `classify_mount_source`
// is deliberately silent about a path with nothing in it — this is the
// message that names the mount it belongs to.
if p.host_path.trim().is_empty() {
return Err(format!(
"Folder mounted at '/workspace/{}' has no host path.",
p.mount_name
));
}
if is_filesystem_root(&p.host_path) {
match classify_mount_source(&p.host_path) {
None => {}
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
return Err(filesystem_root_message(
&p.host_path,
&resolved,
"using it as a project folder",
));
}
Some(UnmountableHostPath::NotAbsolute) => {
return Err(format!(
"'{}' is a filesystem root. Choose the project folder itself — mounting the whole drive gives the container everything on it.",
"'{}' is not a full path to a folder — where it lands is decided by wherever \
Triple-C is running from rather than by you. Give the whole path.",
p.host_path
));
}
}
Ok(())
}
@@ -264,6 +280,23 @@ fn check_mount_name_stays_under_workspace(mount_name: &str) -> Result<(), String
mount_name
));
}
// **An empty name is not refused here, and that is a live residual.** It
// makes the target `/workspace/`, which the daemon normalises to
// `/workspace` — so the host folder shadows the directory the other mounts
// land in, and Docker then creates their mount points *inside it*, on the
// host. It is not refused because it cannot be: an empty name is what a
// half-filled row holds, `legacy_rows` shows those are already in
// `projects.json`, and this function runs on grandfathered rows too, so
// refusing it would make every such project unsavable — the exact
// regression [`validate_project_paths_update`] exists to prevent.
//
// Introducing one is closed at both ends: `validate_one_path` refuses an
// empty name on any new or edited row, and `WorkspaceSection` no longer
// sends half-filled or blank ones. What remains is the *stored* row, and it
// belongs where the mount is built — `docker::create_container` should skip
// a row with no mount name or no host path, which is the same filter that
// stops a stored blank row failing the create with
// `field Source must not be empty`.
if !mount_name.is_empty() && mount_name.chars().all(|c| c == '.') {
return Err(format!(
"Mount name '{}' is not a folder name — it names the directory the mount would sit in.",
@@ -363,12 +396,13 @@ fn validate_project_paths_update(
/// `/tmp/.host-ssh` and `/tmp/.host-ca` — and neither had any check at all, so
/// `/` handed the whole host filesystem to the agent to read. Read-only, so
/// this is disclosure rather than the read-write hole a `/` project folder is,
/// but the fix is the same one line.
/// but it is the same check: [`classify_mount_source`], resolved rather than
/// spelled, so `/..` and `/home/..` are refused here too.
///
/// Same grandfathering as the folder list, for the same reason: a value already
/// stored is already mounted on every start, and refusing an unrelated save
/// does not unmount it. Only a *change* is held to the rule.
fn validate_mounted_host_path(
pub(crate) fn validate_mounted_host_path(
label: &str,
stored: Option<&str>,
incoming: Option<&str>,
@@ -379,29 +413,255 @@ fn validate_mounted_host_path(
if stored.map(str::trim) == Some(value) {
return Ok(());
}
if is_filesystem_root(value) {
match classify_mount_source(value) {
None => {}
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
return Err(filesystem_root_message(
value,
&resolved,
&format!("setting it as {}", label),
));
}
Some(UnmountableHostPath::NotAbsolute) => {
return Err(format!(
"'{}' is a filesystem root, so setting it as {} would mount the whole drive into the \
container. Choose the folder itself.",
"'{}' is not a full path, so it cannot be used as {}. Give the whole path.",
value, label
));
}
}
Ok(())
}
/// Whether a host path is the root of a filesystem, in any spelling the three
/// desktop platforms produce: `/`, a Windows drive root, or a bare UNC/share
/// prefix. Trailing separators are ignored, so `C:\\` and `C:/` are the same
/// answer.
fn is_filesystem_root(host_path: &str) -> bool {
let trimmed = host_path.trim_end_matches(['/', '\\']);
if trimmed.is_empty() {
// Nothing but separators: `/`, `\\`, `//`.
return true;
/// Why a host path may not be used as the source of a bind mount.
///
/// Two answers rather than a `bool` because they need different sentences, and
/// because "is a root" is no longer a question about how the path is *spelled*
/// — the refusal has to be able to say where the path actually landed.
#[derive(Debug, PartialEq)]
enum UnmountableHostPath {
/// The path is, or resolves to, the root of a filesystem. `resolved` is
/// what it lands on, which is the same string only when a root was typed
/// outright.
FilesystemRoot { resolved: String },
/// The path does not name a location at all. Where it lands is decided by
/// whatever directory Triple-C happens to be running from, so it can be a
/// root tomorrow and a folder today, and nothing here can judge it.
NotAbsolute,
}
// `C:` — a drive with no path on it.
let bytes = trimmed.as_bytes();
bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
/// Length of a `C:` drive prefix at the head of `path`, or 0.
///
/// Duplicated from `commands::file_commands::drive_prefix_len`, together with
/// [`is_windows_style_path`] and [`normalize_host_path`] below. Those are
/// private to that module and it is not this branch's file to change; if the
/// two copies are ever merged, that one is the original and carries the wider
/// test coverage.
fn drive_prefix_len(path: &str) -> usize {
let b = path.as_bytes();
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
2
} else {
0
}
}
/// Whether `path` is written in Windows form, and so whether `\` separates its
/// components. On Linux a backslash is an ordinary filename character, which is
/// why this is a question rather than an unconditional substitution.
///
/// Copy of `file_commands::is_windows_style_path` — see [`drive_prefix_len`].
fn is_windows_style_path(path: &str) -> bool {
cfg!(windows) || path.starts_with("\\\\") || drive_prefix_len(path) > 0
}
/// `path` with its separators unified and any Win32 verbatim/device prefix
/// removed — the form every rule below is expressed against.
///
/// `\\?\C:\Windows` and `\\?\UNC\server\share` name the *same locations* as
/// `C:\Windows` and `\\server\share`; the prefix only turns off Win32 path
/// parsing. Stripping it is what stops four characters being a bypass — and it
/// has to run on our own output as well, because `std::fs::canonicalize` hands
/// back exactly that spelling on Windows.
///
/// Copy of `file_commands::normalize_host_path` — see [`drive_prefix_len`].
fn normalize_host_path(path: &str) -> String {
let mut s = if is_windows_style_path(path) {
path.replace('\\', "/")
} else {
path.to_string()
};
// Slicing by byte index is safe here only because a prefix matched
// case-insensitively as ASCII is ASCII, so its end is a char boundary.
for prefix in ["//?/unc/", "//./unc/"] {
if s.len() >= prefix.len()
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
{
return format!("//{}", &s[prefix.len()..]);
}
}
for prefix in ["//?/", "//./"] {
if s.len() >= prefix.len()
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
{
s = s[prefix.len()..].to_string();
break;
}
}
s
}
/// A normalised absolute path split into the root it hangs off and the part
/// below it, or `None` when it names no location at all.
///
/// The three roots the desktop platforms have: `/`, a drive (`C:/`), and a UNC
/// share (`//server/share` — the share *is* the root; `//server` alone names a
/// machine and nothing on it).
fn split_host_root(norm: &str) -> Option<(&str, &str)> {
if let Some(rest) = norm.strip_prefix("//") {
let mut parts = rest.splitn(3, '/');
let server = parts.next().unwrap_or("");
let share = parts.next().unwrap_or("");
if server.is_empty() || share.is_empty() {
// `//server`, `//server/`: no share, so nothing under it is named.
return Some((norm, ""));
}
let root_len = 2 + server.len() + 1 + share.len();
return Some((&norm[..root_len], &norm[root_len..]));
}
let drive = drive_prefix_len(norm);
if drive > 0 {
// `C:x` is drive-*relative* — it means "x under the current directory
// on C:", which is a location only the process's own state decides.
return match norm[drive..].strip_prefix('/') {
Some(tail) => Some((&norm[..drive + 1], tail)),
None if norm.len() == drive => Some((norm, "")),
None => None,
};
}
norm.strip_prefix('/').map(|tail| (&norm[..1], tail))
}
/// How many named components deep `tail` ends up, with `.` dropped and `..`
/// applied — clamped at the root, because `/..` is `/` and not an error.
fn depth_below_root(tail: &str) -> usize {
let mut depth = 0usize;
for segment in tail.split('/') {
match segment {
"" | "." => {}
".." => depth = depth.saturating_sub(1),
_ => depth += 1,
}
}
depth
}
/// Whether a host path can be handed to Docker as a bind-mount source, and if
/// not, why.
///
/// ## Resolved, not spelled
///
/// This used to be `is_filesystem_root`, and it was purely lexical: trim the
/// trailing separators, say yes to what was left over only if it was empty or a
/// bare `C:`. Nothing in this file called `canonicalize`, so `/..`, `/./`,
/// `/home/..`, `/etc/../` and `C:\..` all sailed through and were passed
/// verbatim to `docker::create_container`, which builds a
/// `Mount { source, read_only: Some(false) }` out of them. Verified against the
/// daemon: `-v /..:/mnt/probe` mounts the host root. That is the whole host
/// filesystem, read-write, in a container whose agent has passwordless sudo —
/// the same 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.
///
/// So the answer comes from the OS where the OS can give one: `canonicalize`
/// applies `..`, follows every symlink in the path, and on Windows returns the
/// long name for an 8.3 alias and the verbatim spelling of a UNC share — all
/// things a string comparison cannot see.
///
/// ## When the path cannot be resolved
///
/// `canonicalize` fails on a path that does not exist *here*, which is an
/// ordinary state rather than an attack: `projects.json` syncs between machines
/// and names `C:\Users\jo\code` on a box that has never had a `C:`, and a
/// folder can be created after the project is. Refusing outright would make
/// every such project unsavable, which is the exact failure
/// [`validate_project_paths_update`] exists to avoid — so an unresolvable path
/// falls back to the lexical answer, with `.` and `..` collapsed by
/// [`depth_below_root`] rather than ignored.
///
/// That is a weaker guarantee, not a wrong one, and the gap is bounded: what
/// resolution adds over the lexical rule is symlinks and 8.3 aliases, and both
/// of those are properties of a path that *exists* — precisely the case where
/// `canonicalize` answers. What is left is a path that does not exist at save
/// time and is a symlink to the root by the time the container starts, i.e. the
/// user doing it to themselves after being asked.
///
/// Blocking on the filesystem here is deliberate: this runs on a save, once per
/// row, and is a single `realpath` walk.
fn classify_mount_source(host_path: &str) -> Option<UnmountableHostPath> {
let raw = host_path.trim();
if raw.is_empty() {
// Emptiness is somebody else's error message — see
// `validate_one_path`, which names the mount it belongs to.
return None;
}
// Nothing but separators, in either spelling: `/`, `//`, `\`, `\\`. Taken
// first because a lone `\` is a *relative* name on Linux, and reporting a
// Windows root as "not a full path" would be answering a question the user
// did not ask.
if raw.chars().all(|c| c == '/' || c == '\\') {
return Some(UnmountableHostPath::FilesystemRoot {
resolved: raw.to_string(),
});
}
// Absoluteness is judged on what the user typed, **before** resolution.
//
// `canonicalize` resolves a relative path against Triple-C's own working
// directory, so it hands back an absolute path and the `NotAbsolute` branch
// below never fires — it was reachable only when canonicalize *failed*,
// i.e. only for relative paths that happened not to exist. That made the
// verdict depend on where the app was launched from: `.` and `..` were
// accepted from the repo, refused from `/`. The daemon then refuses the
// mount outright (`invalid mount path: '..' mount path must be absolute`),
// so the project saved cleanly and could never start again — the bricking
// mode `project_path_mounts`'s filter exists to prevent, reached through
// the host-path half of the row instead of the mount-name half.
if split_host_root(&normalize_host_path(raw)).is_none() {
return Some(UnmountableHostPath::NotAbsolute);
}
let canonical = std::fs::canonicalize(raw)
.ok()
.map(|p| p.to_string_lossy().into_owned());
let judged = canonical.as_deref().unwrap_or(raw);
let norm = normalize_host_path(judged);
let Some((root, tail)) = split_host_root(&norm) else {
return Some(UnmountableHostPath::NotAbsolute);
};
if depth_below_root(tail) == 0 {
return Some(UnmountableHostPath::FilesystemRoot {
resolved: root.to_string(),
});
}
None
}
/// The refusal for a host path that lands on a filesystem root, naming the
/// resolved location as well as what was typed when those differ. `/..` reads
/// as a folder; `/..` *is* `/`, and a message that only quoted it back would
/// leave the user with nothing to act on.
fn filesystem_root_message(typed: &str, resolved: &str, use_for: &str) -> String {
let where_it_lands = if typed.trim() == resolved {
format!("'{}' is a filesystem root", typed)
} else {
format!("'{}' resolves to '{}', which is a filesystem root", typed, resolved)
};
format!(
"{}, so {} would mount the whole drive into the container. Choose the folder itself.",
where_it_lands, use_for
)
}
#[tauri::command]
@@ -510,7 +770,7 @@ pub async fn update_project(
// Fields this command does not get to write, whoever is calling it.
//
// `container_id` is the one that matters: it is the handle the whole file
// command surface resolves against, `list_sibling_containers` hands the
// command surface resolves against, `list_sibling_containers` used to hand the
// webview the ids of every other container on the daemon, and a project
// save is not the place a container is adopted. It is assigned by
// `start_project_container` through `projects_store::set_container_id` and
@@ -551,6 +811,12 @@ pub async fn update_project(
project.ca_cert_path.as_deref(),
)?;
// Custom env var names had no charset check anywhere, so a key like
// `BASH_FUNC_stat%%` reached the container environment verbatim. Same
// grandfathering as the folder list, for the same reason — see
// [`crate::models::validate_env_vars_update`].
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
project.container_id = stored.container_id;
project.status = stored.status;
project.created_at = stored.created_at;
@@ -1159,6 +1425,161 @@ mod tests {
assert!(validate_project_paths(&[path("C:\\Users\\u\\project", "project")]).is_ok());
}
/// Every spelling of a root that is not *spelled* like one.
///
/// The predicate this replaces trimmed trailing separators and compared
/// what was left, so `/..` — which the daemon mounts as the host root,
/// verified with `docker run -v /..:/mnt/probe` — was indistinguishable
/// from a project folder called `..`. No test in the repo contained a `.`
/// or a `..` in a host path, which is why it shipped.
#[test]
fn a_host_path_that_resolves_to_a_root_is_refused() {
let escapes = [
"/..",
"/../",
"/./",
"/.",
"/home/..",
"/etc/../",
"/tmp/../..",
// Deliberately not present on any machine, so this is the
// unresolvable path taking the lexical route.
"/no-such-dir-here/../..",
"C:\\..",
"C:\\Users\\..",
"c:/foo/..",
// Win32 verbatim spelling of a drive root.
"\\\\?\\C:\\",
"\\\\?\\C:\\..",
// A UNC share root is the root of everything on that share, which
// the old predicate accepted despite its doc comment claiming
// otherwise.
"\\\\server\\share",
"//server/share/",
];
for escape in escapes {
assert!(
validate_project_paths(&[path(escape, "everything")]).is_err(),
"host path '{}' was accepted as a project folder, which bind-mounts a whole \
filesystem read-write into a container with passwordless sudo",
escape
);
// The same value must not be reachable through the editor either.
assert!(
validate_project_paths_update(&[], &[path(escape, "everything")]).is_err(),
"host path '{}' was accepted through update_project",
escape
);
// And the two read-only mounts are the same check.
assert!(
validate_mounted_host_path("the SSH key folder", None, Some(escape)).is_err(),
"'{}' was accepted as an SSH key path, which read-only bind-mounts a whole \
filesystem at /tmp/.host-ssh",
escape
);
}
}
/// A drive-relative path (`C:x`, no separator) means "x under whatever the
/// current directory on C: happens to be" — a location decided by the
/// process rather than by the user, so it may be the drive root.
#[test]
fn a_relative_path_is_refused_however_it_resolves_from_here() {
// The previous test for this passed by coincidence: its four examples
// did not exist under `app/src-tauri`, so `canonicalize` failed and the
// `NotAbsolute` branch fired for the wrong reason. Creating a directory
// named `project` there flipped it red.
//
// These are paths that *do* exist relative to wherever the test runs,
// so they exercise the branch that used to be unreachable. Judged on
// the typed string, the answer is the same from any working directory —
// which is the property that matters, because the daemon refuses a
// relative mount source and the project would save fine and then never
// start.
for existing in [".", "..", "src", "./src"] {
assert!(
matches!(
classify_mount_source(existing),
Some(UnmountableHostPath::NotAbsolute)
),
"{} is relative and must be refused regardless of cwd",
existing
);
}
// And the fix must not have made an absolute path unreachable.
assert!(
classify_mount_source("/usr").is_none(),
"an ordinary absolute folder must still be accepted"
);
}
#[test]
fn a_path_that_names_no_location_is_refused_rather_than_guessed_at() {
for relative in ["C:x", "C:Users\\jo", "relative/path", "./project"] {
assert!(
validate_project_paths(&[path(relative, "project")]).is_err(),
"'{}' was accepted, though where it lands depends on Triple-C's own \
working directory",
relative
);
}
}
/// The dots that are *not* an escape have to keep working — a folder can
/// legitimately be reached through `.` or a `..` that goes back down again,
/// and the Browse button produces paths on machines this list is not
/// running on.
#[test]
fn an_ordinary_folder_is_still_accepted_however_it_is_spelled() {
for ok in [
"/home/u/./project",
"/home/u/x/../project",
"/home/u/..project",
"/home/u/project/..hidden",
"C:\\Users\\u\\x\\..\\project",
"\\\\server\\share\\project",
"\\\\?\\C:\\Users\\u\\project",
] {
assert!(
validate_project_paths(&[path(ok, "project")]).is_ok(),
"host path '{}' should be usable",
ok
);
}
}
/// The half of this that only resolution can answer.
///
/// A lexical check sees a two-component path under `/tmp` and stops. The
/// container is what plants the link — `/proc/self/mountinfo` inside a
/// Triple-C container spells the host's project paths out verbatim — so the
/// symlink is reachable, and the mount that follows it is read-write.
#[cfg(unix)]
#[test]
fn a_symlink_to_the_root_is_refused_because_resolution_is_what_answers() {
let dir = std::env::temp_dir().join(format!(
"triple-c-root-link-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let link = dir.join("innocent");
std::os::unix::fs::symlink("/", &link).unwrap();
let verdict = validate_project_paths(&[path(&link.to_string_lossy(), "project")]);
std::fs::remove_file(&link).ok();
std::fs::remove_dir(&dir).ok();
assert!(
verdict.is_err(),
"a symlink to / was accepted as a project folder; only canonicalisation can see it"
);
}
#[test]
fn duplicate_and_half_filled_rows_are_refused_but_the_blank_row_is_not() {
assert!(validate_project_paths(&[
@@ -16,6 +16,38 @@ pub async fn update_settings(
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let before = state.settings_store.get();
// The global half of the same rule the project half gets in
// `update_project`: a global custom env var is merged into every project's
// container environment, so an unchecked name here reaches all of them.
crate::models::validate_env_vars_update(
&before.global_custom_env_vars,
&settings.global_custom_env_vars,
)?;
// The same for the two host paths this struct owns. `update_project`
// validated its per-project overrides and this side validated nothing,
// which left the wider hole of the two: `default_ssh_key_path` is the
// fallback for **every** project without an override
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
// home volume this release exists to bound.
//
// Grandfathered the same way project paths are: a value carried over
// unchanged still saves, so a store written before this check cannot lock
// the user out of their own settings.
crate::commands::project_commands::validate_mounted_host_path(
"SSH key path",
before.default_ssh_key_path.as_deref(),
settings.default_ssh_key_path.as_deref(),
)?;
crate::commands::project_commands::validate_mounted_host_path(
"CA certificate path",
before.ca_cert_path.as_deref(),
settings.ca_cert_path.as_deref(),
)?;
let saved = state.settings_store.update(settings)?;
// Persisting a setting is not the same as applying it. The gateway is the
+31 -18
View File
@@ -197,18 +197,17 @@ pub async fn upload_host_file_to_terminal(
state: State<'_, AppState>,
) -> Result<String, String> {
// The drop target is a host path chosen by the webview, not by the OS drag
// itself, so it gets the same host-read policy as the Files pane's upload:
// absolute, no traversal, and nothing out of a hidden directory
// (`~/.ssh`, `~/.aws`) or a system location — applied to the path with its
// symlinks already resolved, so a visible directory that *leads* to `~/.ssh`
// is refused too. What comes back is that resolved path, and it is what
// gets opened.
// itself, so it goes through `file_commands`' host-read policy: absolute,
// no traversal, and nothing whose path passes through a hidden directory
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
// the path with its symlinks already resolved, so a visible directory that
// *leads* to one of those is refused too. What comes back is that resolved
// path, and it is what gets opened. This is now one of only two commands
// that touch a host path at all; the other is `download_container_backup`.
// The name is taken from the path the user actually dropped, *before*
// resolution. Deriving it from the resolved path renames the file behind
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
// a symlink, would land it in the container as `2026-08-23.log`. The Files
// pane's upload had the same bug and fixes it the same way — one helper, so
// the two drop targets cannot drift.
// a symlink, would land it in the container as `2026-08-23.log`.
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
@@ -217,8 +216,23 @@ pub async fn upload_host_file_to_terminal(
let meta = tokio::fs::metadata(&host_path)
.await
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
if meta.is_dir() {
return Err(format!("{} is a directory — drop individual files", host_path));
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
// regular file, reports `len() == 0`, and passes both the directory check
// and the size cap below — and `std::fs::File::open` on one blocks forever
// with no writer, with no timeout anywhere on this path. The upload then
// never returns, the toast sticks on "Adding N files…" for the session and
// the rest of the batch is abandoned. Sockets and device nodes are the same
// shape. With the Files tab's upload removed, this is the only route for
// getting a file into a container, so it is the wrong place to be clever.
if !meta.is_file() {
return Err(if meta.is_dir() {
format!("{} is a directory — drop individual files", host_path)
} else {
format!(
"{} is not a regular file — only ordinary files can be dropped into a terminal",
host_path
)
});
}
// Guard against ballooning host RAM: the file is packed into an in-memory
@@ -229,7 +243,7 @@ pub async fn upload_host_file_to_terminal(
use crate::docker::exec::MAX_DROP_BYTES;
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
meta.len() as f64 / (1024.0 * 1024.0),
MAX_DROP_BYTES / (1024 * 1024)
));
@@ -301,19 +315,18 @@ pub async fn stop_audio_bridge(
#[cfg(test)]
mod tests {
/// Both drop targets must name a dropped file the way the *user* named it.
/// A dropped file must be named the way the *user* named it.
///
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
/// name from the path *after* symlink resolution, so dropping
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
/// `2026-08-23.log` — silently landed the file in the container under the
/// target's name. Nothing errored; the user just got a name they never
/// typed. The Files pane had the identical bug.
/// typed.
///
/// What actually keeps the two from drifting is that they now call one
/// helper, so this asserts that helper's contract from the terminal side:
/// the answer comes from the spelling, and a path that does not name a file
/// is refused rather than silently substituted (it used to fall back to
/// This asserts the shared helper's contract from the terminal side: the
/// answer comes from the spelling, and a path that does not name a file is
/// refused rather than silently substituted (it used to fall back to
/// `"dropped-file"`).
#[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() {
File diff suppressed because it is too large Load Diff
+2 -40
View File
@@ -351,8 +351,8 @@ pub async fn upload_host_file_to_container(
// The caller resolved this path (`resolve_host_read_path`); opening it
// is a second trip through the same directories, so the descriptor is
// checked against the path that was validated before its bytes are
// packed into anything. Same policy as the Files pane's upload — this
// is the terminal's drop target, and the two must not differ.
// packed into anything. This is the terminal's drop target, and it is
// the only path by which host bytes enter a container.
let file = std::fs::File::open(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
crate::commands::file_commands::verify_opened_path(
@@ -601,44 +601,6 @@ pub async fn exec_oneshot_as(
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
}
/// [`exec_oneshot_as`] with a wall-clock ceiling on the whole call.
///
/// H8. Nothing in this module bounds how long a container command may take,
/// which is right for the callers that need it — a base-image migration replays
/// `apt-get` and takes minutes — and wrong for a short command that can be made
/// to block forever by a *file* the caller does not control. The upload
/// reservation is the one that bit: a shell redirect onto a FIFO blocks in
/// `open(2)` until a reader appears, so a single `mkfifo` in a project
/// directory left the Files pane on "Uploading…" for the rest of the session
/// with the rest of the batch abandoned.
///
/// So the ceiling is opt-in per call site rather than global. Note what it can
/// and cannot do: dropping the future closes our end of the stream, but Docker
/// has no "kill an exec" API, so a process that is genuinely wedged stays
/// wedged in the container's process table. That is why the primitive matters
/// more than the timeout — this turns "the app never comes back" into "that
/// upload failed", and it is the caller's job not to run something that blocks.
pub async fn exec_oneshot_as_within(
container_id: &str,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
limit: std::time::Duration,
) -> Result<(String, i64), String> {
match tokio::time::timeout(
limit,
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT),
)
.await
{
Ok(result) => result,
Err(_) => Err(format!(
"The container did not answer within {}s — the command may still be running inside it.",
limit.as_secs()
)),
}
}
/// What a one-shot exec printed, with the two streams still tellable apart.
///
/// `combined` is stdout and stderr interleaved in arrival order — the shape
+33
View File
@@ -369,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
let mut out: Vec<String> = paths
.iter()
// **The same filter `project_path_mounts` applies, and it has to be.**
// That function skips a row with an empty `host_path` or `mount_name`
// so a legacy row cannot brick the create. The consequence is that
// `/workspace/<name>` for such a row is *not* a bind mount — it is
// ordinary writable-layer content. Excluding it here would tell
// `compute_verbatim_paths` to skip staging it, and the container swap
// would then destroy whatever the user has put there. The two
// predicates must agree or a migration silently eats a directory.
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
.map(|p| format!("/workspace/{}", p.mount_name))
.collect();
out.sort();
@@ -1368,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
#[cfg(test)]
mod tests {
/// The mount filter and the migration's exclusion list must agree.
///
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
/// writable-layer content rather than a bind mount — and if this function
/// still excluded it, `compute_verbatim_paths` would skip staging it and
/// the container swap would destroy whatever is there. A migration eating a
/// directory is the quietest kind of data loss there is.
#[test]
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
let paths = vec![
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
// Legacy shapes that `project_path_mounts` skips.
ProjectPath { host_path: "".into(), mount_name: "data".into() },
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
];
let excluded = bind_mount_exclusions(&paths);
assert_eq!(
excluded,
vec!["/workspace/code".to_string()],
"only rows that are actually mounted may be excluded from staging"
);
}
use super::*;
use crate::models::{
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
+177 -3
View File
@@ -435,7 +435,6 @@ pub fn run() {
commands::docker_commands::check_image_exists,
commands::docker_commands::build_image,
commands::docker_commands::get_container_info,
commands::docker_commands::list_sibling_containers,
// Projects
commands::project_commands::list_projects,
commands::project_commands::add_project,
@@ -497,9 +496,7 @@ pub fn run() {
commands::terminal_commands::stop_audio_bridge,
// Files
commands::file_commands::list_container_files,
commands::file_commands::download_container_file,
commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container,
commands::file_commands::read_container_file,
commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory,
@@ -689,6 +686,183 @@ mod tests {
/// pulls in `core:image:default` → `allow-from-path`, which is an
/// unconditional `std::fs::read` of any host path with no scope check, and
/// nothing in the frontend has ever imported `@tauri-apps/api/image`.
/// Every `#[tauri::command]` is registered, and every registration names a
/// command that exists.
///
/// This is the shape of the bug that caused the original OAuth-callback
/// complaint: `set_auth_bridge_enabled` existed, worked, and had a typed
/// frontend wrapper — with **zero call sites**. The switch the docs told
/// users to flip was never wired to anything, so the bridge stayed off and
/// every login callback was refused. Nothing failed; the feature was simply
/// absent, and no test noticed because both halves compiled.
///
/// The reverse direction matters too, and for a sharper reason: a command
/// that is registered but reachable from nowhere is still IPC surface a
/// compromised webview can call. `list_sibling_containers` — which returned
/// every container on the daemon, including the user's unrelated work —
/// sat in exactly that state, and this test is what found it. It has since
/// been removed at all four levels: registration, command, docker helper,
/// and the frontend wrapper and type.
///
/// So this asserts the two lists agree, and leaves *deciding* what belongs
/// on them to a human. It cannot see frontend call sites; `tsc` and the
/// vitest suite cover that side.
#[test]
fn every_command_is_registered_exactly_once() {
use std::collections::BTreeSet;
let mut defined: BTreeSet<String> = BTreeSet::new();
// Walk the source tree for the command attribute and take the `fn` name
// that follows.
//
// The first version of this matched `line.trim() == "#[tauri::command]"`
// exactly and broke on the first non-`#` line. An audit got five real,
// compiling, unregistered commands past it — `#[tauri::command(async)]`,
// `#[tauri::command(rename_all = "snake_case")]`, a trailing comment,
// spaces in the path, and a bare `#[command]` after `use tauri::command`
// — plus `pub(crate) fn` and a `///` line between attribute and `fn`.
// Every one of those is a command the frontend could not call, which is
// the bug this test exists for, and the test stayed green.
//
// The asymmetry matters: confusion on the *definition* side is a silent
// pass, while on the *registration* side it fails loudly against
// legitimate code — and rustc already covers that direction. So this
// errs toward over-matching definitions.
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
let lines: Vec<&str> = text.lines().collect();
for (i, line) in lines.iter().enumerate() {
let t = line.trim();
// `#[tauri::command]`, `#[tauri::command(async)]`,
// `#[tauri :: command]`, a bare `#[command]` under
// `use tauri::command`, and any of those with a
// trailing comment.
let attr = t.strip_prefix("#[").map(|a| {
a.split(']').next().unwrap_or("").replace(' ', "")
});
let is_command_attr = attr.is_some_and(|a| {
a == "command" || a == "tauri::command"
|| a.starts_with("command(")
|| a.starts_with("tauri::command(")
});
if !is_command_attr {
continue;
}
// Skip further attributes and doc comments rather than
// giving up at the first line that is not an attribute.
for next in lines.iter().skip(i + 1) {
let t = next.trim();
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
continue;
}
// Any visibility, then `fn` or `async fn`.
let after_vis = t
.strip_prefix("pub(crate) ")
.or_else(|| t.strip_prefix("pub(super) "))
.or_else(|| t.strip_prefix("pub(in crate) "))
.or_else(|| t.strip_prefix("pub "))
.unwrap_or(t);
let after_async =
after_vis.strip_prefix("async ").unwrap_or(after_vis);
if let Some(rest) = after_async.strip_prefix("fn ") {
if let Some(name) = rest.split(['(', '<']).next() {
out.insert(name.trim().to_string());
}
}
break;
}
}
}
}
}
collect(
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
&mut defined,
);
// The registration list, read from this file rather than from a macro
// expansion so the test does not depend on `generate_handler!`'s shape.
let this = include_str!("lib.rs");
let handler = this
.split_once("generate_handler![")
.and_then(|(_, rest)| rest.split_once("])"))
.map(|(inside, _)| inside)
.expect("lib.rs should contain a generate_handler! list");
// Line-based, not `split(',')`: the list is grouped under `// Docker`
// style comments, and splitting on commas glues each comment to the
// command that follows it. A `starts_with("//")` filter then drops that
// command — silently, and once per group.
let registered: BTreeSet<String> = handler
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
.filter_map(|l| {
l.trim_end_matches(',')
.rsplit("::")
.next()
.map(|n| n.trim().to_string())
})
.filter(|n| !n.is_empty())
.collect();
assert!(
!defined.is_empty() && !registered.is_empty(),
"the scan found nothing — it has stopped testing anything (defined={}, registered={})",
defined.len(),
registered.len()
);
let unregistered: Vec<&String> = defined.difference(&registered).collect();
assert!(
unregistered.is_empty(),
"these commands exist but are not registered, so the frontend cannot call them: {:?}",
unregistered
);
let undefined: Vec<&String> = registered.difference(&defined).collect();
assert!(
undefined.is_empty(),
"these are registered but no `#[tauri::command]` defines them: {:?}",
undefined
);
// "exactly once" was in this test's name and not in its body: both
// sides were sets, so registering the same command twice in a
// hand-maintained 118-line list compiled, warned about nothing, and
// passed here.
let mut seen: Vec<&str> = Vec::new();
let mut duplicated: Vec<&str> = Vec::new();
for line in handler
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
{
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
let name = name.trim();
if name.is_empty() {
continue;
}
if seen.contains(&name) {
duplicated.push(name);
} else {
seen.push(name);
}
}
}
assert!(
duplicated.is_empty(),
"these are registered more than once: {:?}",
duplicated
);
}
#[test]
fn the_capability_grants_are_the_ones_that_were_reviewed() {
let raw = include_str!("../capabilities/default.json");
+353 -8
View File
@@ -8,6 +8,100 @@ pub struct EnvVar {
pub value: String,
}
/// Whether `key` is a name a shell will read back as an ordinary variable:
/// `[A-Za-z_][A-Za-z0-9_]*`.
///
/// ## Why a charset rule, and not just the reserved-name list
///
/// `docker::container::is_reserved_env_key` answers a different question — "is
/// this one of the names Triple-C manages itself" — and nothing anywhere asked
/// what the *characters* were. A key is joined into `KEY=VALUE` and handed to
/// the daemon, which puts it in the container's environment verbatim, so a name
/// that is not an identifier travels through unchallenged.
///
/// The one that matters is `BASH_FUNC_name%%`, bash's wire format for an
/// exported shell function: bash imports those at startup and the *body* is the
/// value. Today that is latent rather than live — the image's `/bin/sh` is
/// dash, which does not import them, and an auditor confirmed the vector fires
/// under `bash -c` and not under `sh -c` in the shipped image. But the
/// pre-commit scrub runs `/bin/sh -c` **as root**, `/bin/sh` is whatever
/// `ubuntu:24.04` points it at, and nothing pins that. One base-image change,
/// or one call site spelled `bash`, turns a stored project setting into root
/// code execution inside the container at commit time.
///
/// So the rule is the shape of the thing rather than a list of the names that
/// are known to be dangerous: `IFS`, `LD_PRELOAD` and `PATH` are all perfectly
/// good identifiers and are the user's business, while nothing legitimate needs
/// a `%`, a `(` or a space in an environment variable name.
///
/// The key is judged **trimmed**, because that is what `create_container` sends
/// — ` FOO ` already reaches the container as `FOO`, and refusing it here would
/// break a setting that works.
pub fn is_valid_env_key(key: &str) -> bool {
let mut chars = key.trim().chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// Validate a custom environment variable list that is about to be stored,
/// admitting the entries it is already stored with.
///
/// Same shape, and the same reasoning, as
/// `commands::project_commands::validate_project_paths_update`: nothing ever
/// checked these keys, so `projects.json` and `settings.json` in the field can
/// hold whatever was typed. Holding every save to the new rule would make such
/// a project unsavable *entirely* — `update_project` is the single command
/// behind the whole Config tab — and would buy nothing, because the stored key
/// is already being handed to every container that starts. An entry carried
/// over verbatim is admitted; a new or edited one is held to the rule, which is
/// what keeps the escalation closed, since escalation means *introducing* a bad
/// key through this command.
///
/// Counted rather than set-tested, for the same reason as the folder rows: a
/// second copy of an existing entry is a new entry.
///
/// The blank entry is not a violation. "+ Add variable" appends
/// `{key: "", value: ""}` and saves the list immediately, so refusing it would
/// turn the button itself into an error toast; `create_container` skips an
/// empty key, so it reaches nothing.
pub fn validate_env_vars_update(stored: &[EnvVar], incoming: &[EnvVar]) -> Result<(), String> {
// An entry with no key is the placeholder, whatever is in its value:
// `create_container` skips it, so it reaches nothing and there is nothing
// to refuse. The editor saves on every blur, and typing the value before
// the name is an ordinary way to fill a row in.
let is_blank = |v: &EnvVar| v.key.trim().is_empty();
let mut carried: std::collections::HashMap<(&str, &str), usize> =
std::collections::HashMap::new();
for v in stored.iter().filter(|v| !is_blank(v)) {
*carried
.entry((v.key.as_str(), v.value.as_str()))
.or_insert(0) += 1;
}
for v in incoming.iter().filter(|v| !is_blank(v)) {
match carried.get_mut(&(v.key.as_str(), v.value.as_str())) {
Some(remaining) if *remaining > 0 => {
*remaining -= 1;
}
_ => {
if !is_valid_env_key(&v.key) {
return Err(format!(
"'{}' is not a usable environment variable name. Use a letter or \
underscore followed by letters, digits or underscores.",
v.key
));
}
}
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectPath {
pub host_path: String,
@@ -85,6 +179,7 @@ impl PermissionMode {
/// Settings for Claude Code CLI behavior inside the container.
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(from = "StoredClaudeCodeSettings")]
/// Every field is three-state, and the third state is load-bearing.
///
/// `None` means "not set at this level". For a *project* that is "inherit
@@ -98,24 +193,24 @@ pub struct ClaudeCodeSettings {
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
/// one. All three are distinct — "let it choose" is not "classic".
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tui_mode: Option<String>,
/// Saved `/effort` level: `None` = unset, otherwise one of
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
/// zero value of this field has to mean "leave it on".
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_scroll_disabled: Option<bool>,
/// Collapse tool output to one-line summaries. Written to settings.json as
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focus_mode: Option<bool>,
/// Show thinking summaries in responses
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show_thinking_summaries: Option<bool>,
/// Turn the session recap **off**.
///
@@ -128,16 +223,99 @@ pub struct ClaudeCodeSettings {
/// never touched the control holds — as "the user turned the recap off" and
/// silently disabled it for all of them. A new name lets the old key be
/// ignored, which lands every existing project on the correct default.
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_recap_disabled: Option<bool>,
/// Strip credentials from subprocess environments
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_scrub: Option<bool>,
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_caching_1h: Option<bool>,
}
/// `ClaudeCodeSettings` in every shape `projects.json` and `settings.json` can
/// be holding, which is what [`ClaudeCodeSettings`] is actually deserialised
/// through.
///
/// ## The upgrade this exists to survive
///
/// Before the widening, the five booleans were plain `bool`s with
/// `#[serde(default)]` and no `skip_serializing_if`, so **every** settings
/// object ever written carries an explicit `"env_scrub": false` — not because
/// anyone chose it, but because that is what a `bool` serialises to. Under the
/// old merge (`if p.x { true } else { g.x }`) that `false` carried no
/// information at all: it was the only value an unset switch could produce, and
/// the global always won.
///
/// Read as `Some(false)` by the new code it becomes a *deliberate off* that
/// beats a global `Some(true)` — so upgrading silently turned five settings off
/// for every project that had ever opened this editor, `env_scrub` ("strip
/// credentials from subprocess environments") among them. There is no store
/// migration anywhere: `projects_store` parses these structs directly.
///
/// ## How an old record is told apart from a new one
///
/// By `enable_session_recap`. It was in the struct from the day it existed and
/// was a plain `bool`, so its key is present in every pre-widening record and
/// in no other — the field was *renamed* to `session_recap_disabled` precisely
/// so the old key could be ignored (see the doc on that field), and the new
/// code has never written it. Its presence is therefore an exact statement that
/// these bytes were written by a binary in which `false` meant "unset", and the
/// booleans are read back that way: `true` is a real choice and survives,
/// `false` becomes `None` and inherits again.
///
/// Nothing marks a *new* record, and nothing needs to: absent is `None` (the
/// fields skip serialising when unset) and a present `false` is the deliberate
/// off the widening was for. That is also what keeps a downgrade survivable —
/// an older binary reads an absent key as `false` through its own
/// `#[serde(default)]`, where a `null` would fail to parse and take the whole
/// of `projects.json` down with it, since `ProjectsStore` parses all-or-nothing
/// and starts empty on an error.
#[derive(Deserialize)]
struct StoredClaudeCodeSettings {
#[serde(default)]
tui_mode: Option<String>,
#[serde(default)]
effort: Option<String>,
#[serde(default)]
auto_scroll_disabled: Option<bool>,
#[serde(default)]
focus_mode: Option<bool>,
#[serde(default)]
show_thinking_summaries: Option<bool>,
#[serde(default)]
session_recap_disabled: Option<bool>,
#[serde(default)]
env_scrub: Option<bool>,
#[serde(default)]
prompt_caching_1h: Option<bool>,
/// The pre-widening spelling of `session_recap_disabled`, and the *only*
/// use of its value: presence dates the record. Its meaning was inverted
/// and it never worked, so it is read for the marker and discarded.
#[serde(default)]
enable_session_recap: Option<bool>,
}
impl From<StoredClaudeCodeSettings> for ClaudeCodeSettings {
fn from(stored: StoredClaudeCodeSettings) -> Self {
let pre_widening = stored.enable_session_recap.is_some();
// On a pre-widening record `false` is what an untouched switch wrote,
// so it means "not set at this level" and must inherit. A `true` was a
// real choice either way.
let read = |v: Option<bool>| if pre_widening { v.filter(|on| *on) } else { v };
ClaudeCodeSettings {
tui_mode: stored.tui_mode,
effort: stored.effort,
auto_scroll_disabled: read(stored.auto_scroll_disabled),
focus_mode: read(stored.focus_mode),
show_thinking_summaries: read(stored.show_thinking_summaries),
session_recap_disabled: read(stored.session_recap_disabled),
env_scrub: read(stored.env_scrub),
prompt_caching_1h: read(stored.prompt_caching_1h),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
@@ -467,3 +645,170 @@ impl Project {
val
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── Custom environment variable names ─────────────────────────────────
#[test]
fn an_env_var_name_has_to_be_a_shell_identifier() {
for ok in ["PATH", "_", "_x", "MY_VAR2", "a", " SPACED_BY_THE_EDITOR "] {
assert!(is_valid_env_key(ok), "'{}' should be a usable name", ok);
}
for bad in [
// bash's wire format for an exported shell function: the value is
// the body, and a `bash` that imports it runs it. The scrub exec is
// `/bin/sh -c` as root, and nothing pins `/bin/sh` to dash.
"BASH_FUNC_stat%%",
"BASH_FUNC_ls()",
"MY VAR",
"2FAST",
"WITH-DASH",
"WITH.DOT",
"",
" ",
"$(id)",
"A=B",
] {
assert!(!is_valid_env_key(bad), "'{}' should be refused", bad);
}
}
fn env(key: &str, value: &str) -> EnvVar {
EnvVar { key: key.to_string(), value: value.to_string() }
}
#[test]
fn a_bad_env_var_name_cannot_be_introduced_but_a_stored_one_does_not_brick_the_editor() {
let bad = [env("BASH_FUNC_stat%%", "() { id; }")];
// Introducing it through the Config tab is the escalation.
assert!(validate_env_vars_update(&[], &bad).is_err());
// Already stored: it is handed to every container that starts whether
// or not an unrelated save is allowed through, and refusing the save
// would make every toggle on the Config tab fail.
assert!(validate_env_vars_update(&bad, &bad).is_ok());
// Editing its value is a new entry, and refused again.
assert!(
validate_env_vars_update(&bad, &[env("BASH_FUNC_stat%%", "() { rm -rf /; }")]).is_err()
);
// Fixing the name is what the message asks for, and it saves.
assert!(validate_env_vars_update(&bad, &[env("STAT", "() { id; }")]).is_ok());
// Dropping it entirely is always fine.
assert!(validate_env_vars_update(&bad, &[]).is_ok());
}
#[test]
fn the_blank_row_the_add_button_saves_is_not_an_error() {
// "+ Add variable" appends an empty entry and saves the list at once,
// so this is the button, not an attempt at anything.
assert!(validate_env_vars_update(&[], &[env("", "")]).is_ok());
// Typing the value before the name is an ordinary way to fill it in,
// and an entry with no name reaches no container either way.
assert!(validate_env_vars_update(&[], &[env("", "value-first")]).is_ok());
assert!(validate_env_vars_update(&[], &[env("GOOD", "v"), env("", "")]).is_ok());
}
#[test]
fn a_stored_entry_may_be_kept_but_not_multiplied() {
let stored = [env("BAD NAME", "v")];
assert!(validate_env_vars_update(&stored, &stored).is_ok());
// A second copy is a new entry, and held to the rule.
assert!(
validate_env_vars_update(&stored, &[env("BAD NAME", "v"), env("BAD NAME", "v")])
.is_err()
);
}
// ── Claude Code settings written before the fields were widened ───────
/// `projects.json` exactly as the shipped `main` binary wrote it: the five
/// booleans were plain `bool`s that always serialised, so every project
/// that ever opened the editor carries `false` for the ones it never
/// touched.
const MAIN_SHAPE_PROJECT: &str = r#"{
"id": "p1",
"name": "demo",
"paths": [{ "host_path": "/home/u/demo", "mount_name": "demo" }],
"container_id": null,
"status": "stopped",
"backend": "anthropic",
"bedrock_config": null,
"ollama_config": null,
"openai_compatible_config": null,
"allow_docker_access": false,
"ssh_key_path": null,
"git_user_name": null,
"git_user_email": null,
"claude_code_settings": {
"tui_mode": "fullscreen",
"effort": null,
"auto_scroll_disabled": false,
"focus_mode": false,
"show_thinking_summaries": false,
"enable_session_recap": false,
"env_scrub": false,
"prompt_caching_1h": false
},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}"#;
#[test]
fn a_setting_stored_as_false_by_the_old_binary_still_inherits_the_global() {
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
let stored = project.claude_code_settings.expect("settings should parse");
// Read verbatim these would be `Some(false)`, which under
// `docker::container::merge_claude_code_settings` beats the global.
assert_eq!(stored.env_scrub, None);
assert_eq!(stored.auto_scroll_disabled, None);
assert_eq!(stored.focus_mode, None);
assert_eq!(stored.show_thinking_summaries, None);
assert_eq!(stored.prompt_caching_1h, None);
assert_eq!(stored.session_recap_disabled, None);
// A value the user did choose is untouched.
assert_eq!(stored.tui_mode.as_deref(), Some("fullscreen"));
// The merge rule itself, spelled the way
// `merge_claude_code_settings` spells it. `main` resolved this with
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
// and it has to go on winning, because the user never turned this off.
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
assert_eq!(
stored.env_scrub.or(global.env_scrub),
Some(true),
"upgrading silently turned off 'strip credentials from subprocess environments'"
);
}
#[test]
fn an_off_chosen_in_the_new_editor_still_beats_a_global_on() {
// Same record without the pre-widening key: this `false` is the
// deliberate off the widening exists to make expressible.
let json = r#"{ "env_scrub": false }"#;
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
assert_eq!(chosen.env_scrub, Some(false));
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
}
#[test]
fn an_unset_setting_is_written_as_absent_rather_than_null() {
// A downgrade parses these fields as plain `bool` with
// `#[serde(default)]`: an absent key is `false`, a `null` is a parse
// error — and `ProjectsStore` parses all-or-nothing, so one project
// with one null empties the whole list and the next save persists that.
let json = serde_json::to_string(&ClaudeCodeSettings::default()).unwrap();
assert_eq!(json, "{}");
assert!(!json.contains("null"));
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
let json = serde_json::to_string(&partial).unwrap();
assert_eq!(json, r#"{"env_scrub":false}"#);
// And it reads back as what it is.
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
assert_eq!(round_tripped, partial);
}
}
+49 -5
View File
@@ -431,6 +431,18 @@
const mobileInput = document.getElementById('mobileInput');
const btnEnter = document.getElementById('btnEnter');
const btnNewline = document.getElementById('btnNewline');
// Whether the *active* session understands ESC+CR as "insert a newline".
//
// Only Claude Code does. `bash -l` has no readline binding for `\e\r`, so
// sending it there is a silent no-op — which is worse from the mobile bar
// than from a hardware key, because the bar puts a dedicated button on
// screen that appears to do nothing. The xterm key handler is already scoped
// this way; these two paths were not.
function activeSessionTakesEscCr() {
const s = activeSessionId && sessions[activeSessionId];
return !!s && s.type === 'claude';
}
const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC');
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
@@ -590,7 +602,7 @@
updateProjectList(msg.projects);
break;
case 'opened':
onSessionOpened(msg.session_id, msg.project_name);
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
break;
case 'output':
onSessionOutput(msg.session_id, msg.data);
@@ -641,8 +653,18 @@
});
}
function onSessionOpened(sessionId, projectName) {
const sessionType = pendingSessionType || 'claude';
function onSessionOpened(sessionId, projectName, serverSessionType) {
// Prefer the type the *server* reports for this session. The old path read
// a single `pendingSessionType` global set at request time, so opening two
// sessions before the first reply landed swapped their labels — routine on
// mobile, where nothing disables the buttons. That was cosmetic until
// Shift+Enter became type-dependent: a Claude session labelled `shell`
// sends a bare CR and submits a half-written prompt.
//
// The fallback keeps an older server working, and defaults to `claude`,
// which is the safe direction — ESC+CR is an unbound no-op in bash, while
// a bare CR in Claude Code loses the prompt.
const sessionType = serverSessionType || pendingSessionType || 'claude';
pendingSessionType = null;
// Create terminal
@@ -791,6 +813,7 @@
switchToSession(remaining[remaining.length - 1]);
} else {
activeSessionId = null;
syncNewlineButton();
emptyState.style.display = '';
}
}
@@ -817,6 +840,7 @@
function switchToSession(sessionId) {
activeSessionId = sessionId;
syncNewlineButton();
// Update tab styles
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -896,7 +920,7 @@
// reasoning, as the terminal's own key handler above. A hardware
// keyboard on a tablet is the only way to reach this; the phone case is
// the dedicated newline button beside Enter.
sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r');
sendTerminalInput(e.shiftKey && activeSessionTakesEscCr() ? '\x1b\r' : '\r');
} else if (e.key === 'Tab') {
e.preventDefault();
sendTerminalInput('\t');
@@ -904,7 +928,27 @@
});
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); };
btnNewline.onclick = () => {
if (!activeSessionTakesEscCr()) { mobileInput.focus(); return; }
sendTerminalInput('\x1b\r');
mobileInput.focus();
};
// Keep the button's affordance honest: on a shell tab there is no byte that
// means "newline without running the line", so the control is disabled
// rather than left looking live.
function syncNewlineButton() {
const usable = activeSessionTakesEscCr();
btnNewline.disabled = !usable;
btnNewline.title = usable
? 'Insert a newline without submitting (Shift+Enter)'
: 'Only Claude sessions support this — a shell runs the line instead';
}
// With no session open yet, `activeSessionTakesEscCr()` is already false —
// but nothing had called this, so the button rendered live before the first
// tab existed.
syncNewlineButton();
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
@@ -46,6 +46,16 @@ enum ServerMessage {
Opened {
session_id: String,
project_name: String,
/// Echoed back so the client can label the session from the reply
/// rather than from a global set at request time.
///
/// Without it the client correlates through a single
/// `pendingSessionType`, so opening two sessions before the first
/// reply lands swaps their labels. That used to be cosmetic; it stopped
/// being cosmetic when Shift+Enter became type-dependent, because a
/// Claude session mislabelled as a shell now submits a half-written
/// prompt instead of inserting a newline.
session_type: String,
},
Output {
session_id: String,
@@ -319,6 +329,11 @@ async fn handle_open(
let _ = out_tx.send(ServerMessage::Opened {
session_id,
project_name,
// Derived from the same match that chose `cmd` above, not echoed from
// the request: anything that is not exactly "bash" runs Claude, so
// echoing the raw value would label an unrecognised string as its own
// type and put the client back where it started.
session_type: if session_type == Some("bash") { "bash" } else { "claude" }.to_string(),
});
Ok(())
@@ -60,12 +60,18 @@ describe("ClaudeCodeSettingsEditor", () => {
});
it("offers every effort level Claude Code accepts", () => {
// Verified against the shipped `claude` binary's own schema rather than
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
// checked externally — which is the whole weakness of this test. It can
// only prove the editor agrees with this list, never that the list is the
// one Claude Code reads. The same blind spot is why `effort` and
// `focusMode` were confidently wrong for months.
renderEditor(null);
expect(
Array.from(
screen.getByLabelText("Effort level").querySelectorAll("option"),
).map((o) => o.getAttribute("value")),
).toEqual(["", "low", "medium", "high", "xhigh"]);
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
});
describe("project scope", () => {
@@ -193,4 +199,27 @@ describe("ClaudeCodeSettingsEditor", () => {
expect(screen.getByRole("switch", { name: label })).toBeChecked();
});
});
/**
* A settings object with nothing set at this level arrives as `{}`: the Rust
* struct skips serialising a field it has no value for, which is what keeps
* an older binary able to parse `projects.json` after a downgrade. It is also
* the exact shape a project stored before the fields were widened is read
* back as — every one of its `false`s meant "unset" — so reading absent as
* "off" would show a switch the user never touched as a deliberate choice.
*/
it("reads an absent field as Global rather than as Off", () => {
renderEditor({} as ClaudeCodeSettings, "project");
expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global");
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global");
});
it("still collapses to null when an absent-field object is edited back", () => {
const onSave = renderEditor({} as ClaudeCodeSettings, "global");
// Off and straight back on: the round trip has to land on `null`, or an
// untouched global stops being indistinguishable from one never opened.
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenLastCalledWith(null);
});
});
@@ -37,15 +37,20 @@ export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
* overrides a global on, so a settings object holding one has to be persisted.
*/
function isAllDefaults(s: ClaudeCodeSettings): boolean {
// `== null`, not `===`: an unset field is *absent* on the wire, not null.
// The Rust struct skips serialising one it has no value for, so a project
// whose stored settings were all "unset" arrives here as `{}` — and reading
// that as "off" is exactly the mistake the three-state control exists to
// avoid. See the note on `ClaudeCodeSettings` in `lib/types.ts`.
return (
s.tui_mode === null &&
s.effort === null &&
s.auto_scroll_disabled === null &&
s.focus_mode === null &&
s.show_thinking_summaries === null &&
s.session_recap_disabled === null &&
s.env_scrub === null &&
s.prompt_caching_1h === null
s.tui_mode == null &&
s.effort == null &&
s.auto_scroll_disabled == null &&
s.focus_mode == null &&
s.show_thinking_summaries == null &&
s.session_recap_disabled == null &&
s.env_scrub == null &&
s.prompt_caching_1h == null
);
}
@@ -63,7 +68,15 @@ const BOOLEAN_FIELDS: {
hint: string;
invert?: boolean;
}[] = [
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
{
key: "focus_mode",
label: "Focus mode",
// It summarises tool *calls*, not all output — and it does nothing at all
// unless the fullscreen renderer is on, which is a separate switch above.
// Saying so here is cheaper than the user concluding the setting is broken,
// which is the complaint that started this whole round of work.
hint: "Summarises each tool call to one line, showing the last prompt and the final response. Needs TUI mode set to Fullscreen.",
},
{
key: "show_thinking_summaries",
label: "Thinking summaries",
@@ -163,6 +176,9 @@ export default function ClaudeCodeSettingsEditor({
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Extra high</option>
{/* `max` is accepted by the CLI and was missing here. Confirmed
against the shipped claude binary's own schema, not just docs. */}
<option value="max">Maximum</option>
</select>
}
/>
@@ -204,7 +220,7 @@ export default function ClaudeCodeSettingsEditor({
// `stored` holds the deviation from Claude Code's default, so an
// inverted field reads back the other way round — see BOOLEAN_FIELDS.
const selected =
stored === null ? "global" : (invert ? !stored : stored) ? "on" : "off";
stored == null ? "global" : (invert ? !stored : stored) ? "on" : "off";
return (
<SwitchRow
@@ -16,14 +16,12 @@ interface Props {
projectId: string;
entry: FileEntry;
onClose: () => void;
/** "Save to host…" — the way out for anything the viewer can't render. */
onSaveToHost: (entry: FileEntry) => void;
}
type Preview =
| { kind: "loading" }
| { kind: "error"; message: string }
/** Too big to render whole — offered as a download rather than a half-file. */
/** Too big to render whole — said so rather than shown as a half-file. */
| { kind: "too-large" }
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
| { kind: "image"; url: string }
@@ -37,7 +35,7 @@ type Preview =
* keeps a multi-megabyte base64 string out of the DOM. `blob:` is in the app's
* `img-src` for exactly this; the asset protocol deliberately is not enabled.
*/
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) {
export default function FileViewerModal({ projectId, entry, onClose }: Props) {
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
/**
@@ -121,19 +119,9 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
);
const footer = (
<>
<Button
size="md"
onClick={() => {
onSaveToHost(entry);
}}
>
Save to host…
</Button>
<Button size="md" variant="primary" onClick={onClose}>
Close
</Button>
</>
);
return (
@@ -156,14 +144,15 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
{preview.kind === "too-large" && (
<p className="text-[13px] text-[var(--text-secondary)]">
This file is {formatBytes(entry.size)} — too large to preview in the app. Save it
to the host to open it there.
This file is {formatBytes(entry.size)} — too large to preview in the app. Open it
from a terminal in the container, or take a backup and open it on the host.
</p>
)}
{preview.kind === "unsupported" && (
<p className="text-[13px] text-[var(--text-secondary)]">
There is no preview for this file type. Save it to the host to open it there.
There is no preview for this file type. Open it from a terminal in the container,
or take a backup and open it on the host.
</p>
)}
@@ -4,16 +4,12 @@ import FilesTab from "./FilesTab";
import type { FileContents, FileEntry, Project } from "../../../lib/types";
const listContainerFiles = vi.fn();
const downloadContainerFile = vi.fn(async () => {});
const uploadFileToContainer = vi.fn(async () => {});
const renameContainerPath = vi.fn(async () => "");
const createContainerDirectory = vi.fn(async () => "");
const readContainerFile = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
@@ -31,29 +27,6 @@ const toastText = () =>
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
.join("\n");
const save = vi.fn(async () => "/host/out");
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (o: unknown) => save(o),
open: vi.fn(async () => null),
}));
/** The webview's window-wide native drag-drop listener, captured for driving. */
type DragPayload =
| { type: "enter" | "over"; position: { x: number; y: number }; paths: string[] }
| { type: "leave" }
| { type: "drop"; position: { x: number; y: number }; paths: string[] };
let dragHandler: ((e: { payload: DragPayload }) => void | Promise<void>) | null = null;
const unlistenDrag = vi.fn();
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
onDragDropEvent: async (cb: (e: { payload: DragPayload }) => void) => {
dragHandler = cb;
return unlistenDrag;
},
}),
}));
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
@@ -82,39 +55,17 @@ async function renderTab() {
return view;
}
/** Fire the native drop payload at a point inside the pane's stubbed rect. */
async function drop(paths: string[], position = { x: 100, y: 100 }) {
await act(async () => {
await dragHandler?.({ payload: { type: "drop", position, paths } });
});
}
/** Every row that is part of the grid's roving tabindex, in order. */
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
/** The rows that are actually tab stops. There must never be more than one. */
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
/** Fire a drop without awaiting it — for the paths that stop to ask a question. */
function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
let pending: unknown;
act(() => {
pending = dragHandler?.({ payload: { type: "drop", position, paths } });
});
return pending as Promise<void> | undefined;
}
beforeEach(() => {
vi.clearAllMocks();
dragHandler = null;
listContainerFiles.mockResolvedValue([
entry("src", { is_directory: true, path: "/workspace/src" }),
entry("notes.txt"),
]);
// jsdom lays nothing out, so the pane's hit-test rect has to be supplied.
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600,
toJSON: () => ({}),
} as DOMRect);
// Not implemented in jsdom; the image preview needs both halves.
URL.createObjectURL = vi.fn(() => "blob:mock-url");
URL.revokeObjectURL = vi.fn();
@@ -224,7 +175,9 @@ describe("FilesTab viewer", () => {
});
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
expect(screen.queryByAltText("huge.png")).toBeNull();
expect(screen.getByRole("button", { name: "Save to host…" })).toBeTruthy();
// The way out is named, and it is not a host path this pane could write:
// a terminal inside the container, or a backup.
expect(screen.getByText(/take a backup/)).toBeTruthy();
});
it("says so in words when only a prefix of a big text file came back", async () => {
@@ -239,7 +192,7 @@ describe("FilesTab viewer", () => {
expect(screen.getByText("first megabyte")).toBeTruthy();
});
it("offers Save to host for a file it cannot render", async () => {
it("says there is no preview, and where to open the file instead", async () => {
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
readContainerFile.mockResolvedValue(contents("a\x00b"));
await renderTab();
@@ -314,191 +267,6 @@ describe("FilesTab new folder", () => {
});
});
describe("FilesTab host drag-and-drop", () => {
it("uploads dropped paths into the directory on screen, then re-lists", async () => {
await renderTab();
listContainerFiles.mockClear();
await drop(["/host/a.png", "/host/b.png"]);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace");
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace");
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
});
it("drops into the directory the user has navigated to", async () => {
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("src"));
});
await drop(["/host/a.png"]);
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace/src");
});
it("ignores a drop outside the pane — the listener is window-wide", async () => {
// This is the whole routing discipline: the terminal's listener is live at
// the same time, and only the hit-test keeps them apart.
await renderTab();
await drop(["/host/a.png"], { x: 5000, y: 5000 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("divides the payload position by devicePixelRatio on Windows only", async () => {
// 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. At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside the
// 800x600 pane — but the same payload on a HiDPI Mac or Linux box really
// is (900, 900) and belongs to nobody.
const originalDpr = window.devicePixelRatio;
const originalUa = window.navigator.userAgent;
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
Object.defineProperty(window.navigator, "userAgent", {
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
configurable: true,
});
await renderTab();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window.navigator, "userAgent", {
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
configurable: true,
});
vi.mocked(uploadFileToContainer).mockClear();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
// …and the *unhalved* point still lands, which is the half a HiDPI Mac
// user was losing.
await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window, "devicePixelRatio", {
value: originalDpr,
configurable: true,
});
Object.defineProperty(window.navigator, "userAgent", {
value: originalUa,
configurable: true,
});
});
it("accepts a drop that lands on a toast floating over the pane", async () => {
// Round 1. `ToastHost` is `fixed bottom-4 right-4 z-[60]` and 24rem wide,
// and its error cards stay until dismissed — so a z-order gate asking "is
// what is painted here part of my pane?" made the bottom-right corner of
// this pane refuse drops for as long as one error was on screen. jsdom has
// no `elementFromPoint`, so that branch only ran when a test supplied one;
// the gate no longer asks, and this pins that nothing painted over a pane
// can refuse a drop on its own account.
await renderTab();
const toastCard = document.createElement("div");
document.body.appendChild(toastCard);
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => toastCard,
});
await drop(["/host/a.png"], { x: 700, y: 550 });
expect(uploadFileToContainer).toHaveBeenCalled();
delete (document as Partial<Document>).elementFromPoint;
toastCard.remove();
});
it("refuses a drop while a dialog is open, toast painted over it or not", async () => {
// Round 2, which is the reason this file exists in its current shape. The
// refusal pushes a toast; `ToastHost` is `z-[60]` and the `Modal` backdrop
// is `z-50` in the same stacking context, so the *toast* becomes the
// topmost element over a covered pane. A gate that asked `elementFromPoint`
// "is a blocker painted here?" then answered no and uploaded into the
// directory the dialog was covering — one refused drop was all it took to
// open the hole. Both stubs below therefore have to be refused.
await renderTab();
const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true");
document.body.appendChild(backdrop);
const toastCard = document.createElement("div"); // z-[60], above the backdrop
document.body.appendChild(toastCard);
const stub = (top: Element) =>
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => top,
});
stub(backdrop);
await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
stub(toastCard);
await drop(["/host/a.png"], { x: 700, y: 550 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
delete (document as Partial<Document>).elementFromPoint;
toastCard.remove();
backdrop.remove();
});
it("highlights the pane while a drag hovers it, and drops the highlight on leave", async () => {
await renderTab();
await act(async () => {
await dragHandler?.({
payload: { type: "over", position: { x: 100, y: 100 }, paths: [] },
});
});
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
await act(async () => {
await dragHandler?.({ payload: { type: "leave" } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
});
});
describe("FilesTab save to host", () => {
it("copies a file out to the path the user picks", async () => {
await renderTab();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Save to host… — notes.txt" }));
});
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
});
it("does not offer a directory download, which cannot work", async () => {
await renderTab();
expect(screen.queryByRole("button", { name: "Save to host… — src" })).toBeNull();
});
});
describe("FilesTab drop hit test", () => {
it("uploads nothing when a dialog is covering the pane", async () => {
// The pane still has its rect underneath the viewer's `fixed inset-0`
// portal, which is exactly why a rect alone was the wrong test.
readContainerFile.mockResolvedValue(contents("hello"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
await screen.findByRole("dialog");
await drop(["/host/a.png"]);
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("does not paint the hint under a dialog either", async () => {
readContainerFile.mockResolvedValue(contents("hello"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
await screen.findByRole("dialog");
await act(async () => {
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
});
});
describe("FilesTab grid focus", () => {
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
@@ -592,22 +360,28 @@ describe("FilesTab grid semantics", () => {
await renderTab();
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
expect(rename.textContent).toBe("Rename");
expect(rename.getAttribute("aria-label")).toContain("Rename");
const saveTo = screen.getByRole("button", { name: "Save to host… — notes.txt" });
expect(saveTo.getAttribute("aria-label")).toContain(saveTo.textContent!);
expect(rename.getAttribute("aria-label")).toContain(rename.textContent!);
});
it("mounts the live region empty, then fills it", async () => {
// A `role="status"` node inserted already carrying its text is frequently
// not announced at all, which is how every one of these went by in silence.
createContainerDirectory.mockResolvedValue("/workspace/new");
await renderTab();
const live = screen.getByRole("status");
expect(live.textContent).toBe("");
await drop(["/host/a.png"]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
});
const input = screen.getByLabelText("New folder name");
fireEvent.change(input, { target: { value: "new" } });
await act(async () => {
fireEvent.blur(input);
});
// Same node throughout — it is never unmounted.
expect(screen.getByRole("status")).toBe(live);
expect(live.textContent).toContain("Uploaded 1 item");
expect(live.textContent).toContain('Created "new"');
});
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
@@ -618,130 +392,3 @@ describe("FilesTab grid semantics", () => {
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
});
});
describe("FilesTab overwrite prompt", () => {
it("asks before replacing, and re-uploads with overwrite on Replace", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/notes.txt"]);
const dialog = await screen.findByRole("dialog");
expect(dialog.textContent).toContain("notes.txt");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
await pending;
});
expect(uploadFileToContainer).toHaveBeenLastCalledWith(
"p1",
"/host/notes.txt",
"/workspace",
true,
);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("uploads nothing more on Skip", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/notes.txt"]);
await screen.findByRole("dialog");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
await pending;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("offers the blanket answers only when files are queued behind this one", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
await screen.findByRole("dialog");
expect(screen.getByRole("button", { name: "Replace all" })).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Skip all" }));
await pending;
});
expect(screen.queryByRole("dialog")).toBeNull();
});
});
/**
* Dismissal. `Modal` gives every dialog Escape, a ✕ and click-outside for free,
* and `OverwriteConfirmModal` maps all three onto `onChoose("skip")` — because
* the destructive answer has to be chosen, and because a dialog that is closed
* rather than answered must not leave the batch waiting forever or throw away
* the files behind it.
*/
describe("FilesTab overwrite prompt dismissal", () => {
/**
* Drop two files where the first name is taken, and stop at the dialog. The
* unsettled batch comes back wrapped — returning it bare from an `async`
* helper would adopt it, and awaiting the helper would then wait for an
* upload that cannot proceed until the helper has returned.
*/
async function dropIntoConflict(): Promise<{ batch: Promise<void> | undefined }> {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
await renderTab();
const batch = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
await screen.findByRole("dialog");
return { batch };
}
/** What every dismissal has to leave behind: one skip, one upload, no clobber. */
function expectSkippedAndCarriedOn() {
expect(screen.queryByRole("dialog")).toBeNull();
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
expect(uploadFileToContainer).toHaveBeenLastCalledWith("p1", "/host/b.txt", "/workspace");
expect(uploadFileToContainer.mock.calls.some((call) => call[3] === true)).toBe(false);
expect(screen.getByRole("status").textContent).toContain("skipped 1");
}
it("counts Escape as a Skip", async () => {
const { batch } = await dropIntoConflict();
await act(async () => {
fireEvent.keyDown(document, { key: "Escape" });
await batch;
});
expectSkippedAndCarriedOn();
});
it("counts the ✕ as a Skip", async () => {
const { batch } = await dropIntoConflict();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
await batch;
});
expectSkippedAndCarriedOn();
});
it("counts a click on the backdrop as a Skip", async () => {
const { batch } = await dropIntoConflict();
// The overlay is the dialog panel's parent — `Modal` only closes when the
// click landed on the overlay itself, not on anything inside the panel.
const overlay = screen.getByRole("dialog").parentElement!;
await act(async () => {
fireEvent.click(overlay);
await batch;
});
expectSkippedAndCarriedOn();
});
it("does not dismiss on a click inside the dialog", async () => {
const { batch } = await dropIntoConflict();
fireEvent.click(screen.getByRole("dialog"));
expect(screen.queryByRole("dialog")).not.toBeNull();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
await batch;
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
});
});
+15 -109
View File
@@ -1,12 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import type { FileEntry, Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager";
import { classifyDrop, isDropTarget, DROP_BLOCKED_TOAST } from "../../../lib/dropTarget";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal";
import OverwriteConfirmModal from "./OverwriteConfirmModal";
import { formatBytes } from "./format";
interface Props {
@@ -17,7 +13,15 @@ interface Props {
const PARENT_ROW = "..";
/**
* The project's file manager.
* The project's file browser.
*
* Container-side only: it lists, opens, renames and creates folders inside the
* container, and it does no host filesystem I/O at all. A file gets *into* a
* container by being dropped onto the Terminal tab, and a whole tree comes back
* out through "Back up container" in the project's Workspace settings. Four
* successive audits found that host paths crossing IPC were where the criticals
* lived; those two paths are the ones that survived, and this pane is not one
* of them.
*
* Interaction model, chosen to match every desktop file manager rather than
* the old half-and-half: **single click selects, double click opens**. That
@@ -42,16 +46,10 @@ export default function FilesTab({ project }: Props) {
entries,
loading,
error,
busy,
completed,
conflict,
resolveConflict,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
uploadPaths,
renameEntry,
createFolder,
} = useFileManager(project.id);
@@ -65,8 +63,6 @@ export default function FilesTab({ project }: Props) {
const [creatingFolder, setCreatingFolder] = useState(false);
const [folderDraft, setFolderDraft] = useState("");
const [viewing, setViewing] = useState<FileEntry | null>(null);
/** A host drag is currently over this pane. */
const [dragOver, setDragOver] = useState(false);
/** The row that owns the grid's single tab stop. */
const [activeRow, setActiveRow] = useState<string | null>(null);
@@ -234,58 +230,6 @@ export default function FilesTab({ project }: Props) {
goUp();
}, [currentPath, goUp]);
// Host → container drag and drop.
//
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
// it), which blocks HTML5 drag inside the webview on Windows, and only the
// native payload carries real file *paths*. The listener is window-wide, so
// routing is `classifyDrop` — the rect hit test, which says *whose* drop it
// is, plus the document-wide question a rect cannot answer: is a modal or a
// blocking overlay on screen at all? That second half is deliberately not a
// per-point z-order test; `lib/dropTarget.ts` records the two ways that went
// wrong.
useEffect(() => {
if (!running) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
const payload = event.payload;
if (payload.type === "leave") {
setDragOver(false);
return;
}
if (payload.type === "enter" || payload.type === "over") {
setDragOver(isDropTarget(paneRef.current, payload.position));
return;
}
if (payload.type !== "drop") return;
setDragOver(false);
const verdict = classifyDrop(paneRef.current, payload.position);
// Aimed at this pane and refused anyway: say so. Nothing else would —
// the file just never appears in the listing.
if (verdict === "blocked") {
console.warn("[drop] refused: a dialog or overlay is open", payload.position);
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
return;
}
if (verdict !== "accept") return;
const paths = payload.paths ?? [];
if (paths.length === 0) return;
await uploadPaths(paths);
});
if (cancelled) un();
else unlisten = un;
})();
return () => {
cancelled = true;
unlisten?.();
};
}, [running, uploadPaths]);
const breadcrumbs =
currentPath === "/"
? [{ label: "/", path: "/" }]
@@ -324,10 +268,10 @@ export default function FilesTab({ project }: Props) {
/**
* The live region's text. One region, always mounted, filled and emptied —
* a `role="status"` node that is *inserted* already carrying its text is
* frequently not announced at all, which is how "uploading 3 items…" and
* every completion notice used to go by in silence.
* frequently not announced at all, which is how every completion notice used
* to go by in silence.
*/
const liveText = busy ? busy : (completed ?? "");
const liveText = completed ?? "";
return (
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
@@ -361,9 +305,6 @@ export default function FilesTab({ project }: Props) {
>
New folder
</Button>
<Button onClick={uploadFile} className="ml-1">
Upload file
</Button>
<Button onClick={refresh} disabled={loading} className="ml-1">
Refresh
</Button>
@@ -372,9 +313,9 @@ export default function FilesTab({ project }: Props) {
<div className="flex-1 overflow-y-auto min-h-0">
{/* The one failure that stays inline: it explains why the grid below is
empty, it is in context, and there are no rows for it to scroll
behind. Every *transient* failure — upload, rename, mkdir,
save-to-host — goes to `ToastHost` instead, which is above
the file viewer's overlay and does not scroll away. */}
behind. Every *transient* failure — rename, new folder — goes to
`ToastHost` instead, which is above the file viewer's overlay and
does not scroll away. */}
{error && (
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
{error}
@@ -560,18 +501,6 @@ export default function FilesTab({ project }: Props) {
>
Rename
</Button>
{!entry.is_directory && (
<Button
aria-label={`Save to host… — ${entry.name}`}
className="ml-1"
onClick={(e) => {
e.stopPropagation();
downloadFile(entry);
}}
>
Save to host…
</Button>
)}
</>
)}
</td>
@@ -594,34 +523,11 @@ export default function FilesTab({ project }: Props) {
)}
</div>
{/* Drop hint. Purely decorative — the native listener is what accepts the
drop, so this must never intercept pointer events. */}
{dragOver && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-[var(--accent)] bg-[var(--bg-primary)]/70"
>
<span className="text-[13px] font-medium text-[var(--text-primary)]">
Drop files into {currentPath}
</span>
</div>
)}
{conflict && (
<OverwriteConfirmModal
name={conflict.name}
directory={conflict.directory}
remaining={conflict.remaining}
onChoose={resolveConflict}
/>
)}
{viewing && (
<FileViewerModal
projectId={project.id}
entry={viewing}
onClose={() => setViewing(null)}
onSaveToHost={downloadFile}
/>
)}
</div>
@@ -1,73 +0,0 @@
import type { OverwriteChoice } from "../../../lib/uploadErrors";
import Button from "../../ui/Button";
import Modal from "../../ui/Modal";
interface Props {
/** Bare name of the file that is already there. */
name: string;
/** Container directory it is going into. */
directory: string;
/** How many more files are queued behind this one. */
remaining: number;
onChoose: (choice: OverwriteChoice) => void;
}
/**
* "That name is taken — replace it?"
*
* This exists because the backend stopped overwriting silently, and a raw
* error string would have been a worse answer than the old silent clobber: it
* tells the user their drop failed without telling them it *can* succeed. The
* dialog names the file and the directory, because a drop is aimed with a
* mouse and "notes.txt" alone does not say which `notes.txt`.
*
* The blanket answers only appear when there is something to apply them to — a
* single-file drop with "Replace all" on it invites the reflex of clicking the
* widest button for no benefit.
*
* Dismissing (Escape, ✕, click-outside) is a **skip**, never a replace: the
* destructive answer has to be chosen explicitly.
*/
export default function OverwriteConfirmModal({ name, directory, remaining, onChoose }: Props) {
const footer = (
<>
{remaining > 0 && (
<>
<Button size="md" onClick={() => onChoose("skip-all")}>
Skip all
</Button>
<Button size="md" onClick={() => onChoose("replace-all")}>
Replace all
</Button>
</>
)}
<Button size="md" onClick={() => onChoose("skip")}>
Skip
</Button>
<Button size="md" variant="primary" onClick={() => onChoose("replace")}>
Replace
</Button>
</>
);
return (
<Modal
title="A file with that name is already there"
description={directory}
onClose={() => onChoose("skip")}
footer={footer}
widthClassName="w-[30rem]"
>
<p className="text-[13px] text-[var(--text-primary)]">
<span className="font-mono">{name}</span> already exists in{" "}
<span className="font-mono">{directory}</span>. Replacing it overwrites the container's
copy, and that cannot be undone from here.
</p>
{remaining > 0 && (
<p className="mt-2 text-xs text-[var(--text-secondary)]">
{remaining} more file{remaining === 1 ? "" : "s"} still to upload.
</p>
)}
</Modal>
);
}
@@ -109,7 +109,16 @@ export default function RuntimeSection({
<ConfigGroup
title="Claude Code settings"
description="Per-project CLI behaviour. Anything left on Global follows Settings; Off overrides a global On."
description={
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
"Off overrides a global On. Changing any of these recreates the container, " +
"which commits a new image layer — so flipping switches repeatedly costs disk. " +
"Turning TUI mode, Effort level, Focus mode or Session recap back to Global " +
"also needs the base image updated first: those four are cleared by removing a " +
"key, and an older image's startup script ignores the instruction to remove it. " +
"Update the base image from Overview. TUI mode, Effort level and Focus mode " +
"visibly refuse to switch off until you do; Session recap just stays off silently."
}
>
<ClaudeCodeSettingsEditor
scope="project"
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import WorkspaceSection from "./WorkspaceSection";
import type { Project } from "../../../../lib/types";
// The Browse button is the OS folder picker.
const open = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (...args: unknown[]) => open(...args),
}));
const baseProject: Project = {
id: "p1",
name: "api-server",
paths: [{ host_path: "/src/api", mount_name: "api" }],
container_id: null,
status: "stopped",
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
llamacpp_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
mission_control_enabled: false,
auth_bridge_enabled: false,
browser_view_enabled: false,
vpn_support_enabled: false,
use_shared_auth_token: true,
full_permissions: false,
permission_mode: null,
ssh_key_path: null,
ca_cert_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const save = vi.fn().mockResolvedValue(true);
function renderSection(over: Partial<Project> = {}, disabled = false) {
return render(
<WorkspaceSection
project={{ ...baseProject, ...over }}
save={save}
disabled={disabled}
/>,
);
}
/** Every folder list this component has sent to `update_project`. */
function savedLists() {
return save.mock.calls
.filter(([patch]) => "paths" in patch)
.map(([patch]) => patch.paths);
}
describe("WorkspaceSection — the blank row is never stored", () => {
beforeEach(() => vi.clearAllMocks());
/**
* The bug this file exists for. `create_container` mounts every stored row
* unfiltered, so a persisted `{host_path: "", mount_name: ""}` becomes
* `{"Target": "/workspace/", "Source": ""}` and the daemon refuses the whole
* container with `field Source must not be empty` — the project can never be
* started or recreated again. Click "+ Add folder", blur a field, and it is
* bricked.
*/
it("drops the placeholder row when a real edit is saved", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
const hostPath = screen.getByLabelText("Folder 1 host path");
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
fireEvent.blur(hostPath);
expect(save).toHaveBeenCalledTimes(1);
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
});
it("drops it when Browse fills a different row in", async () => {
open.mockResolvedValueOnce("/src/api-v2");
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
// The picker is awaited inside the handler, so the state update that
// follows it lands outside the click.
await act(async () => {
fireEvent.click(screen.getAllByRole("button", { name: "Browse" })[0]);
});
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
});
it("drops it when a row is removed", () => {
renderSection({
paths: [
{ host_path: "/src/api", mount_name: "api" },
{ host_path: "/src/web", mount_name: "web" },
],
});
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
fireEvent.click(screen.getByRole("button", { name: "Remove folder 2" }));
expect(savedLists()[0]).toEqual([{ host_path: "/src/api", mount_name: "api" }]);
});
it("never sends a row with an empty host path, whatever the route", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
const hostPath = screen.getByLabelText("Folder 1 host path");
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
fireEvent.blur(hostPath);
for (const list of savedLists()) {
for (const row of list) {
expect(row.host_path).not.toBe("");
expect(row.mount_name).not.toBe("");
}
}
});
});
describe("WorkspaceSection — what a blur is allowed to save", () => {
beforeEach(() => vi.clearAllMocks());
/**
* Both inputs save on blur, so tabbing from the host path to the mount name
* fires a save with the name still empty — which `update_project` refuses,
* turning an ordinary keystroke into an error toast.
*/
it("holds a half-filled row back until it is complete", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
const newHostPath = screen.getByLabelText("Folder 2 host path");
fireEvent.change(newHostPath, { target: { value: "/src/web" } });
fireEvent.blur(newHostPath);
expect(save).not.toHaveBeenCalled();
const newMountName = screen.getByLabelText("Folder 2 mount name");
fireEvent.change(newMountName, { target: { value: "web" } });
fireEvent.blur(newMountName);
expect(savedLists()[0]).toEqual([
{ host_path: "/src/api", mount_name: "api" },
{ host_path: "/src/web", mount_name: "web" },
]);
});
/**
* Blurring out of an untouched field is not an edit. Saving anyway would
* round-trip the filtered list through `project` and take the empty row away
* while the user was still filling it in.
*/
it("saves nothing when the blur changed nothing", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
fireEvent.blur(screen.getByLabelText("Folder 1 mount name"));
expect(save).not.toHaveBeenCalled();
expect(screen.getByLabelText("Folder 2 host path")).toBeTruthy();
});
it("still saves a rename, which does not go through the folder list", () => {
renderSection();
const name = screen.getByDisplayValue("api-server");
fireEvent.change(name, { target: { value: "api-v2" } });
fireEvent.blur(name);
expect(save).toHaveBeenCalledWith({ name: "api-v2" });
});
});
@@ -10,6 +10,14 @@ interface Props {
disabled: boolean;
}
/** Whether two folder lists are the same rows in the same order. */
function sameRows(a: ProjectPath[], b: ProjectPath[]): boolean {
return (
a.length === b.length &&
a.every((row, i) => row.host_path === b[i].host_path && row.mount_name === b[i].mount_name)
);
}
export default function WorkspaceSection({ project, save, disabled }: Props) {
const [name, setName] = useState(project.name);
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
@@ -19,6 +27,27 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
setPaths(project.paths ?? []);
}, [project]);
/**
* Persist a folder list, minus the rows that are only in it because the UI
* put them there.
*
* **The blank row must never reach the store.** "+ Add folder" inserts
* `{host_path: "", mount_name: ""}` deliberately, and `create_container`
* mounts every stored row unfiltered — a stored blank one becomes
* `{"Target": "/workspace/", "Source": ""}`, which the daemon rejects with
* `field Source must not be empty`. The project then cannot be started or
* recreated at all, from a click and a blur. `AddProjectDialog` has always
* filtered this; this section computed the filtered list and then saved the
* unfiltered one.
*
* Every save goes through here for that reason — Browse and Remove write the
* list too, and either can be holding a blank row from an earlier click.
*/
const persist = (rows: ProjectPath[]) => {
const filled = rows.filter((p) => p.host_path.trim() || p.mount_name.trim());
return save({ paths: filled });
};
/**
* Save only when every row is fully filled in.
*
@@ -27,12 +56,18 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
* a half-filled row is refused — so the unconditional save turned an ordinary
* keystroke into an error toast. A blank row is *not* incomplete: the
* "+ Add folder" button adds one deliberately, and it is dropped on save.
*
* A blur that changed nothing saves nothing, which is what keeps the blank
* row on screen while it is being filled in: persisting the filtered list
* would round-trip through `project` and take the empty row away under the
* cursor.
*/
const saveIfComplete = () => {
const filled = paths.filter((p) => p.host_path.trim() || p.mount_name.trim());
const halfFilled = filled.some((p) => !p.host_path.trim() || !p.mount_name.trim());
if (halfFilled) return;
return save({ paths });
if (sameRows(filled, project.paths ?? [])) return;
return persist(paths);
};
return (
@@ -106,7 +141,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
mount_name: updated[i].mount_name || basename,
};
setPaths(updated);
save({ paths: updated });
persist(updated);
}
}}
>
@@ -137,7 +172,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
onClick={() => {
const updated = paths.filter((_, j) => j !== i);
setPaths(updated);
save({ paths: updated });
persist(updated);
}}
>
Remove
+23 -354
View File
@@ -4,15 +4,11 @@ import { useFileManager } from "./useFileManager";
import type { FileEntry } from "../lib/types";
const listContainerFiles = vi.fn();
const downloadContainerFile = vi.fn();
const uploadFileToContainer = vi.fn();
const renameContainerPath = vi.fn();
const createContainerDirectory = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
@@ -35,13 +31,6 @@ const toastText = () =>
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
.join("\n");
const save = vi.fn();
const openDialog = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (opts: unknown) => save(opts),
open: (opts: unknown) => openDialog(opts),
}));
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
name,
path: `/workspace/${name}`,
@@ -100,48 +89,6 @@ describe("useFileManager navigation", () => {
});
});
describe("useFileManager uploads", () => {
it("uploads every dropped path into the current directory, then re-lists once", async () => {
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace/app");
});
listContainerFiles.mockClear();
await act(async () => {
await result.current.uploadPaths(["/host/a.png", "/host/b.png"]);
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace/app");
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace/app");
// One refresh for the batch, not one per file.
expect(listContainerFiles).toHaveBeenCalledTimes(1);
});
it("reports a failed upload but still lists whatever did land", async () => {
uploadFileToContainer.mockResolvedValueOnce(undefined);
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]);
});
// Inline `error` is reserved for the listing failure the user can see in
// context; a failed upload goes where it cannot scroll away.
expect(result.current.error).toBeNull();
expect(toastText()).toContain("too large");
expect(listContainerFiles).toHaveBeenCalled();
});
it("does nothing when the file picker is cancelled", async () => {
openDialog.mockResolvedValue(null);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadFile();
});
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
});
describe("useFileManager rename and mkdir", () => {
it("sends the bare new name, never a path, and re-lists on success", async () => {
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
@@ -204,47 +151,22 @@ describe("useFileManager rename and mkdir", () => {
});
});
describe("useFileManager save to host", () => {
it("writes to the path the user picked", async () => {
save.mockResolvedValue("/host/Downloads/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.downloadFile(file("a.txt"));
});
expect(downloadContainerFile).toHaveBeenCalledWith(
"p1",
"/workspace/a.txt",
"/host/Downloads/a.txt",
);
});
it("reports a refused download — a directory is no longer written as garbage", async () => {
save.mockResolvedValue("/host/Downloads/src");
downloadContainerFile.mockRejectedValue("/workspace/src is a folder — download its files individually");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.downloadFile(file("src", { is_directory: true }));
});
expect(toastText()).toContain("is a folder");
});
});
describe("useFileManager stays where the user is", () => {
it("does not drag the pane back when the user navigates away mid-upload", async () => {
it("does not drag the pane back when the user navigates away mid-operation", async () => {
// The closure captured `/workspace`; the user is in `/workspace/src` by the
// time the copy finishes. Re-listing the *captured* path is what used to
// time the rename finishes. Re-listing the *captured* path is what used to
// yank them out of the directory they had walked into.
let failUpload: (reason: unknown) => void = () => {};
let failRename: (reason: unknown) => void = () => {};
// `Once`, deliberately: `clearAllMocks` clears calls but not
// implementations, so a never-settling one would hang every test after it.
uploadFileToContainer.mockImplementationOnce(
() => new Promise((_resolve, reject) => { failUpload = reject; }),
renameContainerPath.mockImplementationOnce(
() => new Promise((_resolve, reject) => { failRename = reject; }),
);
const { result } = renderHook(() => useFileManager("p1"));
let upload!: Promise<void>;
let rename!: Promise<boolean>;
await act(async () => {
upload = result.current.uploadPaths(["/host/big.bin"]);
rename = result.current.renameEntry(file("big.bin"), "bigger.bin");
await Promise.resolve();
});
@@ -255,13 +177,13 @@ describe("useFileManager stays where the user is", () => {
listContainerFiles.mockClear();
await act(async () => {
failUpload("cp: no space left on device");
await upload;
failRename("mv: no space left on device");
await rename;
});
expect(result.current.currentPath).toBe("/workspace/src");
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
// No re-list of the directory the upload targeted…
// No re-list of the directory the rename targeted…
expect(listContainerFiles).not.toHaveBeenCalled();
// …and no failure text painted over the listing that replaced it.
expect(result.current.error).toBeNull();
@@ -269,13 +191,14 @@ describe("useFileManager stays where the user is", () => {
});
it("re-lists when the user stayed put, which is the ordinary case", async () => {
renameContainerPath.mockResolvedValue("/workspace/b.txt");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace");
});
listContainerFiles.mockClear();
await act(async () => {
await result.current.uploadPaths(["/host/a.png"]);
await result.current.renameEntry(file("a.txt"), "b.txt");
});
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
});
@@ -316,248 +239,11 @@ describe("useFileManager stays where the user is", () => {
await result.current.navigate("/root");
});
listContainerFiles.mockClear();
// The pane never left /workspace, so an upload started now targets it.
// The pane never left /workspace, so a new folder made now lands there.
await act(async () => {
await result.current.uploadPaths(["/host/a.png"]);
await result.current.createFolder("new");
});
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace");
});
});
describe("useFileManager overwrite prompt", () => {
const alreadyThere = "FILE_EXISTS: /workspace/a.txt already exists";
it("asks rather than clobbering, and replaces on demand", async () => {
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
uploadFileToContainer.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
let upload!: Promise<void>;
await act(async () => {
upload = result.current.uploadPaths(["/host/a.txt"]);
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
expect(result.current.conflict?.directory).toBe("/workspace");
// One file, so there is nothing for a blanket answer to apply to.
expect(result.current.conflict?.remaining).toBe(0);
await act(async () => {
result.current.resolveConflict("replace");
await upload;
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
expect(result.current.conflict).toBeNull();
});
it("skips without uploading anything when the user says so", async () => {
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
const { result } = renderHook(() => useFileManager("p1"));
let upload!: Promise<void>;
await act(async () => {
upload = result.current.uploadPaths(["/host/a.txt"]);
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict).not.toBeNull());
await act(async () => {
result.current.resolveConflict("skip");
await upload;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
// A skip is a choice, not a failure — nothing to report.
expect(toastText()).not.toContain("could not be uploaded");
});
it("asks once for a batch when the answer is Replace all", async () => {
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
uploadFileToContainer.mockResolvedValueOnce(undefined);
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/b.txt already exists");
uploadFileToContainer.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
let upload!: Promise<void>;
await act(async () => {
upload = result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict?.remaining).toBe(1));
await act(async () => {
result.current.resolveConflict("replace-all");
await upload;
});
expect(result.current.conflict).toBeNull();
expect(uploadFileToContainer).toHaveBeenNthCalledWith(4, "p1", "/host/b.txt", "/workspace", true);
});
it("leaves an unrelated failure alone — no prompt offering a button that cannot work", async () => {
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/huge.bin"]);
});
expect(result.current.conflict).toBeNull();
expect(toastText()).toContain("too large");
});
});
/**
* The loop, end to end. The prompt only earns its place if the *batch* survives
* it: one answer, given once, has to leave every other file in the drop exactly
* where it would have been.
*/
describe("useFileManager overwrite prompt closes the loop", () => {
const clash = (name: string) => `FILE_EXISTS: /workspace/${name} already exists`;
/**
* Start an upload and wait for it to stop at the prompt, handing back the
* still-unsettled batch.
*
* Wrapped in an object on purpose: an `async` function that returned the
* promise itself would *adopt* it, so awaiting the helper would wait for the
* whole upload — which cannot finish until the question is answered, which
* cannot happen until the helper returns. That deadlock looks exactly like
* the hang these tests exist to rule out.
*/
async function uploadUntilPrompt(
result: { current: ReturnType<typeof useFileManager> },
paths: string[],
): Promise<{ batch: Promise<void> }> {
let batch!: Promise<void>;
await act(async () => {
batch = result.current.uploadPaths(paths);
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict).not.toBeNull());
return { batch };
}
it("replaces the file that clashed and still uploads the rest of the batch", async () => {
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt")) // 1: a.txt, no overwrite
.mockResolvedValueOnce(undefined) // 2: a.txt, overwrite: true
.mockResolvedValueOnce(undefined); // 3: b.txt, no clash
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
expect(result.current.conflict?.name).toBe("a.txt");
expect(result.current.conflict?.remaining).toBe(1);
await act(async () => {
result.current.resolveConflict("replace");
await batch;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
// The retry is the whole point: same file, same directory, overwrite on.
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
// …and "Replace" answered for *that* file only, so the next one is offered
// to the backend the safe way round.
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
expect(result.current.conflict).toBeNull();
expect(result.current.completed).toContain("Uploaded 2 items");
expect(toastText()).not.toContain("could not be uploaded");
});
it("moves on to the next file on Skip rather than ending the batch", async () => {
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockResolvedValueOnce(undefined); // b.txt still goes
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
await act(async () => {
result.current.resolveConflict("skip");
await batch;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.txt", "/workspace");
// Nothing was overwritten.
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
expect(result.current.completed).toContain("skipped 1");
});
it("dismissing the dialog is a Skip — the batch carries on", async () => {
// `OverwriteConfirmModal` maps Escape / ✕ / click-outside onto this exact
// call, so a dismissal must not hang the loop or abort the drop.
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
await act(async () => {
// What `Modal`'s `onClose` produces.
result.current.resolveConflict("skip");
await batch;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
expect(result.current.completed).toContain("Uploaded 1 item, skipped 1");
expect(result.current.busy).toBeNull();
});
it("answers every remaining clash with Skip all, asking only once", async () => {
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockRejectedValueOnce(clash("b.txt"))
.mockRejectedValueOnce(clash("c.txt"));
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt", "/host/c.txt"]);
expect(result.current.conflict?.remaining).toBe(2);
await act(async () => {
result.current.resolveConflict("skip-all");
await batch;
});
// Three attempts, no second prompt, nothing replaced.
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
expect(result.current.conflict).toBeNull();
expect(result.current.completed).toContain("skipped 3");
});
it("puts a picked file through exactly the road a dropped one takes", async () => {
// The Upload button and the native drop listener are one routine —
// `uploadPaths` — so the prompt, the retry and the blanket answers cannot
// drift apart between them. This is that claim, from the picker end.
openDialog.mockResolvedValueOnce(["/host/a.txt", "/host/b.txt"]);
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
let picked!: Promise<void>;
await act(async () => {
picked = result.current.uploadFile();
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
await act(async () => {
result.current.resolveConflict("replace");
await picked;
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
});
it("does not leave the batch waiting for an answer that can never arrive", async () => {
// The pane unmounted mid-prompt (tab closed, container stopped). The upload
// promise has to settle, or `busy` never clears and the loop leaks.
uploadFileToContainer.mockRejectedValueOnce(clash("a.txt"));
const { result, unmount } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt"]);
unmount();
await expect(batch).resolves.toBeUndefined();
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "new");
});
});
@@ -567,8 +253,6 @@ describe("useFileManager overwrite prompt closes the loop", () => {
* reported that way is a sentence nobody reads.
*/
describe("useFileManager surfaces written refusals as prose", () => {
const hiddenFolder =
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
const outsideRoots =
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
@@ -576,10 +260,10 @@ describe("useFileManager surfaces written refusals as prose", () => {
const lastToast = () => pushToast.mock.calls.at(-1)?.[0];
it("puts the write-root refusal in the headline, not behind Details", async () => {
uploadFileToContainer.mockRejectedValueOnce(outsideRoots);
createContainerDirectory.mockRejectedValueOnce(outsideRoots);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/a.txt"]);
await result.current.createFolder("new");
});
expect(lastToast().message).toBe(outsideRoots);
@@ -587,39 +271,24 @@ describe("useFileManager surfaces written refusals as prose", () => {
expect(lastToast().message).not.toMatch(/^Error:/);
});
it("says it once for a whole batch that failed the same way", async () => {
// The refusal is about the target directory, so every file in the drop
// fails identically — three copies of the same sentence is not detail.
uploadFileToContainer.mockRejectedValue(outsideRoots);
it("unwraps an `Error` rather than stamping \"Error:\" on prose", async () => {
renameContainerPath.mockRejectedValueOnce(new Error(outsideRoots));
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
await result.current.renameEntry(file("a.txt"), "b.txt");
});
expect(lastToast().message).toBe(outsideRoots);
expect(lastToast().detail).toBeUndefined();
});
it("does the same for a refused save to the host", async () => {
save.mockResolvedValue("/home/me/.ssh/a.txt");
downloadContainerFile.mockRejectedValueOnce(new Error(hiddenFolder));
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.downloadFile(file("a.txt"));
});
// Unwrapped: an `Error` on the way through must not stamp "Error:" on prose.
expect(lastToast().message).toBe(hiddenFolder);
});
it("keeps the hook's own headline when the failure is not a written refusal", async () => {
uploadFileToContainer.mockRejectedValueOnce("no space left on device");
createContainerDirectory.mockRejectedValueOnce("no space left on device");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/a.txt"]);
await result.current.createFolder("new");
});
expect(lastToast().message).toBe("A file could not be uploaded");
expect(lastToast().message).toBe('Could not create "new"');
expect(lastToast().detail).toBe("no space left on device");
});
});
+20 -235
View File
@@ -1,36 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
import { useCallback, useRef, useState } from "react";
import type { FileEntry } from "../lib/types";
import * as commands from "../lib/tauri-commands";
import { useAppState } from "../store/appState";
import {
errorText,
fileExistsPath,
isFileExistsError,
readableRefusal,
type OverwriteChoice,
} from "../lib/uploadErrors";
/**
* One upload waiting on the user to say whether it may replace what is there.
* `remaining` is how many files are queued behind this one, which is what
* decides whether the blanket answers are worth offering.
*/
export interface UploadConflict {
/** Host file being uploaded. */
hostPath: string;
/** Bare name, for the prompt. */
name: string;
/** Container directory it is going into. */
directory: string;
remaining: number;
}
/** `/a/b/c.txt` and `C:\a\b\c.txt` both give `c.txt`. */
function baseName(path: string): string {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] || path;
}
import { errorText, readableRefusal } from "../lib/refusalText";
/**
* ## Where failures are reported
@@ -41,22 +13,19 @@ function baseName(path: string): string {
* (empty) grid. It is on screen, it is in context, it explains why there are
* no rows, and it is not transient — it stands until the directory lists.
*
* Every **transient operation** failure — upload, rename, create folder,
* save-to-host — goes to `ToastHost` instead. Those used to land
* in the same inline `error` div, which is the first child of the *scrolling*
* list: three hundred rows down, a refused rename produced no visible change
* at all, just a rename box that stayed open for no stated reason. Worse, the
* file viewer routes its "Save to host…" through the same call, and the viewer
* is a `fixed inset-0` portal at `z-50` — so that failure reported *behind* the
* dialog that caused it. The toast host is a persistent `aria-live` region at
* `z-[60]`, i.e. the one place in the app that is above a modal and does not
* scroll away.
* Every **transient operation** failure — rename, create folder — goes to
* `ToastHost` instead. Those used to land in the same inline `error` div, which
* is the first child of the *scrolling* list: three hundred rows down, a
* refused rename produced no visible change at all, just a rename box that
* stayed open for no stated reason. The toast host is a persistent `aria-live`
* region at `z-[60]`, i.e. the one place in the app that is above a modal and
* does not scroll away.
*
* ## Where the current directory lives
*
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
* after an await). Every long operation captures the directory it targets at
* the start and compares it against the ref at the end: a 200 MB upload into
* the start and compares it against the ref at the end: a slow rename in
* `/workspace` must not drag the pane back out of `src/` because that is where
* the closure happened to be created. The ref moves at the *start* of a
* navigation rather than when the listing lands, because the question being
@@ -68,14 +37,11 @@ export function useFileManager(projectId: string) {
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
const [busy, setBusy] = useState<string | null>(null);
/**
* What just finished. A live region that only ever says "uploading…" tells a
* screen reader user when to start waiting and never when to stop.
* What just finished, for the live region — a rename or a new folder is a
* change a sighted user sees in the grid and a screen reader user does not.
*/
const [completed, setCompleted] = useState<string | null>(null);
const [conflict, setConflict] = useState<UploadConflict | null>(null);
const currentPathRef = useRef(currentPath);
@@ -87,45 +53,29 @@ export function useFileManager(projectId: string) {
*/
const navGeneration = useRef(0);
const startWork = useCallback((note: string) => {
setBusy(note);
setCompleted(null);
}, []);
/**
* Report a failed operation, given the headline this hook would write and the
* raw failures behind it.
* raw failure behind it.
*
* The headline is what the *hook* knows ("Could not rename …"); it is a
* category, not an explanation. Some backend refusals are already a finished
* sentence written for the person reading it — a hidden host folder, a
* container path outside the roots this panel may change — and those used to
* sentence written for the person reading it — a container path outside the
* roots this panel may change, a name it will not create — and those used to
* arrive as the toast's `detail`, which `ToastHost` renders as collapsed
* monospace behind a "Details" button. So the sentence that said what was
* wrong and what to do about it was hidden under a headline that said
* neither. When every failure reduces to the *same* such sentence — which is
* the normal case, since these refusals are about the target directory and so
* fail identically for every file in a batch — it becomes the headline and
* there is nothing left to hide.
* neither. When there is such a sentence it becomes the headline, and there
* is nothing left to hide.
*/
const report = useCallback((message: string, ...causes: unknown[]) => {
const refusals = causes.map(readableRefusal);
const shared =
causes.length > 0 && refusals.every((r) => r !== null)
? [...new Set(refusals as string[])]
: [];
const promoted = shared.length === 1 ? shared[0] : null;
const report = useCallback((message: string, cause: unknown) => {
const promoted = readableRefusal(cause);
useAppState.getState().pushToast({
kind: "error",
message: promoted ?? message,
detail: promoted || causes.length === 0 ? undefined : causes.map(errorText).join("\n"),
detail: promoted ? undefined : errorText(cause),
});
}, []);
const confirm = useCallback((message: string) => {
useAppState.getState().pushToast({ kind: "success", message });
}, []);
const navigate = useCallback(
async (path: string) => {
const mine = ++navGeneration.current;
@@ -163,164 +113,6 @@ export function useFileManager(projectId: string) {
navigate(currentPathRef.current);
}, [navigate]);
/** Copy an entry out to a host path the user picks. */
const downloadFile = useCallback(
async (entry: FileEntry) => {
try {
const hostPath = await save({ defaultPath: entry.name });
if (!hostPath) return;
// Every sibling operation sets `busy`; this one did not, so a 200 MB
// copy was a click, then a frozen-looking pane, then nothing.
startWork(`Saving "${entry.name}" to the host…`);
try {
await commands.downloadContainerFile(projectId, entry.path, hostPath);
setCompleted(`Saved "${entry.name}" to ${hostPath}.`);
confirm(`Saved "${entry.name}" to the host.`);
} finally {
setBusy(null);
}
} catch (e) {
report(`Could not save "${entry.name}" to the host`, e);
}
},
[projectId, startWork, report, confirm],
);
/**
* The pending answer to `conflict`. Kept in a ref rather than state because
* the upload loop is `await`ing it — it needs the resolver, not a re-render.
*/
const conflictResolver = useRef<((choice: OverwriteChoice) => void) | null>(null);
const resolveConflict = useCallback((choice: OverwriteChoice) => {
const resolve = conflictResolver.current;
conflictResolver.current = null;
setConflict(null);
resolve?.(choice);
}, []);
// A pane unmounted mid-prompt (the tab was closed, the container stopped)
// would otherwise leave the upload loop awaiting an answer that can never
// come. Skipping is the safe reading of "the dialog went away".
useEffect(
() => () => {
conflictResolver.current?.("skip-all");
conflictResolver.current = null;
},
[],
);
const askOverwrite = useCallback(
(hostPath: string, directory: string, remaining: number, containerPath: string | null) =>
new Promise<OverwriteChoice>((resolve) => {
// One batch asks one question at a time — the loop awaits each answer —
// so a resolver still sitting here belongs to a *different* batch (two
// drops in flight at once, or a drop landing while the Upload button's
// batch is still copying). Installing over it would leave that batch
// awaiting an answer no dialog can ever produce: a silent hang, with
// its file neither uploaded nor skipped. Skipping it is the same
// reading of "the dialog went away" the unmount cleanup uses.
conflictResolver.current?.("skip");
conflictResolver.current = resolve;
setConflict({
hostPath,
name: baseName(containerPath ?? hostPath),
directory,
remaining,
});
}),
[],
);
/**
* Copy host files into the current directory. Shared by the Upload button and
* the native drag-drop listener, so a dropped file and a picked one take the
* same path — including the one refresh at the end rather than one per file.
*
* The backend refuses to overwrite unless asked to, so a name clash is not a
* failure here: it is a question, and the answer can be given once for the
* whole batch.
*/
const uploadPaths = useCallback(
async (hostPaths: string[]) => {
if (hostPaths.length === 0) return;
// The directory this upload is *for*. Compared against the live ref at
// the end, because the user is free to walk away while it copies.
const target = currentPathRef.current;
startWork(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`);
/** Raw failures, kept unstringified so `report` can read their shape. */
const failures: unknown[] = [];
let uploaded = 0;
let skipped = 0;
/** A "…all" answer, applied to every remaining clash without asking. */
let blanket: OverwriteChoice | null = null;
try {
for (let i = 0; i < hostPaths.length; i++) {
const hostPath = hostPaths[i];
try {
await commands.uploadFileToContainer(projectId, hostPath, target);
uploaded++;
continue;
} catch (e) {
if (!isFileExistsError(e)) {
failures.push(e);
continue;
}
const choice: OverwriteChoice =
blanket ??
(await askOverwrite(
hostPath,
target,
hostPaths.length - i - 1,
fileExistsPath(e),
));
if (choice === "replace-all" || choice === "skip-all") blanket = choice;
if (choice === "skip" || choice === "skip-all") {
skipped++;
continue;
}
}
try {
await commands.uploadFileToContainer(projectId, hostPath, target, true);
uploaded++;
} catch (e) {
failures.push(e);
}
}
} finally {
setBusy(null);
}
const summary =
`Uploaded ${uploaded} item${uploaded === 1 ? "" : "s"}` +
(skipped > 0 ? `, skipped ${skipped}` : "") +
(failures.length > 0 ? `, ${failures.length} failed` : "") +
".";
setCompleted(summary);
if (failures.length > 0) {
report(
failures.length === 1 ? "A file could not be uploaded" : `${failures.length} files could not be uploaded`,
...failures,
);
}
// Only re-list if the user is still looking at the directory this went
// into. Navigating away during a slow copy used to drag the pane back.
if (currentPathRef.current === target) await navigate(target);
},
[projectId, navigate, startWork, report, askOverwrite],
);
const uploadFile = useCallback(async () => {
try {
const selected = await openDialog({ multiple: true, directory: false });
if (!selected) return;
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
} catch (e) {
report("Could not open the file picker", e);
}
}, [uploadPaths, report]);
/**
* Rename in place. `newName` is a bare name — Rust rejects anything with a
* `/` in it, so this can never turn into a move. Resolves true on success so
@@ -368,19 +160,12 @@ export function useFileManager(projectId: string) {
loading,
/** Inline, in-context: why the listing on screen is empty. */
error,
busy,
/** What the last operation finished doing, for the live region. */
completed,
/** An upload waiting for a Replace / Skip answer, or `null`. */
conflict,
resolveConflict,
setError,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
uploadPaths,
renameEntry,
createFolder,
};
+5 -3
View File
@@ -7,8 +7,10 @@
*
* 1. **Which pane is this drop for?** Geometry, and nothing else: is the
* payload position inside my rect? A hidden pane is `display:none` and so
* has a zero-size rect, which is what stops `TerminalView` and `FilesTab`
* both claiming the same drop.
* has a zero-size rect, which is what stops two panes both claiming the
* same drop. `TerminalView` is the only pane that takes dropped files
* today — the Files pane is container-side only — but the routing is what
* keeps it honest when a second one appears.
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
* document-wide, no geometry, no z-order. While a modal or a blocking
* overlay is on screen anywhere, every drop is refused.
@@ -16,7 +18,7 @@
* ## Why there is no z-order test here, and must not be one
*
* A drop that lands underneath a dialog and silently uploads into the
* directory the dialog is covering is the failure mode that matters: it is
* container behind it is the failure mode that matters: it is
* invisible, it writes to the container, and the user did not ask for it.
* Every attempt to be *precise* about which points a dialog covers has gone
* wrong, twice, in opposite directions:
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { errorText, readableRefusal } from "./refusalText";
/**
* Refusals that are a sentence the backend wrote for the person reading it.
* They used to arrive as a toast's `detail`, which renders as collapsed
* monospace behind a "Details" button — so the only part of the message that
* explained anything was the part nobody saw.
*/
describe("readableRefusal", () => {
const hidden =
'the path goes through ".ssh", a hidden folder — Triple-C will not save anything whose folders are not all visible. Choose a visible location.';
const outside =
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
it("recognises the hidden-host-folder refusal, in both directions", () => {
expect(readableRefusal(hidden)).toBe(hidden);
expect(
readableRefusal(
'the path goes through ".aws", a hidden folder — Triple-C will not read anything whose folders are not all visible. Choose a visible location.',
),
).toContain("hidden folder");
});
it("recognises the container write-root refusal", () => {
expect(readableRefusal(outside)).toBe(outside);
});
it("strips a wrapper a JS layer put in front of the sentence", () => {
// `invoke` rejects with the bare string today, but an `Error` anywhere in
// between would otherwise put "Error: " in front of prose meant to be read.
expect(readableRefusal(new Error(hidden))).toBe(hidden);
expect(readableRefusal(`Error: ${hidden}`)).toBe(hidden);
expect(readableRefusal(`Uncaught (in promise) Error: ${outside}`)).toBe(outside);
expect(readableRefusal({ message: `invoke failed: ${outside}` })).toBe(outside);
});
it("says nothing about failures that are not a written refusal", () => {
// Promotion is an improvement, not a fallback: anything unrecognised keeps
// reporting exactly as it did before.
expect(readableRefusal("File too large to upload (900 MB; limit 256 MB)")).toBeNull();
expect(readableRefusal("FILE_EXISTS: /workspace/a.txt already exists")).toBeNull();
expect(readableRefusal("cp: Permission denied")).toBeNull();
expect(readableRefusal(null)).toBeNull();
});
});
describe("errorText", () => {
it("keeps an ordinary message intact", () => {
expect(errorText("cp: cannot create regular file: Permission denied")).toBe(
"cp: cannot create regular file: Permission denied",
);
});
it("reads a message out of a shape `String()` would render as [object Object]", () => {
expect(errorText({ message: "Container not running" })).toBe("Container not running");
expect(errorText({ kind: "NotRunning" })).toBe("NotRunning");
expect(errorText(new Error("Failed to upload file to container: no space left"))).toBe(
"Failed to upload file to container: no space left",
);
});
it("prefers the written refusal when there is one", () => {
expect(errorText(new Error("Folder path is outside the folders this panel can change (/workspace): /etc"))).toBe(
"Folder path is outside the folders this panel can change (/workspace): /etc",
);
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Turning a backend refusal into the sentence a person reads.
*
* Tauri command errors cross the IPC boundary as whatever `serde` made of them:
* a bare string from `Err(String)`, an object from a `#[derive(Serialize)]`
* error enum, or an `Error` if a JS layer wrapped it on the way through. All
* three are the same refusal, and the UI must not read differently depending on
* which one a future refactor produces — so everything here is tolerant about
* the *shape* of an error and picks the most human string out of it.
*/
/**
* Field names a serialised Rust error realistically uses for its discriminant
* and for its human text. `error` is listed as a discriminant field and yet
* routinely carries a whole sentence, which is why a kind string is read as
* prose too.
*/
const KIND_FIELDS = ["kind", "code", "type", "error", "reason"] as const;
const MESSAGE_FIELDS = ["message", "msg", "detail", "description"] as const;
function asRecord(e: unknown): Record<string, unknown> | null {
return typeof e === "object" && e !== null ? (e as Record<string, unknown>) : null;
}
/**
* Every string an error carries, flattened: the error itself if it is one, its
* message-ish fields, and its kind-ish fields. Nesting is followed one level
* because a wrapped error (`{ error: { message: … } }`) is the same refusal.
*/
function stringsIn(e: unknown, depth = 0): string[] {
if (typeof e === "string") return [e];
if (e instanceof Error) return [e.message, e.name];
const record = asRecord(e);
if (!record || depth > 1) return [];
const out: string[] = [];
const walk = (value: unknown) => {
if (typeof value === "string") out.push(value);
else if (value !== undefined) out.push(...stringsIn(value, depth + 1));
};
for (const field of KIND_FIELDS) walk(record[field]);
for (const field of MESSAGE_FIELDS) walk(record[field]);
return out;
}
/**
* Fragments that identify a refusal the backend already wrote **for a person**.
*
* The file commands guard two policies that a user can trip over by accident,
* and both answer with a finished sentence that names the offending path and
* says what to do instead:
*
* the path goes through ".ssh", a hidden folder — Triple-C will not save …
* Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc
*
* Those sentences were being used as the *detail* of a generic toast, and
* `ToastHost` renders a detail as collapsed monospace behind a "Details"
* button — so the one part of the message that explained anything was the part
* nobody saw. Matching them here lets the caller promote the sentence to the
* toast's headline.
*
* Matched on a stable fragment rather than the whole string, because the path
* and the verb ("save"/"read", "file"/"folder") vary per call. Deliberately a
* short list: an error that is *not* recognised still reports exactly as it
* did before, so a wrong guess here can only fail to promote, never mangle.
*/
const REFUSAL_MARKERS = [
// `validate_host_path` — hidden host component, and system locations.
"Triple-C will not",
// `validate_container_write_path` — outside /workspace, /home/claude, /tmp.
"outside the folders this panel can change",
] as const;
/**
* `Error: …`, `TypeError: …`, `invoke failed: …` — wrappers a JS layer may have
* put in front of the backend's sentence on the way through. Stripped so the
* prose starts where the backend started it; applied twice at most, because a
* doubly-wrapped error is the realistic worst case and looping on user text is
* not.
*/
const WRAPPER_PREFIX = /^(?:uncaught\s*(?:\(in promise\)\s*)?)?(?:[a-z]*error|invoke(?:\s+failed)?)\s*:\s*/i;
function stripWrapper(text: string): string {
let out = text.trim();
for (let i = 0; i < 2; i++) {
const next = out.replace(WRAPPER_PREFIX, "").trim();
if (next === out) break;
out = next;
}
return out;
}
/**
* The backend's own user-facing sentence, when this failure is one — otherwise
* `null`, and the caller reports it however it reported everything else.
*/
export function readableRefusal(e: unknown): string | null {
for (const s of stringsIn(e)) {
const text = stripWrapper(s);
if (REFUSAL_MARKERS.some((marker) => text.includes(marker))) return text;
}
return null;
}
/**
* The most human form of any failure, for the places that show one verbatim.
*
* `String(e)` is what these used to be, which turns a serialised error object
* into `[object Object]` and leaves a JS wrapper prefix on a sentence that
* reads perfectly well without it.
*/
export function errorText(e: unknown): string {
const readable = readableRefusal(e);
if (readable) return readable;
if (typeof e === "string") return stripWrapper(e);
if (e instanceof Error) return stripWrapper(e.message);
const record = asRecord(e);
if (record) {
for (const field of [...MESSAGE_FIELDS, ...KIND_FIELDS]) {
const value = record[field];
if (typeof value === "string" && value.trim().length > 0) return stripWrapper(value);
}
}
return String(e);
}
+1 -20
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
import type { Project, ProjectPath, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -7,8 +7,6 @@ export const checkImageExists = () => invoke<boolean>("check_image_exists");
export const buildImage = () => invoke<void>("build_image");
export const getContainerInfo = (projectId: string) =>
invoke<ContainerInfo | null>("get_container_info", { projectId });
export const listSiblingContainers = () =>
invoke<SiblingContainer[]>("list_sibling_containers");
// Projects
export const listProjects = () => invoke<Project[]>("list_projects");
@@ -71,25 +69,8 @@ export const stopAudioBridge = (sessionId: string) =>
// Files
export const listContainerFiles = (projectId: string, path: string) =>
invoke<FileEntry[]>("list_container_files", { projectId, path });
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
/**
* Copy a host file into a container directory.
*
* `overwrite` is opt-in because a drop is aimed with a mouse: the backend
* refuses by default when the name is already taken (see `lib/uploadErrors.ts`
* for the marker that refusal carries), and the caller re-runs with `true`
* only once the user has said "Replace" to that specific file. Leaving it off
* is the safe default every existing caller gets.
*/
export const uploadFileToContainer = (
projectId: string,
hostPath: string,
containerDir: string,
overwrite?: boolean,
) => invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir, overwrite });
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
/** `toPath` is the new *name*, not a destination — renames never move. */
+13 -15
View File
@@ -162,23 +162,28 @@ export interface OpenAiCompatibleConfig {
* project that is "inherit the global value", and on the global settings it is
* "leave Claude Code's own default alone". `false` is a deliberate off, which
* is what lets a project turn a globally-enabled setting back off.
*
* Every field is optional as well as nullable: the Rust struct skips
* serialising a field it has no value for, so an object with nothing set at
* this level arrives as `{}`. Absent and `null` mean the same thing, which is
* why every read of one of these has to use `== null` rather than `=== null`.
*/
export interface ClaudeCodeSettings {
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
tui_mode: string | null;
tui_mode?: string | null;
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
effort: string | null;
auto_scroll_disabled: boolean | null;
effort?: string | null;
auto_scroll_disabled?: boolean | null;
/** Written as `viewMode: "focus"`. */
focus_mode: boolean | null;
show_thinking_summaries: boolean | null;
focus_mode?: boolean | null;
show_thinking_summaries?: boolean | null;
/**
* Turns the session recap **off**. Held in the disabled sense because Claude
* Code's recap is on by default — see the Rust doc on `ClaudeCodeSettings`.
*/
session_recap_disabled: boolean | null;
env_scrub: boolean | null;
prompt_caching_1h: boolean | null;
session_recap_disabled?: boolean | null;
env_scrub?: boolean | null;
prompt_caching_1h?: boolean | null;
}
export interface ContainerInfo {
@@ -188,13 +193,6 @@ export interface ContainerInfo {
image: string;
}
export interface SiblingContainer {
id: string;
names: string[] | null;
image: string;
state: string;
status: string;
}
export interface TerminalSession {
id: string;
-184
View File
@@ -1,184 +0,0 @@
import { describe, expect, it } from "vitest";
import {
errorText,
FILE_EXISTS_MARKER,
fileExistsPath,
isFileExistsError,
readableRefusal,
} from "./uploadErrors";
/**
* The shapes here are the point of the module.
*
* A Tauri command error crosses the IPC boundary as whatever `serde` made of
* it, and the Rust side is free to change from `Err(String)` to a serialised
* error enum without anyone thinking of this file. Every one of these has to
* keep meaning "that name is taken", or an upload that could have been
* retried with `overwrite: true` degrades into a raw string in a toast.
*/
describe("isFileExistsError", () => {
it("recognises the agreed prose form", () => {
expect(isFileExistsError("FILE_EXISTS: /workspace/notes.txt already exists")).toBe(true);
});
it("recognises a bare marker", () => {
expect(isFileExistsError(FILE_EXISTS_MARKER)).toBe(true);
});
it("recognises a serialised error enum, whatever case it is written in", () => {
expect(isFileExistsError({ kind: "FileExists", path: "/workspace/a.txt" })).toBe(true);
expect(isFileExistsError({ code: "file-exists" })).toBe(true);
expect(isFileExistsError({ type: "file_exists" })).toBe(true);
});
it("recognises it inside a message field", () => {
expect(isFileExistsError({ message: "upload refused: FILE_EXISTS" })).toBe(true);
expect(isFileExistsError(new Error("FILE_EXISTS: /workspace/a.txt"))).toBe(true);
});
it("looks one level into a wrapped error", () => {
expect(isFileExistsError({ error: { kind: "FileExists" } })).toBe(true);
});
it("says no to every other failure, which must not raise an overwrite prompt", () => {
expect(isFileExistsError("File too large to upload (900 MB; limit 256 MB)")).toBe(false);
expect(isFileExistsError("cp: cannot create regular file: Permission denied")).toBe(false);
expect(isFileExistsError({ kind: "NotRunning" })).toBe(false);
expect(isFileExistsError(null)).toBe(false);
expect(isFileExistsError(undefined)).toBe(false);
expect(isFileExistsError(42)).toBe(false);
expect(isFileExistsError({})).toBe(false);
});
it("cannot be forged by the name of the file being uploaded", () => {
// The one that mattered. Matching `fileexists` anywhere in a normalised
// error meant a host file called `file-exists.txt` turned *every* failure
// into a collision: the overwrite prompt appeared over a permission error,
// and Replace re-invoked the upload with `overwrite: true`, clobbering
// whatever shared that name in the container.
expect(
isFileExistsError("Failed to upload /host/file-exists.txt: Permission denied"),
).toBe(false);
expect(
isFileExistsError({
message: "cp: cannot create regular file '/workspace/FILE_EXISTS.txt'",
}),
).toBe(false);
expect(isFileExistsError("no space left on device: /host/File Exists.png")).toBe(
false,
);
// A path that merely ends in the marker is a path, not the marker.
expect(isFileExistsError("cannot stat /workspace/FILE_EXISTS: no such file")).toBe(
false,
);
// …while the contract's own shape still reads as the refusal it is.
expect(
isFileExistsError("FILE_EXISTS: /workspace/file-exists.txt already exists"),
).toBe(true);
});
it("still reads a wrapped error whose `error` field is a whole sentence", () => {
// `error` is listed as a discriminant field but routinely carries prose,
// so it is held to both standards.
expect(
isFileExistsError({ error: "FILE_EXISTS: /workspace/a.txt already exists" }),
).toBe(true);
expect(isFileExistsError({ error: "upload of file-exists.txt failed" })).toBe(false);
});
});
describe("fileExistsPath", () => {
it("reads the path out of the agreed prose form", () => {
expect(fileExistsPath("FILE_EXISTS: /workspace/notes.txt already exists")).toBe(
"/workspace/notes.txt",
);
});
it("prefers a structured field", () => {
expect(fileExistsPath({ kind: "FileExists", path: "/workspace/a.txt" })).toBe(
"/workspace/a.txt",
);
expect(fileExistsPath({ kind: "FileExists", container_path: "/workspace/b.txt" })).toBe(
"/workspace/b.txt",
);
});
it("finds one in a wrapped error", () => {
expect(fileExistsPath({ error: { kind: "FileExists", path: "/workspace/c.txt" } })).toBe(
"/workspace/c.txt",
);
});
it("returns null rather than guessing", () => {
// The caller falls back to the host path it was uploading, which is always
// known — so "no path" is a perfectly good answer.
expect(fileExistsPath("FILE_EXISTS")).toBeNull();
expect(fileExistsPath({ kind: "FileExists" })).toBeNull();
expect(fileExistsPath(null)).toBeNull();
});
});
/**
* The other half of the contract: refusals that are *not* a name clash, but are
* a sentence the backend wrote for the person reading it. They used to arrive
* as a toast's `detail`, which renders as collapsed monospace behind a
* "Details" button — so the only part of the message that explained anything
* was the part nobody saw.
*/
describe("readableRefusal", () => {
const hidden =
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
const outside =
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
it("recognises the hidden-host-folder refusal, in both directions", () => {
expect(readableRefusal(hidden)).toBe(hidden);
expect(
readableRefusal('".aws" is a hidden folder — Triple-C will not read there. Choose a visible location.'),
).toContain("hidden folder");
});
it("recognises the container write-root refusal", () => {
expect(readableRefusal(outside)).toBe(outside);
});
it("strips a wrapper a JS layer put in front of the sentence", () => {
// `invoke` rejects with the bare string today, but an `Error` anywhere in
// between would otherwise put "Error: " in front of prose meant to be read.
expect(readableRefusal(new Error(hidden))).toBe(hidden);
expect(readableRefusal(`Error: ${hidden}`)).toBe(hidden);
expect(readableRefusal(`Uncaught (in promise) Error: ${outside}`)).toBe(outside);
expect(readableRefusal({ message: `invoke failed: ${outside}` })).toBe(outside);
});
it("says nothing about failures that are not a written refusal", () => {
// Promotion is an improvement, not a fallback: anything unrecognised keeps
// reporting exactly as it did before.
expect(readableRefusal("File too large to upload (900 MB; limit 256 MB)")).toBeNull();
expect(readableRefusal("FILE_EXISTS: /workspace/a.txt already exists")).toBeNull();
expect(readableRefusal("cp: Permission denied")).toBeNull();
expect(readableRefusal(null)).toBeNull();
});
});
describe("errorText", () => {
it("keeps an ordinary message intact", () => {
expect(errorText("cp: cannot create regular file: Permission denied")).toBe(
"cp: cannot create regular file: Permission denied",
);
});
it("reads a message out of a shape `String()` would render as [object Object]", () => {
expect(errorText({ message: "Container not running" })).toBe("Container not running");
expect(errorText({ kind: "NotRunning" })).toBe("NotRunning");
expect(errorText(new Error("Failed to upload file to container: no space left"))).toBe(
"Failed to upload file to container: no space left",
);
});
it("prefers the written refusal when there is one", () => {
expect(errorText(new Error("Folder path is outside the folders this panel can change (/workspace): /etc"))).toBe(
"Folder path is outside the folders this panel can change (/workspace): /etc",
);
});
});
-264
View File
@@ -1,264 +0,0 @@
/**
* The one place the frontend agrees with Rust about "that name is taken".
*
* `upload_file_to_container` used to clobber whatever was already at the
* destination, which is the wrong default for a drop: a drag is aimed with a
* mouse, and the file it lands on is frequently not the file the user meant to
* replace. So the backend refuses by default and the frontend asks — but only
* if it can tell *this* refusal apart from "permission denied" or "no space
* left", because an overwrite prompt raised over an unrelated failure would
* offer a button that cannot possibly work.
*
* **This module is the contract point, and the Rust half has to hold up its
* end**: `upload_file_to_container` must put `FILE_EXISTS_MARKER` in the error
* it returns when the destination already exists, ideally in the agreed shape
*
* FILE_EXISTS: /workspace/notes.txt already exists
*
* and must accept an `overwrite: bool` argument that skips the check. Nothing
* here parses a human sentence — the marker is the whole agreement, and the
* path is a bonus that is only used to name the file in the prompt.
*
* The predicate is deliberately tolerant about the *shape* of the error rather
* than its wording, because a Tauri command error crosses the IPC boundary as
* whatever `serde` made of it: a bare string from `Err(String)`, an object from
* a `#[derive(Serialize)]` error enum, or an `Error` if a JS layer wrapped it
* on the way through. All three are the same refusal, and the UI must not
* behave differently depending on which one a future refactor produces.
*
* **Tolerant about shape is not the same as tolerant about content.** This
* used to normalise the whole error (lower-case, `_`/`-` stripped) and ask
* whether `fileexists` appeared *anywhere* in it — which a host file named
* `file-exists.txt` satisfies on its way through any error at all. Uploading
* that file and hitting "permission denied" therefore raised the overwrite
* prompt, and answering Replace re-invoked the upload with `overwrite: true`:
* an unrelated failure silently promoted into an overwrite of whatever shared
* the name in the container. So the marker now has to appear in a form a
* *filename* cannot produce:
*
* - in prose, the canonical `FILE_EXISTS` (or `FILE-EXISTS`) in upper case,
* standing alone — end of string, or followed by the `:`/`=` of the agreed
* `FILE_EXISTS: <path>` form. `file-exists.txt`, `FILE_EXISTS.txt` and
* `/workspace/FILE_EXISTS` all fail that, because a filename brings its own
* extension, quote or path separator along with it.
* - in a discriminant field, the *whole* value, case- and separator-insensitive
* (`FileExists`, `file_exists`, `file-exists`, `FileExistsError`) — a
* discriminant is a variant name, not a sentence, so equality is the right
* test and a filename never gets to be one.
*/
/** Marker the backend puts in the error for "a file with this name is already there". */
export const FILE_EXISTS_MARKER = "FILE_EXISTS";
/**
* Structured error shapes carry the marker in a discriminant rather than in
* prose. These are the field names a serialised Rust error realistically uses;
* matching is case-insensitive and ignores `_`/`-` so `FileExists`,
* `file_exists` and `FILE-EXISTS` all read as the same variant.
*/
const KIND_FIELDS = ["kind", "code", "type", "error", "reason"] as const;
const MESSAGE_FIELDS = ["message", "msg", "detail", "description"] as const;
const PATH_FIELDS = ["path", "container_path", "containerPath", "target", "file"] as const;
/** `FileExists` / `file-exists` / `FILE_EXISTS` all normalise to `fileexists`. */
function normaliseKind(value: string): string {
return value.toLowerCase().replace(/[\s_-]/g, "");
}
const KIND_NEEDLE = normaliseKind(FILE_EXISTS_MARKER);
/**
* The marker standing on its own inside a sentence.
*
* Derived from `FILE_EXISTS_MARKER` so the two cannot drift. Upper case is
* load-bearing (a lower-case `file-exists` is a plausible filename, the
* upper-case token is not), and so is the lookahead: the marker must end the
* string or be followed by the `:`/`=` that introduces the path. That is what
* a path or a filename cannot forge — `FILE_EXISTS.txt`, `"FILE_EXISTS"` and
* `/workspace/FILE_EXISTS` are each rejected by one end or the other.
*/
const PROSE_MARKER = new RegExp(
`(?:^|[\\s:;(\\[{"'\`])${FILE_EXISTS_MARKER.replace(/_/g, "[_-]")}(?=$|[\\s:=])`,
);
/** A discriminant *is* the refusal, rather than mentioning it. */
function isFileExistsDiscriminant(value: string): boolean {
const normalised = normaliseKind(value);
return normalised === KIND_NEEDLE || normalised === `${KIND_NEEDLE}error`;
}
function asRecord(e: unknown): Record<string, unknown> | null {
return typeof e === "object" && e !== null ? (e as Record<string, unknown>) : null;
}
/**
* Every string an error carries, flattened: the error itself if it is one, its
* message-ish fields, and its kind-ish fields. Nesting is followed one level
* because a wrapped error (`{ error: { kind: … } }`) is the same refusal.
*/
function stringsIn(e: unknown, depth = 0): string[] {
const { prose, kinds } = partitionStrings(e, depth);
return [...prose, ...kinds];
}
/**
* The same flattening, but keeping track of *where* each string came from.
*
* A discriminant field and a message field are held to different standards
* (see the module comment), so they cannot be pooled. `error` is listed as a
* discriminant field and yet routinely carries a whole sentence, which is why
* a kind string is tested against both rules and a prose string only against
* the prose one.
*/
function partitionStrings(
e: unknown,
depth = 0,
): { prose: string[]; kinds: string[] } {
if (typeof e === "string") return { prose: [e], kinds: [] };
if (e instanceof Error) return { prose: [e.message], kinds: [e.name] };
const record = asRecord(e);
if (!record || depth > 1) return { prose: [], kinds: [] };
const prose: string[] = [];
const kinds: string[] = [];
const walk = (value: unknown, into: string[]) => {
if (typeof value === "string") into.push(value);
else if (value !== undefined) {
const nested = partitionStrings(value, depth + 1);
prose.push(...nested.prose);
kinds.push(...nested.kinds);
}
};
for (const field of KIND_FIELDS) walk(record[field], kinds);
for (const field of MESSAGE_FIELDS) walk(record[field], prose);
return { prose, kinds };
}
/**
* True when the backend refused an upload because the destination is taken.
*
* Accepts a bare string, an `Error`, or an object with a `kind`/`code`
* discriminant or a `message` — see the module comment for why all three have
* to work.
*/
export function isFileExistsError(e: unknown): boolean {
const { prose, kinds } = partitionStrings(e);
return (
kinds.some((s) => isFileExistsDiscriminant(s) || PROSE_MARKER.test(s)) ||
prose.some((s) => PROSE_MARKER.test(s))
);
}
/**
* The container path the conflict is about, when the error carries one — used
* only to name the file in the prompt, so `null` is a perfectly good answer
* and the caller falls back to the host path it was uploading.
*/
export function fileExistsPath(e: unknown): string | null {
const record = asRecord(e);
if (record) {
for (const field of PATH_FIELDS) {
const value = record[field];
if (typeof value === "string" && value.length > 0) return value;
}
// One level down, for `{ error: { path } }`.
for (const field of KIND_FIELDS) {
const nested = fileExistsPath(record[field]);
if (nested) return nested;
}
}
for (const s of stringsIn(e)) {
// The agreed prose form: `FILE_EXISTS: <path>` — everything up to the
// first space after the marker.
const match = new RegExp(`${FILE_EXISTS_MARKER}\\s*[:=]\\s*(\\S+)`).exec(s);
if (match) return match[1];
}
return null;
}
/**
* What the user answered to one conflict. The blanket answers exist because a
* ten-file drop onto a populated directory is ten prompts otherwise, which is
* the kind of dialog people dismiss without reading.
*/
export type OverwriteChoice = "replace" | "skip" | "replace-all" | "skip-all";
/**
* Fragments that identify a refusal the backend already wrote **for a person**.
*
* The file commands guard two policies that a user can trip over by accident,
* and both answer with a finished sentence that names the offending path and
* says what to do instead:
*
* ".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.
* Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc
*
* Those sentences were being used as the *detail* of a generic toast
* ("A file could not be uploaded"), and `ToastHost` renders a detail as
* collapsed monospace behind a "Details" button — so the one part of the
* message that explained anything was the part nobody saw. Matching them here
* lets the caller promote the sentence to the toast's headline.
*
* Matched on a stable fragment rather than the whole string, because the path
* and the verb ("save"/"read", "file"/"folder") vary per call. Deliberately a
* short list: an error that is *not* recognised still reports exactly as it
* did before, so a wrong guess here can only fail to promote, never mangle.
*/
const REFUSAL_MARKERS = [
// `validate_host_path` — hidden host component, and system locations.
"Triple-C will not",
// `validate_container_write_path` — outside /workspace, /home/claude, /tmp.
"outside the folders this panel can change",
] as const;
/**
* `Error: …`, `TypeError: …`, `invoke failed: …` — wrappers a JS layer may have
* put in front of the backend's sentence on the way through. Stripped so the
* prose starts where the backend started it; applied twice at most, because a
* doubly-wrapped error is the realistic worst case and looping on user text is
* not.
*/
const WRAPPER_PREFIX = /^(?:uncaught\s*(?:\(in promise\)\s*)?)?(?:[a-z]*error|invoke(?:\s+failed)?)\s*:\s*/i;
function stripWrapper(text: string): string {
let out = text.trim();
for (let i = 0; i < 2; i++) {
const next = out.replace(WRAPPER_PREFIX, "").trim();
if (next === out) break;
out = next;
}
return out;
}
/**
* The backend's own user-facing sentence, when this failure is one — otherwise
* `null`, and the caller reports it however it reported everything else.
*/
export function readableRefusal(e: unknown): string | null {
for (const s of stringsIn(e)) {
const text = stripWrapper(s);
if (REFUSAL_MARKERS.some((marker) => text.includes(marker))) return text;
}
return null;
}
/**
* The most human form of any failure, for the places that show one verbatim.
*
* `String(e)` is what these used to be, which turns a serialised error object
* into `[object Object]` and leaves a JS wrapper prefix on a sentence that
* reads perfectly well without it.
*/
export function errorText(e: unknown): string {
const readable = readableRefusal(e);
if (readable) return readable;
if (typeof e === "string") return stripWrapper(e);
if (e instanceof Error) return stripWrapper(e.message);
const record = asRecord(e);
if (record) {
for (const field of [...MESSAGE_FIELDS, ...KIND_FIELDS]) {
const value = record[field];
if (typeof value === "string" && value.trim().length > 0) return stripWrapper(value);
}
}
return String(e);
}
+33 -11
View File
@@ -454,27 +454,48 @@ fi
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
# volume, so without this the container keeps trying to run the SSO refresh even
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
# Replace ~/.claude.json atomically: write a sibling temp file, then rename.
#
# `> "$CLAUDE_JSON"` truncates before it writes, so a write that fails part-way
# — a full home volume being the obvious way, and bounding that volume is what
# half this release is about — leaves the file unparseable. It holds the OAuth
# account, and the damage does not self-heal: the next start's `jq` fails on the
# corrupt file, `MERGED` comes back empty, and the `[ -n "$MERGED" ]` guard
# skips the write that would have repaired it. `triple-c-task-runner` has done
# it this way all along.
write_claude_json() {
_wcj_tmp="${CLAUDE_JSON}.triple-c-tmp"
if printf '%s\n' "$1" > "$_wcj_tmp" 2>/dev/null; then
mv -f "$_wcj_tmp" "$CLAUDE_JSON" 2>/dev/null || rm -f "$_wcj_tmp"
else
rm -f "$_wcj_tmp"
echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)"
return 1
fi
# By name, after the rename, so these land on the new inode.
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
}
CLAUDE_JSON="/home/claude/.claude.json"
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
if [ -f "$CLAUDE_JSON" ]; then
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
write_claude_json "$MERGED"
fi
else
printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON"
# No existing file, so there is nothing to destroy — but go through the
# same helper so the owner and mode are set in one place.
write_claude_json "$(printf '{"awsAuthRefresh":"%s"}' "$AWS_SSO_AUTH_REFRESH_CMD")"
fi
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
unset AWS_SSO_AUTH_REFRESH_CMD
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
# Only rewrite when the key is actually present, to avoid a needless jq
# reformat of ~/.claude.json on every start of a non-SSO backend.
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
write_claude_json "$MERGED"
fi
fi
@@ -491,16 +512,17 @@ if [ -f "$CLAUDE_JSON" ]; then
# Only rewrite when the value isn't already true, to avoid a needless jq
# reformat of ~/.claude.json on every single start.
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
# Atomic, via `write_claude_json` — see its comment for why a plain
# `>` on this file can permanently destroy the OAuth login.
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
write_claude_json "$MERGED"
fi
fi
else
printf '{"shiftEnterKeyBindingInstalled":true}\n' > "$CLAUDE_JSON"
# Nothing to destroy, but the helper owns the owner/mode too.
write_claude_json '{"shiftEnterKeyBindingInstalled":true}'
fi
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
# ── Docker socket permissions ────────────────────────────────────────────────
if [ -S /var/run/docker.sock ]; then