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