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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user