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:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -71,25 +71,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. */
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user