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
@@ -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>
</>
<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>
)}