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
This commit is contained in:
2026-08-23 17:05:56 -07:00
co-authored by Claude Opus 5
parent 168b61d632
commit 06ccb4d818
19 changed files with 761 additions and 2928 deletions
+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",
);
});
});