Compare commits
10
Commits
168b61d632
...
016de8f641
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
016de8f641 | ||
|
|
4d1a5a2417 | ||
|
|
a323047964 | ||
|
|
11216c45e3 | ||
|
|
913aa85805 | ||
|
|
e9902f0564 | ||
|
|
7488fc5b70 | ||
|
|
06ccb4d818 | ||
|
|
9472cb3c4c | ||
|
|
dd23a52b41 |
@@ -79,14 +79,34 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||
modals — see "UI conventions" below.
|
||||
- **Files takes drops *in*, and that path does not use HTML5 drag.** Dropping into the pane
|
||||
is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by a
|
||||
hit-test of the physical-pixel payload position against the pane's rect ÷
|
||||
`devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and
|
||||
`TerminalView`'s listener both firing. Keep `lib/dropTarget.ts` and both listeners.
|
||||
- **Getting a file *out* is "Save to host…", and there is no other route.** OS drag-out —
|
||||
`tauri-plugin-drag`, `stage_container_file_for_drag` and its host staging directory — was
|
||||
removed from the ship branch and held back for separate hardening; it lives on
|
||||
- **Files is container-side only. It does no host filesystem I/O, and must not grow any.**
|
||||
The tab lists, opens (text and image viewer), renames and creates folders *inside* the
|
||||
container: `list_container_files`, `read_container_file`, `rename_container_path`,
|
||||
`create_container_directory`. There is no upload button, no "Save to host…", and no
|
||||
drop-into-the-pane. Four successive audits found that host filesystem paths crossing IPC
|
||||
were where the criticals lived — a caller-named host destination for container-controlled
|
||||
bytes, an arbitrary host source read into the container, a `link(2)` upload reservation
|
||||
that succeeded against a directory and failed forever on any filesystem without hard
|
||||
links — so the feature was narrowed rather than fixed a fifth time. If a host path ever
|
||||
needs to reach this pane again, the honest shape is for the *backend* to drive
|
||||
`tauri-plugin-dialog`, so no host path arrives over IPC at all.
|
||||
- **A file gets *in* by being dropped on the Terminal, and *out* through "Back up
|
||||
container".** Those two are the whole host↔container story, they predate the Files work,
|
||||
and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent` is Tauri's
|
||||
native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for *whose*
|
||||
drop it is, a document-wide `dropIsBlocked` for whether the app should accept one at all;
|
||||
keep both halves and keep `PaneVisibility`). Backup is
|
||||
`file_commands::download_container_backup`.
|
||||
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
|
||||
after canonicalisation.** That includes the general hidden-component rule, which
|
||||
deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or
|
||||
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
|
||||
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
|
||||
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
|
||||
through a planted symlink with a perfectly visible name. The two callers left are
|
||||
occasional, so over-refusing is the cheaper mistake.
|
||||
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
|
||||
host staging directory were held back for separate hardening and live on
|
||||
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
|
||||
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
|
||||
a compromised webview start a drag on *any* host path the user can read, and the staging
|
||||
|
||||
+61
-21
@@ -228,7 +228,7 @@ buttons. Below that are six tabs:
|
||||
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
||||
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
||||
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Files** | Browse, view and rename files inside the container — see [Files](#files) for how files get in and out |
|
||||
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||
|
||||
### Sessions
|
||||
@@ -351,7 +351,7 @@ it. The sidebar row carries only the two hover controls.
|
||||
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
|
||||
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
|
||||
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container |
|
||||
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
|
||||
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
|
||||
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
|
||||
@@ -615,18 +615,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
|
||||
|
||||
| Setting | What It Does |
|
||||
|---------|-------------|
|
||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
||||
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
|
||||
| **Session Recap** | Provides context when returning to a session after being away |
|
||||
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
||||
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||
| **Focus Mode** | Summarises tool *calls* to one line each, showing the last prompt and the final response. **Needs the fullscreen renderer** — set TUI Mode to Fullscreen or this does nothing |
|
||||
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||
| **Session Recap** | A one-line recap when you return to the terminal after a few minutes away. **On by default** — the switch is how you turn it off |
|
||||
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
||||
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
| **Prompt Caching (1h)** | Requests a 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
|
||||
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
|
||||
Each switch has three states on a project: **Global** (follow Settings), **On**, and **Off**. Off is a
|
||||
real choice — it overrides a global On, which a project could not previously do.
|
||||
|
||||
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
|
||||
> These map to Claude Code environment variables and `~/.claude/settings.json` keys, and are applied
|
||||
> when the container starts. Changing one stops and recreates the container.
|
||||
>
|
||||
> **Two caveats on an existing project.** Changing any of these recreates the container, and a
|
||||
> recreation commits a new image layer — so flipping switches repeatedly costs disk. And
|
||||
> **TUI Mode, Effort Level, Focus Mode and Session Recap cannot be returned to Global** until the
|
||||
> project's base image is updated: those four are cleared by *removing* a key, and an older image's
|
||||
> startup script ignores the instruction to remove it. Update the base image from the project's
|
||||
> Overview tab first. The other switches work on any image.
|
||||
|
||||
### MCP Servers
|
||||
|
||||
@@ -1161,16 +1170,43 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
|
||||
|
||||
### Files
|
||||
|
||||
The **Files** tab of Project Home browses inside a running container. You can:
|
||||
The **Files** tab of Project Home browses inside a running container. It works entirely on the
|
||||
container side — it never reads or writes anything on your own machine. You can:
|
||||
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation
|
||||
- **Save to host…** — copy any file out to a location you pick. This is the way to get a file out
|
||||
of a container; there is one button per file entry, and the file viewer offers it too
|
||||
- **Upload file** from your host into the current container directory — or **drop files straight
|
||||
onto the pane** from your desktop, which uploads them into the directory on screen
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||
between rows and Enter opens the selected one
|
||||
- **View** a file — double-click it, or press Enter. Text files and images render in a read-only
|
||||
viewer
|
||||
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
|
||||
file between folders
|
||||
- **New folder** in the directory on screen
|
||||
- **Refresh** the directory listing at any time
|
||||
|
||||
The listing shows file names, sizes, and modification dates.
|
||||
The listing shows file names, sizes, and modification dates, and marks symbolic links.
|
||||
|
||||
#### Getting files in and out
|
||||
|
||||
The Files tab deliberately does **not** copy files between your computer and the container. There
|
||||
are two supported routes, and they are the ones to use:
|
||||
|
||||
- **To get a file in:** drag it from your desktop and **drop it onto the Terminal tab**. The file
|
||||
is copied into the container and its path is typed into the terminal for you, ready to hand to
|
||||
Claude Code. (You can also drop it onto the terminal's *Following* toggle — the whole pane is a
|
||||
drop target.)
|
||||
- **To get files out:** use **Back up container** in Project Home's **⋯** overflow menu. It writes
|
||||
a `.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose.
|
||||
For a single file, `cat` it in a terminal, or work in a project folder that is mounted from your
|
||||
host in the first place — those files are already on both sides.
|
||||
|
||||
Both routes refuse a destination whose path passes through a hidden folder — anything with a
|
||||
component beginning with `.`, such as `~/.ssh`, `~/.local/bin` or `~/.config`. That rule catches
|
||||
more than it strictly needs to (a path that happens to resolve through `~/.cache` or
|
||||
`node_modules/.pnpm` is refused as well), and the refusal says which folder tripped it. Choose a
|
||||
visible location such as `~/Documents` or `~/Downloads`.
|
||||
|
||||
If you already keep the project in a folder mounted into the container, the simplest answer is
|
||||
usually neither of the above: edit the file on your host and it is already inside.
|
||||
|
||||
### Terminal Rendering
|
||||
|
||||
@@ -1216,10 +1252,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an
|
||||
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
||||
that was used.
|
||||
|
||||
### Creating Tasks (In the Container)
|
||||
### Creating Tasks
|
||||
|
||||
There is no "add task" form in the app. Create tasks from a terminal in the container — either type
|
||||
the commands yourself in a **Shell** session, or just ask Claude to do it.
|
||||
The quickest route is the **New task** button on a project's **Automation** tab, which gives you a
|
||||
form for the name, the schedule and the prompt.
|
||||
|
||||
You can also create tasks from a terminal in the container — type the commands yourself in a
|
||||
**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to
|
||||
work out the schedule or the prompt for you, and it is what the rest of this section covers.
|
||||
|
||||
### Create a Recurring Task
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ configuration. Per-project configuration lives in the Config tab rather than in
|
||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Files** | Browse, view and rename files inside the container. Container-side only: to get a file *in*, drop it on the Terminal tab; to get files *out*, use **Back up container** |
|
||||
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||
|
||||
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||
@@ -513,7 +513,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) |
|
||||
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||
@@ -536,7 +536,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) |
|
||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
@@ -547,7 +547,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
|
||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||
@@ -561,7 +561,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir) plus `download_container_backup` |
|
||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||
|
||||
+1
-1
@@ -504,7 +504,7 @@ triple-c/
|
||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File browser (list/download/upload)
|
||||
│ ├── file_commands.rs # File browser (browse, view, rename)
|
||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
||||
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is now \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API and never touches this permission; the Files tab is browse, view and rename only. An earlier version of this sentence pointed at a \"Save to host…\" action, which was removed in the same round that removed drag-out — this file is the reviewed threat model of record, so a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -37,20 +37,3 @@ pub async fn get_container_info(
|
||||
docker::get_container_info(&project).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
|
||||
let containers = docker::list_sibling_containers().await?;
|
||||
let result: Vec<serde_json::Value> = containers
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"names": c.names,
|
||||
"image": c.image,
|
||||
"state": c.state,
|
||||
"status": c.status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1071,21 +1071,54 @@ fn reconcile_retries() -> &'static std::sync::Mutex<std::collections::HashSet<St
|
||||
RETRIES.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// One project's place in [`reconcile_retries`], handed back on drop.
|
||||
///
|
||||
/// RAII for the reason [`crate::project_lock::ProjectGuard`] sets out, and this
|
||||
/// claim is the case that proves the rule: the release used to be a trailing
|
||||
/// statement at the bottom of the spawned task in
|
||||
/// [`defer_migration_reconcile`], sitting after an `.await` on
|
||||
/// [`reconcile_migration_now`]. A panic in there — or the future simply being
|
||||
/// dropped, which is what happens to every in-flight task at shutdown — skips
|
||||
/// the statement, and nothing else ever removes an id from that set. The
|
||||
/// project is then fenced off from *every* later deferral for the rest of the
|
||||
/// process: each `reconcile_project_statuses` pass finds it held, fails to
|
||||
/// claim, and returns, so the phase stays un-normalised, no resume or rollback
|
||||
/// is offered, and the `:pre-migration-*` pin stays `Claimed`. That is the
|
||||
/// session-long silence deferring was written to end, reintroduced one panic
|
||||
/// later and lasting until the app is restarted.
|
||||
///
|
||||
/// Dropping this hands the claim straight back, so a caller that discards the
|
||||
/// value has claimed nothing while reading as though it had; `#[must_use]`
|
||||
/// makes that a compile warning rather than a second waiter on one record.
|
||||
#[must_use = "the claim is handed back the moment this guard drops; bind it inside the waiting task, for the whole task"]
|
||||
struct ReconcileRetryClaim {
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
impl Drop for ReconcileRetryClaim {
|
||||
fn drop(&mut self) {
|
||||
// `into_inner` past poisoning, as in `project_lock`: the only thing
|
||||
// ever done while holding this mutex is a single `HashSet` insert or
|
||||
// remove, so a panic on another thread cannot have left it half
|
||||
// written — and declining to release here would strand the project
|
||||
// permanently, which is the exact failure the guard exists to stop.
|
||||
reconcile_retries()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim the right to be the one deferred reconcile for `project_id`.
|
||||
/// `false` means somebody else already is.
|
||||
fn claim_reconcile_retry(project_id: &str) -> bool {
|
||||
/// `None` means somebody else already is.
|
||||
fn claim_reconcile_retry(project_id: &str) -> Option<ReconcileRetryClaim> {
|
||||
reconcile_retries()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(project_id.to_string())
|
||||
}
|
||||
|
||||
/// Give the claim back, so a later `reconcile_project_statuses` can defer again.
|
||||
fn release_reconcile_retry(project_id: &str) {
|
||||
reconcile_retries()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(project_id);
|
||||
.then(|| ReconcileRetryClaim {
|
||||
project_id: project_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Come back to a project that was held when [`reconcile_migration`] reached it.
|
||||
@@ -1108,13 +1141,19 @@ fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
|
||||
if !migration_store::has_record(&project.id).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
if !claim_reconcile_retry(&project.id) {
|
||||
let Some(claim) = claim_reconcile_retry(&project.id) else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let project = project.clone();
|
||||
let app_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Moved in and bound for the whole body, rather than released by a
|
||||
// statement at the bottom: everything below this line can panic or be
|
||||
// dropped mid-await, and a claim that only comes back on the happy path
|
||||
// is a claim that eventually does not come back at all. See
|
||||
// [`ReconcileRetryClaim`].
|
||||
let _claim = claim;
|
||||
let released =
|
||||
await_release(&project.id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await;
|
||||
if released {
|
||||
@@ -1133,28 +1172,42 @@ fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
|
||||
RECONCILE_RETRY_INTERVAL.as_secs() as usize * RECONCILE_RETRY_ATTEMPTS / 60
|
||||
);
|
||||
}
|
||||
release_reconcile_retry(&project.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for `project_id` to stop being held, up to `attempts` looks
|
||||
/// `interval` apart. `true` means it was released, `false` that the budget ran
|
||||
/// out with it still held.
|
||||
/// Wait for `project_id` to stop being held: `attempts` looks, the first
|
||||
/// immediate and the rest `interval` apart. `true` means it was released,
|
||||
/// `false` that the budget ran out with it still held.
|
||||
///
|
||||
/// Split out of [`defer_migration_reconcile`] so the waiting can be tested
|
||||
/// against a real [`crate::project_lock`] guard on a paused clock — the part
|
||||
/// that is easy to get wrong is "gives up while still holding the claim" and
|
||||
/// "never looks again", neither of which is visible from the constants.
|
||||
/// against a real [`crate::project_lock`] guard on a paused clock — the parts
|
||||
/// that are easy to get wrong are "gives up while still holding the claim",
|
||||
/// "never looks again", and the ordering of the look against the sleep, none of
|
||||
/// which is visible from the constants.
|
||||
async fn await_release(
|
||||
project_id: &str,
|
||||
interval: std::time::Duration,
|
||||
attempts: usize,
|
||||
) -> bool {
|
||||
for _ in 0..attempts {
|
||||
tokio::time::sleep(interval).await;
|
||||
for attempt in 0..attempts {
|
||||
// Look first, sleep second. Sleeping first charged every deferral a
|
||||
// full interval before anyone read the map even once, and the common
|
||||
// case is a holder that has already let go: `held()` is sampled in
|
||||
// `reconcile_migration`, a task is spawned, and by the time it is first
|
||||
// polled the Reset that was on its last step is frequently finished.
|
||||
// That bought nothing and cost twenty seconds of a startup pass waiting
|
||||
// on a lock nobody holds, in front of a check that is one `HashMap`
|
||||
// lookup.
|
||||
if crate::project_lock::held(project_id).is_none() {
|
||||
return true;
|
||||
}
|
||||
// And no sleep after the final look: nothing reads the map again
|
||||
// afterwards, so it is twenty seconds of delay in front of a `false`
|
||||
// that has already been decided. The budget is still `attempts` looks,
|
||||
// which is what the constants above are chosen against.
|
||||
if attempt + 1 < attempts {
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -2217,6 +2270,34 @@ mod tests {
|
||||
assert!(budget >= std::time::Duration::from_secs(15 * 60), "{:?}", budget);
|
||||
}
|
||||
|
||||
/// MEDIUM: an unheld project is reconciled now, not in twenty seconds.
|
||||
///
|
||||
/// The wait slept before its first look, so a holder that let go between
|
||||
/// `reconcile_migration` sampling `held()` and this task being polled — the
|
||||
/// *common* case, since a deferral is only taken when something was on its
|
||||
/// way out — still cost a full `RECONCILE_RETRY_INTERVAL` of a startup pass
|
||||
/// waiting on a lock nobody held. On a paused clock the assertion is exact:
|
||||
/// the fixed shape returns without the clock moving at all, the sleep-first
|
||||
/// shape cannot return before it has advanced one interval.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn an_unheld_project_is_seen_without_waiting_out_an_interval() {
|
||||
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||
assert!(
|
||||
crate::project_lock::held(&id).is_none(),
|
||||
"a fresh uuid is not held"
|
||||
);
|
||||
|
||||
let before = tokio::time::Instant::now();
|
||||
assert!(await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
|
||||
let waited = tokio::time::Instant::now() - before;
|
||||
assert_eq!(
|
||||
waited,
|
||||
std::time::Duration::ZERO,
|
||||
"an already-released project cost {:?} before anyone looked",
|
||||
waited
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_deferred_reconcile_waits_per_project() {
|
||||
// Every "Docker became available" walks every project, so without the
|
||||
@@ -2224,14 +2305,72 @@ mod tests {
|
||||
// per call — all of which then reconcile the same record in a row.
|
||||
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||
let other = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||
assert!(claim_reconcile_retry(&id));
|
||||
assert!(!claim_reconcile_retry(&id), "a second waiter was allowed in");
|
||||
assert!(claim_reconcile_retry(&other), "the claim is not per-project");
|
||||
release_reconcile_retry(&id);
|
||||
assert!(claim_reconcile_retry(&id), "the claim was never handed back");
|
||||
release_reconcile_retry(&id);
|
||||
release_reconcile_retry(&other);
|
||||
// Releasing something that was never claimed is not an error.
|
||||
release_reconcile_retry(&id);
|
||||
let first = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||
assert!(
|
||||
claim_reconcile_retry(&id).is_none(),
|
||||
"a second waiter was allowed in"
|
||||
);
|
||||
let other_claim =
|
||||
claim_reconcile_retry(&other).expect("the claim is not per-project");
|
||||
drop(first);
|
||||
// Bound rather than discarded: the guard releases on drop, so
|
||||
// `claim_reconcile_retry(&id);` as a bare statement would test nothing
|
||||
// — which is what `#[must_use]` is there to catch in real callers.
|
||||
let retaken = claim_reconcile_retry(&id).expect("the claim was never handed back");
|
||||
drop(retaken);
|
||||
drop(other_claim);
|
||||
// And the other project's claim was never the same claim.
|
||||
drop(claim_reconcile_retry(&other).expect("released independently"));
|
||||
}
|
||||
|
||||
/// MEDIUM: the claim survives the task that holds it dying badly.
|
||||
///
|
||||
/// The release used to be a trailing statement after
|
||||
/// `reconcile_migration_now(...).await` at the bottom of the spawned task,
|
||||
/// so a panic anywhere in that call — or the future being dropped at
|
||||
/// shutdown — skipped it and left the id in the set with no task behind it.
|
||||
/// Nothing removes it afterwards, so that project could never be deferred
|
||||
/// again for the rest of the process: exactly the state deferring was added
|
||||
/// to prevent, now permanent instead of one pass long. Fails against the
|
||||
/// trailing-statement shape, which is the point.
|
||||
#[tokio::test]
|
||||
async fn a_panicking_deferred_reconcile_hands_its_claim_back() {
|
||||
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||
let claimed = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||
|
||||
// Spawned, not just called: the real claim is held across an await
|
||||
// inside a `tauri::async_runtime::spawn`, and a task panic is caught by
|
||||
// the runtime rather than unwinding the caller.
|
||||
let task = {
|
||||
let id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _claim = claimed;
|
||||
tokio::task::yield_now().await;
|
||||
panic!("reconcile_migration_now blew up on '{}'", id);
|
||||
})
|
||||
};
|
||||
assert!(task.await.is_err(), "the task was supposed to panic");
|
||||
|
||||
let after = claim_reconcile_retry(&id);
|
||||
assert!(
|
||||
after.is_some(),
|
||||
"a panicking reconcile stranded the claim — this project can never be \
|
||||
deferred again for the rest of the process"
|
||||
);
|
||||
drop(after);
|
||||
|
||||
// The other half of the same failure: a task that is simply dropped
|
||||
// mid-flight, which is every in-flight task at shutdown.
|
||||
let claimed = claim_reconcile_retry(&id).expect("released above");
|
||||
let never_finishes = tokio::spawn(async move {
|
||||
let _claim = claimed;
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
never_finishes.abort();
|
||||
let _ = never_finishes.await;
|
||||
assert!(
|
||||
claim_reconcile_retry(&id).is_some(),
|
||||
"a dropped task stranded the claim"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,9 @@ pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
||||
/// entire host filesystem read-write into a container whose agent has
|
||||
/// passwordless sudo. Anything short of a filesystem root is the user choosing
|
||||
/// a folder — the Browse button and the free-text field lead to the same place
|
||||
/// — so only the roots themselves are refused.
|
||||
/// — so only the roots themselves are refused, and *root* is answered by
|
||||
/// resolving the path rather than by reading it: `/..` is spelled like a folder
|
||||
/// and is the root. See [`classify_mount_source`].
|
||||
///
|
||||
/// This is the **whole** rule set, and it belongs to `add_project`, where every
|
||||
/// row is new by definition. `update_project` runs
|
||||
@@ -228,18 +230,32 @@ fn validate_one_path(p: &ProjectPath) -> Result<(), String> {
|
||||
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
|
||||
}
|
||||
check_mount_name_stays_under_workspace(&p.mount_name)?;
|
||||
if p.host_path.is_empty() {
|
||||
// Trimmed: a host path of spaces is not a folder, and `classify_mount_source`
|
||||
// is deliberately silent about a path with nothing in it — this is the
|
||||
// message that names the mount it belongs to.
|
||||
if p.host_path.trim().is_empty() {
|
||||
return Err(format!(
|
||||
"Folder mounted at '/workspace/{}' has no host path.",
|
||||
p.mount_name
|
||||
));
|
||||
}
|
||||
if is_filesystem_root(&p.host_path) {
|
||||
match classify_mount_source(&p.host_path) {
|
||||
None => {}
|
||||
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
|
||||
return Err(filesystem_root_message(
|
||||
&p.host_path,
|
||||
&resolved,
|
||||
"using it as a project folder",
|
||||
));
|
||||
}
|
||||
Some(UnmountableHostPath::NotAbsolute) => {
|
||||
return Err(format!(
|
||||
"'{}' is a filesystem root. Choose the project folder itself — mounting the whole drive gives the container everything on it.",
|
||||
"'{}' is not a full path to a folder — where it lands is decided by wherever \
|
||||
Triple-C is running from rather than by you. Give the whole path.",
|
||||
p.host_path
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -264,6 +280,23 @@ fn check_mount_name_stays_under_workspace(mount_name: &str) -> Result<(), String
|
||||
mount_name
|
||||
));
|
||||
}
|
||||
// **An empty name is not refused here, and that is a live residual.** It
|
||||
// makes the target `/workspace/`, which the daemon normalises to
|
||||
// `/workspace` — so the host folder shadows the directory the other mounts
|
||||
// land in, and Docker then creates their mount points *inside it*, on the
|
||||
// host. It is not refused because it cannot be: an empty name is what a
|
||||
// half-filled row holds, `legacy_rows` shows those are already in
|
||||
// `projects.json`, and this function runs on grandfathered rows too, so
|
||||
// refusing it would make every such project unsavable — the exact
|
||||
// regression [`validate_project_paths_update`] exists to prevent.
|
||||
//
|
||||
// Introducing one is closed at both ends: `validate_one_path` refuses an
|
||||
// empty name on any new or edited row, and `WorkspaceSection` no longer
|
||||
// sends half-filled or blank ones. What remains is the *stored* row, and it
|
||||
// belongs where the mount is built — `docker::create_container` should skip
|
||||
// a row with no mount name or no host path, which is the same filter that
|
||||
// stops a stored blank row failing the create with
|
||||
// `field Source must not be empty`.
|
||||
if !mount_name.is_empty() && mount_name.chars().all(|c| c == '.') {
|
||||
return Err(format!(
|
||||
"Mount name '{}' is not a folder name — it names the directory the mount would sit in.",
|
||||
@@ -363,12 +396,13 @@ fn validate_project_paths_update(
|
||||
/// `/tmp/.host-ssh` and `/tmp/.host-ca` — and neither had any check at all, so
|
||||
/// `/` handed the whole host filesystem to the agent to read. Read-only, so
|
||||
/// this is disclosure rather than the read-write hole a `/` project folder is,
|
||||
/// but the fix is the same one line.
|
||||
/// but it is the same check: [`classify_mount_source`], resolved rather than
|
||||
/// spelled, so `/..` and `/home/..` are refused here too.
|
||||
///
|
||||
/// Same grandfathering as the folder list, for the same reason: a value already
|
||||
/// stored is already mounted on every start, and refusing an unrelated save
|
||||
/// does not unmount it. Only a *change* is held to the rule.
|
||||
fn validate_mounted_host_path(
|
||||
pub(crate) fn validate_mounted_host_path(
|
||||
label: &str,
|
||||
stored: Option<&str>,
|
||||
incoming: Option<&str>,
|
||||
@@ -379,29 +413,255 @@ fn validate_mounted_host_path(
|
||||
if stored.map(str::trim) == Some(value) {
|
||||
return Ok(());
|
||||
}
|
||||
if is_filesystem_root(value) {
|
||||
match classify_mount_source(value) {
|
||||
None => {}
|
||||
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
|
||||
return Err(filesystem_root_message(
|
||||
value,
|
||||
&resolved,
|
||||
&format!("setting it as {}", label),
|
||||
));
|
||||
}
|
||||
Some(UnmountableHostPath::NotAbsolute) => {
|
||||
return Err(format!(
|
||||
"'{}' is a filesystem root, so setting it as {} would mount the whole drive into the \
|
||||
container. Choose the folder itself.",
|
||||
"'{}' is not a full path, so it cannot be used as {}. Give the whole path.",
|
||||
value, label
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a host path is the root of a filesystem, in any spelling the three
|
||||
/// desktop platforms produce: `/`, a Windows drive root, or a bare UNC/share
|
||||
/// prefix. Trailing separators are ignored, so `C:\\` and `C:/` are the same
|
||||
/// answer.
|
||||
fn is_filesystem_root(host_path: &str) -> bool {
|
||||
let trimmed = host_path.trim_end_matches(['/', '\\']);
|
||||
if trimmed.is_empty() {
|
||||
// Nothing but separators: `/`, `\\`, `//`.
|
||||
return true;
|
||||
/// Why a host path may not be used as the source of a bind mount.
|
||||
///
|
||||
/// Two answers rather than a `bool` because they need different sentences, and
|
||||
/// because "is a root" is no longer a question about how the path is *spelled*
|
||||
/// — the refusal has to be able to say where the path actually landed.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum UnmountableHostPath {
|
||||
/// The path is, or resolves to, the root of a filesystem. `resolved` is
|
||||
/// what it lands on, which is the same string only when a root was typed
|
||||
/// outright.
|
||||
FilesystemRoot { resolved: String },
|
||||
/// The path does not name a location at all. Where it lands is decided by
|
||||
/// whatever directory Triple-C happens to be running from, so it can be a
|
||||
/// root tomorrow and a folder today, and nothing here can judge it.
|
||||
NotAbsolute,
|
||||
}
|
||||
// `C:` — a drive with no path on it.
|
||||
let bytes = trimmed.as_bytes();
|
||||
bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
|
||||
|
||||
/// Length of a `C:` drive prefix at the head of `path`, or 0.
|
||||
///
|
||||
/// Duplicated from `commands::file_commands::drive_prefix_len`, together with
|
||||
/// [`is_windows_style_path`] and [`normalize_host_path`] below. Those are
|
||||
/// private to that module and it is not this branch's file to change; if the
|
||||
/// two copies are ever merged, that one is the original and carries the wider
|
||||
/// test coverage.
|
||||
fn drive_prefix_len(path: &str) -> usize {
|
||||
let b = path.as_bytes();
|
||||
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is written in Windows form, and so whether `\` separates its
|
||||
/// components. On Linux a backslash is an ordinary filename character, which is
|
||||
/// why this is a question rather than an unconditional substitution.
|
||||
///
|
||||
/// Copy of `file_commands::is_windows_style_path` — see [`drive_prefix_len`].
|
||||
fn is_windows_style_path(path: &str) -> bool {
|
||||
cfg!(windows) || path.starts_with("\\\\") || drive_prefix_len(path) > 0
|
||||
}
|
||||
|
||||
/// `path` with its separators unified and any Win32 verbatim/device prefix
|
||||
/// removed — the form every rule below is expressed against.
|
||||
///
|
||||
/// `\\?\C:\Windows` and `\\?\UNC\server\share` name the *same locations* as
|
||||
/// `C:\Windows` and `\\server\share`; the prefix only turns off Win32 path
|
||||
/// parsing. Stripping it is what stops four characters being a bypass — and it
|
||||
/// has to run on our own output as well, because `std::fs::canonicalize` hands
|
||||
/// back exactly that spelling on Windows.
|
||||
///
|
||||
/// Copy of `file_commands::normalize_host_path` — see [`drive_prefix_len`].
|
||||
fn normalize_host_path(path: &str) -> String {
|
||||
let mut s = if is_windows_style_path(path) {
|
||||
path.replace('\\', "/")
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
// Slicing by byte index is safe here only because a prefix matched
|
||||
// case-insensitively as ASCII is ASCII, so its end is a char boundary.
|
||||
for prefix in ["//?/unc/", "//./unc/"] {
|
||||
if s.len() >= prefix.len()
|
||||
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
|
||||
{
|
||||
return format!("//{}", &s[prefix.len()..]);
|
||||
}
|
||||
}
|
||||
for prefix in ["//?/", "//./"] {
|
||||
if s.len() >= prefix.len()
|
||||
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
|
||||
{
|
||||
s = s[prefix.len()..].to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A normalised absolute path split into the root it hangs off and the part
|
||||
/// below it, or `None` when it names no location at all.
|
||||
///
|
||||
/// The three roots the desktop platforms have: `/`, a drive (`C:/`), and a UNC
|
||||
/// share (`//server/share` — the share *is* the root; `//server` alone names a
|
||||
/// machine and nothing on it).
|
||||
fn split_host_root(norm: &str) -> Option<(&str, &str)> {
|
||||
if let Some(rest) = norm.strip_prefix("//") {
|
||||
let mut parts = rest.splitn(3, '/');
|
||||
let server = parts.next().unwrap_or("");
|
||||
let share = parts.next().unwrap_or("");
|
||||
if server.is_empty() || share.is_empty() {
|
||||
// `//server`, `//server/`: no share, so nothing under it is named.
|
||||
return Some((norm, ""));
|
||||
}
|
||||
let root_len = 2 + server.len() + 1 + share.len();
|
||||
return Some((&norm[..root_len], &norm[root_len..]));
|
||||
}
|
||||
let drive = drive_prefix_len(norm);
|
||||
if drive > 0 {
|
||||
// `C:x` is drive-*relative* — it means "x under the current directory
|
||||
// on C:", which is a location only the process's own state decides.
|
||||
return match norm[drive..].strip_prefix('/') {
|
||||
Some(tail) => Some((&norm[..drive + 1], tail)),
|
||||
None if norm.len() == drive => Some((norm, "")),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
norm.strip_prefix('/').map(|tail| (&norm[..1], tail))
|
||||
}
|
||||
|
||||
/// How many named components deep `tail` ends up, with `.` dropped and `..`
|
||||
/// applied — clamped at the root, because `/..` is `/` and not an error.
|
||||
fn depth_below_root(tail: &str) -> usize {
|
||||
let mut depth = 0usize;
|
||||
for segment in tail.split('/') {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => depth = depth.saturating_sub(1),
|
||||
_ => depth += 1,
|
||||
}
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
/// Whether a host path can be handed to Docker as a bind-mount source, and if
|
||||
/// not, why.
|
||||
///
|
||||
/// ## Resolved, not spelled
|
||||
///
|
||||
/// This used to be `is_filesystem_root`, and it was purely lexical: trim the
|
||||
/// trailing separators, say yes to what was left over only if it was empty or a
|
||||
/// bare `C:`. Nothing in this file called `canonicalize`, so `/..`, `/./`,
|
||||
/// `/home/..`, `/etc/../` and `C:\..` all sailed through and were passed
|
||||
/// verbatim to `docker::create_container`, which builds a
|
||||
/// `Mount { source, read_only: Some(false) }` out of them. Verified against the
|
||||
/// daemon: `-v /..:/mnt/probe` mounts the host root. That is the whole host
|
||||
/// filesystem, read-write, in a container whose agent has passwordless sudo —
|
||||
/// the same escalation `check_mount_name_stays_under_workspace` exists to
|
||||
/// close, reached through the host-path half of the mount instead of the
|
||||
/// mount-name half.
|
||||
///
|
||||
/// So the answer comes from the OS where the OS can give one: `canonicalize`
|
||||
/// applies `..`, follows every symlink in the path, and on Windows returns the
|
||||
/// long name for an 8.3 alias and the verbatim spelling of a UNC share — all
|
||||
/// things a string comparison cannot see.
|
||||
///
|
||||
/// ## When the path cannot be resolved
|
||||
///
|
||||
/// `canonicalize` fails on a path that does not exist *here*, which is an
|
||||
/// ordinary state rather than an attack: `projects.json` syncs between machines
|
||||
/// and names `C:\Users\jo\code` on a box that has never had a `C:`, and a
|
||||
/// folder can be created after the project is. Refusing outright would make
|
||||
/// every such project unsavable, which is the exact failure
|
||||
/// [`validate_project_paths_update`] exists to avoid — so an unresolvable path
|
||||
/// falls back to the lexical answer, with `.` and `..` collapsed by
|
||||
/// [`depth_below_root`] rather than ignored.
|
||||
///
|
||||
/// That is a weaker guarantee, not a wrong one, and the gap is bounded: what
|
||||
/// resolution adds over the lexical rule is symlinks and 8.3 aliases, and both
|
||||
/// of those are properties of a path that *exists* — precisely the case where
|
||||
/// `canonicalize` answers. What is left is a path that does not exist at save
|
||||
/// time and is a symlink to the root by the time the container starts, i.e. the
|
||||
/// user doing it to themselves after being asked.
|
||||
///
|
||||
/// Blocking on the filesystem here is deliberate: this runs on a save, once per
|
||||
/// row, and is a single `realpath` walk.
|
||||
fn classify_mount_source(host_path: &str) -> Option<UnmountableHostPath> {
|
||||
let raw = host_path.trim();
|
||||
if raw.is_empty() {
|
||||
// Emptiness is somebody else's error message — see
|
||||
// `validate_one_path`, which names the mount it belongs to.
|
||||
return None;
|
||||
}
|
||||
|
||||
// Nothing but separators, in either spelling: `/`, `//`, `\`, `\\`. Taken
|
||||
// first because a lone `\` is a *relative* name on Linux, and reporting a
|
||||
// Windows root as "not a full path" would be answering a question the user
|
||||
// did not ask.
|
||||
if raw.chars().all(|c| c == '/' || c == '\\') {
|
||||
return Some(UnmountableHostPath::FilesystemRoot {
|
||||
resolved: raw.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Absoluteness is judged on what the user typed, **before** resolution.
|
||||
//
|
||||
// `canonicalize` resolves a relative path against Triple-C's own working
|
||||
// directory, so it hands back an absolute path and the `NotAbsolute` branch
|
||||
// below never fires — it was reachable only when canonicalize *failed*,
|
||||
// i.e. only for relative paths that happened not to exist. That made the
|
||||
// verdict depend on where the app was launched from: `.` and `..` were
|
||||
// accepted from the repo, refused from `/`. The daemon then refuses the
|
||||
// mount outright (`invalid mount path: '..' mount path must be absolute`),
|
||||
// so the project saved cleanly and could never start again — the bricking
|
||||
// mode `project_path_mounts`'s filter exists to prevent, reached through
|
||||
// the host-path half of the row instead of the mount-name half.
|
||||
if split_host_root(&normalize_host_path(raw)).is_none() {
|
||||
return Some(UnmountableHostPath::NotAbsolute);
|
||||
}
|
||||
|
||||
let canonical = std::fs::canonicalize(raw)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned());
|
||||
let judged = canonical.as_deref().unwrap_or(raw);
|
||||
let norm = normalize_host_path(judged);
|
||||
|
||||
let Some((root, tail)) = split_host_root(&norm) else {
|
||||
return Some(UnmountableHostPath::NotAbsolute);
|
||||
};
|
||||
if depth_below_root(tail) == 0 {
|
||||
return Some(UnmountableHostPath::FilesystemRoot {
|
||||
resolved: root.to_string(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The refusal for a host path that lands on a filesystem root, naming the
|
||||
/// resolved location as well as what was typed when those differ. `/..` reads
|
||||
/// as a folder; `/..` *is* `/`, and a message that only quoted it back would
|
||||
/// leave the user with nothing to act on.
|
||||
fn filesystem_root_message(typed: &str, resolved: &str, use_for: &str) -> String {
|
||||
let where_it_lands = if typed.trim() == resolved {
|
||||
format!("'{}' is a filesystem root", typed)
|
||||
} else {
|
||||
format!("'{}' resolves to '{}', which is a filesystem root", typed, resolved)
|
||||
};
|
||||
format!(
|
||||
"{}, so {} would mount the whole drive into the container. Choose the folder itself.",
|
||||
where_it_lands, use_for
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -510,7 +770,7 @@ pub async fn update_project(
|
||||
// Fields this command does not get to write, whoever is calling it.
|
||||
//
|
||||
// `container_id` is the one that matters: it is the handle the whole file
|
||||
// command surface resolves against, `list_sibling_containers` hands the
|
||||
// command surface resolves against, `list_sibling_containers` used to hand the
|
||||
// webview the ids of every other container on the daemon, and a project
|
||||
// save is not the place a container is adopted. It is assigned by
|
||||
// `start_project_container` through `projects_store::set_container_id` and
|
||||
@@ -551,6 +811,12 @@ pub async fn update_project(
|
||||
project.ca_cert_path.as_deref(),
|
||||
)?;
|
||||
|
||||
// Custom env var names had no charset check anywhere, so a key like
|
||||
// `BASH_FUNC_stat%%` reached the container environment verbatim. Same
|
||||
// grandfathering as the folder list, for the same reason — see
|
||||
// [`crate::models::validate_env_vars_update`].
|
||||
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
|
||||
|
||||
project.container_id = stored.container_id;
|
||||
project.status = stored.status;
|
||||
project.created_at = stored.created_at;
|
||||
@@ -1159,6 +1425,161 @@ mod tests {
|
||||
assert!(validate_project_paths(&[path("C:\\Users\\u\\project", "project")]).is_ok());
|
||||
}
|
||||
|
||||
/// Every spelling of a root that is not *spelled* like one.
|
||||
///
|
||||
/// The predicate this replaces trimmed trailing separators and compared
|
||||
/// what was left, so `/..` — which the daemon mounts as the host root,
|
||||
/// verified with `docker run -v /..:/mnt/probe` — was indistinguishable
|
||||
/// from a project folder called `..`. No test in the repo contained a `.`
|
||||
/// or a `..` in a host path, which is why it shipped.
|
||||
#[test]
|
||||
fn a_host_path_that_resolves_to_a_root_is_refused() {
|
||||
let escapes = [
|
||||
"/..",
|
||||
"/../",
|
||||
"/./",
|
||||
"/.",
|
||||
"/home/..",
|
||||
"/etc/../",
|
||||
"/tmp/../..",
|
||||
// Deliberately not present on any machine, so this is the
|
||||
// unresolvable path taking the lexical route.
|
||||
"/no-such-dir-here/../..",
|
||||
"C:\\..",
|
||||
"C:\\Users\\..",
|
||||
"c:/foo/..",
|
||||
// Win32 verbatim spelling of a drive root.
|
||||
"\\\\?\\C:\\",
|
||||
"\\\\?\\C:\\..",
|
||||
// A UNC share root is the root of everything on that share, which
|
||||
// the old predicate accepted despite its doc comment claiming
|
||||
// otherwise.
|
||||
"\\\\server\\share",
|
||||
"//server/share/",
|
||||
];
|
||||
for escape in escapes {
|
||||
assert!(
|
||||
validate_project_paths(&[path(escape, "everything")]).is_err(),
|
||||
"host path '{}' was accepted as a project folder, which bind-mounts a whole \
|
||||
filesystem read-write into a container with passwordless sudo",
|
||||
escape
|
||||
);
|
||||
// The same value must not be reachable through the editor either.
|
||||
assert!(
|
||||
validate_project_paths_update(&[], &[path(escape, "everything")]).is_err(),
|
||||
"host path '{}' was accepted through update_project",
|
||||
escape
|
||||
);
|
||||
// And the two read-only mounts are the same check.
|
||||
assert!(
|
||||
validate_mounted_host_path("the SSH key folder", None, Some(escape)).is_err(),
|
||||
"'{}' was accepted as an SSH key path, which read-only bind-mounts a whole \
|
||||
filesystem at /tmp/.host-ssh",
|
||||
escape
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A drive-relative path (`C:x`, no separator) means "x under whatever the
|
||||
/// current directory on C: happens to be" — a location decided by the
|
||||
/// process rather than by the user, so it may be the drive root.
|
||||
#[test]
|
||||
fn a_relative_path_is_refused_however_it_resolves_from_here() {
|
||||
// The previous test for this passed by coincidence: its four examples
|
||||
// did not exist under `app/src-tauri`, so `canonicalize` failed and the
|
||||
// `NotAbsolute` branch fired for the wrong reason. Creating a directory
|
||||
// named `project` there flipped it red.
|
||||
//
|
||||
// These are paths that *do* exist relative to wherever the test runs,
|
||||
// so they exercise the branch that used to be unreachable. Judged on
|
||||
// the typed string, the answer is the same from any working directory —
|
||||
// which is the property that matters, because the daemon refuses a
|
||||
// relative mount source and the project would save fine and then never
|
||||
// start.
|
||||
for existing in [".", "..", "src", "./src"] {
|
||||
assert!(
|
||||
matches!(
|
||||
classify_mount_source(existing),
|
||||
Some(UnmountableHostPath::NotAbsolute)
|
||||
),
|
||||
"{} is relative and must be refused regardless of cwd",
|
||||
existing
|
||||
);
|
||||
}
|
||||
|
||||
// And the fix must not have made an absolute path unreachable.
|
||||
assert!(
|
||||
classify_mount_source("/usr").is_none(),
|
||||
"an ordinary absolute folder must still be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_names_no_location_is_refused_rather_than_guessed_at() {
|
||||
for relative in ["C:x", "C:Users\\jo", "relative/path", "./project"] {
|
||||
assert!(
|
||||
validate_project_paths(&[path(relative, "project")]).is_err(),
|
||||
"'{}' was accepted, though where it lands depends on Triple-C's own \
|
||||
working directory",
|
||||
relative
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dots that are *not* an escape have to keep working — a folder can
|
||||
/// legitimately be reached through `.` or a `..` that goes back down again,
|
||||
/// and the Browse button produces paths on machines this list is not
|
||||
/// running on.
|
||||
#[test]
|
||||
fn an_ordinary_folder_is_still_accepted_however_it_is_spelled() {
|
||||
for ok in [
|
||||
"/home/u/./project",
|
||||
"/home/u/x/../project",
|
||||
"/home/u/..project",
|
||||
"/home/u/project/..hidden",
|
||||
"C:\\Users\\u\\x\\..\\project",
|
||||
"\\\\server\\share\\project",
|
||||
"\\\\?\\C:\\Users\\u\\project",
|
||||
] {
|
||||
assert!(
|
||||
validate_project_paths(&[path(ok, "project")]).is_ok(),
|
||||
"host path '{}' should be usable",
|
||||
ok
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The half of this that only resolution can answer.
|
||||
///
|
||||
/// A lexical check sees a two-component path under `/tmp` and stops. The
|
||||
/// container is what plants the link — `/proc/self/mountinfo` inside a
|
||||
/// Triple-C container spells the host's project paths out verbatim — so the
|
||||
/// symlink is reachable, and the mount that follows it is read-write.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_to_the_root_is_refused_because_resolution_is_what_answers() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-root-link-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let link = dir.join("innocent");
|
||||
std::os::unix::fs::symlink("/", &link).unwrap();
|
||||
|
||||
let verdict = validate_project_paths(&[path(&link.to_string_lossy(), "project")]);
|
||||
std::fs::remove_file(&link).ok();
|
||||
std::fs::remove_dir(&dir).ok();
|
||||
|
||||
assert!(
|
||||
verdict.is_err(),
|
||||
"a symlink to / was accepted as a project folder; only canonicalisation can see it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_and_half_filled_rows_are_refused_but_the_blank_row_is_not() {
|
||||
assert!(validate_project_paths(&[
|
||||
|
||||
@@ -16,6 +16,38 @@ pub async fn update_settings(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let before = state.settings_store.get();
|
||||
|
||||
// The global half of the same rule the project half gets in
|
||||
// `update_project`: a global custom env var is merged into every project's
|
||||
// container environment, so an unchecked name here reaches all of them.
|
||||
crate::models::validate_env_vars_update(
|
||||
&before.global_custom_env_vars,
|
||||
&settings.global_custom_env_vars,
|
||||
)?;
|
||||
|
||||
// The same for the two host paths this struct owns. `update_project`
|
||||
// validated its per-project overrides and this side validated nothing,
|
||||
// which left the wider hole of the two: `default_ssh_key_path` is the
|
||||
// fallback for **every** project without an override
|
||||
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
|
||||
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
|
||||
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
|
||||
// home volume this release exists to bound.
|
||||
//
|
||||
// Grandfathered the same way project paths are: a value carried over
|
||||
// unchanged still saves, so a store written before this check cannot lock
|
||||
// the user out of their own settings.
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"SSH key path",
|
||||
before.default_ssh_key_path.as_deref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
)?;
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"CA certificate path",
|
||||
before.ca_cert_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
)?;
|
||||
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
// Persisting a setting is not the same as applying it. The gateway is the
|
||||
|
||||
@@ -197,18 +197,17 @@ pub async fn upload_host_file_to_terminal(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
// The drop target is a host path chosen by the webview, not by the OS drag
|
||||
// itself, so it gets the same host-read policy as the Files pane's upload:
|
||||
// absolute, no traversal, and nothing out of a hidden directory
|
||||
// (`~/.ssh`, `~/.aws`) or a system location — applied to the path with its
|
||||
// symlinks already resolved, so a visible directory that *leads* to `~/.ssh`
|
||||
// is refused too. What comes back is that resolved path, and it is what
|
||||
// gets opened.
|
||||
// itself, so it goes through `file_commands`' host-read policy: absolute,
|
||||
// no traversal, and nothing whose path passes through a hidden directory
|
||||
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
||||
// the path with its symlinks already resolved, so a visible directory that
|
||||
// *leads* to one of those is refused too. What comes back is that resolved
|
||||
// path, and it is what gets opened. This is now one of only two commands
|
||||
// that touch a host path at all; the other is `download_container_backup`.
|
||||
// The name is taken from the path the user actually dropped, *before*
|
||||
// resolution. Deriving it from the resolved path renames the file behind
|
||||
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
||||
// a symlink, would land it in the container as `2026-08-23.log`. The Files
|
||||
// pane's upload had the same bug and fixes it the same way — one helper, so
|
||||
// the two drop targets cannot drift.
|
||||
// a symlink, would land it in the container as `2026-08-23.log`.
|
||||
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
||||
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
||||
|
||||
@@ -217,8 +216,23 @@ pub async fn upload_host_file_to_terminal(
|
||||
let meta = tokio::fs::metadata(&host_path)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||
if meta.is_dir() {
|
||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
||||
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||
// regular file, reports `len() == 0`, and passes both the directory check
|
||||
// and the size cap below — and `std::fs::File::open` on one blocks forever
|
||||
// with no writer, with no timeout anywhere on this path. The upload then
|
||||
// never returns, the toast sticks on "Adding N files…" for the session and
|
||||
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
||||
// shape. With the Files tab's upload removed, this is the only route for
|
||||
// getting a file into a container, so it is the wrong place to be clever.
|
||||
if !meta.is_file() {
|
||||
return Err(if meta.is_dir() {
|
||||
format!("{} is a directory — drop individual files", host_path)
|
||||
} else {
|
||||
format!(
|
||||
"{} is not a regular file — only ordinary files can be dropped into a terminal",
|
||||
host_path
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||
@@ -229,7 +243,7 @@ pub async fn upload_host_file_to_terminal(
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
meta.len() as f64 / (1024.0 * 1024.0),
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
@@ -301,19 +315,18 @@ pub async fn stop_audio_bridge(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Both drop targets must name a dropped file the way the *user* named it.
|
||||
/// A dropped file must be named the way the *user* named it.
|
||||
///
|
||||
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
||||
/// name from the path *after* symlink resolution, so dropping
|
||||
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
||||
/// `2026-08-23.log` — silently landed the file in the container under the
|
||||
/// target's name. Nothing errored; the user just got a name they never
|
||||
/// typed. The Files pane had the identical bug.
|
||||
/// typed.
|
||||
///
|
||||
/// What actually keeps the two from drifting is that they now call one
|
||||
/// helper, so this asserts that helper's contract from the terminal side:
|
||||
/// the answer comes from the spelling, and a path that does not name a file
|
||||
/// is refused rather than silently substituted (it used to fall back to
|
||||
/// This asserts the shared helper's contract from the terminal side: the
|
||||
/// answer comes from the spelling, and a path that does not name a file is
|
||||
/// refused rather than silently substituted (it used to fall back to
|
||||
/// `"dropped-file"`).
|
||||
#[test]
|
||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -351,8 +351,8 @@ pub async fn upload_host_file_to_container(
|
||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. Same policy as the Files pane's upload — this
|
||||
// is the terminal's drop target, and the two must not differ.
|
||||
// packed into anything. This is the terminal's drop target, and it is
|
||||
// the only path by which host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
crate::commands::file_commands::verify_opened_path(
|
||||
@@ -601,44 +601,6 @@ pub async fn exec_oneshot_as(
|
||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_as`] with a wall-clock ceiling on the whole call.
|
||||
///
|
||||
/// H8. Nothing in this module bounds how long a container command may take,
|
||||
/// which is right for the callers that need it — a base-image migration replays
|
||||
/// `apt-get` and takes minutes — and wrong for a short command that can be made
|
||||
/// to block forever by a *file* the caller does not control. The upload
|
||||
/// reservation is the one that bit: a shell redirect onto a FIFO blocks in
|
||||
/// `open(2)` until a reader appears, so a single `mkfifo` in a project
|
||||
/// directory left the Files pane on "Uploading…" for the rest of the session
|
||||
/// with the rest of the batch abandoned.
|
||||
///
|
||||
/// So the ceiling is opt-in per call site rather than global. Note what it can
|
||||
/// and cannot do: dropping the future closes our end of the stream, but Docker
|
||||
/// has no "kill an exec" API, so a process that is genuinely wedged stays
|
||||
/// wedged in the container's process table. That is why the primitive matters
|
||||
/// more than the timeout — this turns "the app never comes back" into "that
|
||||
/// upload failed", and it is the caller's job not to run something that blocks.
|
||||
pub async fn exec_oneshot_as_within(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: std::time::Duration,
|
||||
) -> Result<(String, i64), String> {
|
||||
match tokio::time::timeout(
|
||||
limit,
|
||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!(
|
||||
"The container did not answer within {}s — the command may still be running inside it.",
|
||||
limit.as_secs()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a one-shot exec printed, with the two streams still tellable apart.
|
||||
///
|
||||
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
||||
|
||||
@@ -369,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
|
||||
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
||||
let mut out: Vec<String> = paths
|
||||
.iter()
|
||||
// **The same filter `project_path_mounts` applies, and it has to be.**
|
||||
// That function skips a row with an empty `host_path` or `mount_name`
|
||||
// so a legacy row cannot brick the create. The consequence is that
|
||||
// `/workspace/<name>` for such a row is *not* a bind mount — it is
|
||||
// ordinary writable-layer content. Excluding it here would tell
|
||||
// `compute_verbatim_paths` to skip staging it, and the container swap
|
||||
// would then destroy whatever the user has put there. The two
|
||||
// predicates must agree or a migration silently eats a directory.
|
||||
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
|
||||
.map(|p| format!("/workspace/{}", p.mount_name))
|
||||
.collect();
|
||||
out.sort();
|
||||
@@ -1368,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// The mount filter and the migration's exclusion list must agree.
|
||||
///
|
||||
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
|
||||
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
|
||||
/// writable-layer content rather than a bind mount — and if this function
|
||||
/// still excluded it, `compute_verbatim_paths` would skip staging it and
|
||||
/// the container swap would destroy whatever is there. A migration eating a
|
||||
/// directory is the quietest kind of data loss there is.
|
||||
#[test]
|
||||
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
|
||||
let paths = vec![
|
||||
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
|
||||
// Legacy shapes that `project_path_mounts` skips.
|
||||
ProjectPath { host_path: "".into(), mount_name: "data".into() },
|
||||
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
|
||||
];
|
||||
let excluded = bind_mount_exclusions(&paths);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec!["/workspace/code".to_string()],
|
||||
"only rows that are actually mounted may be excluded from staging"
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
use crate::models::{
|
||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
||||
|
||||
+177
-3
@@ -435,7 +435,6 @@ pub fn run() {
|
||||
commands::docker_commands::check_image_exists,
|
||||
commands::docker_commands::build_image,
|
||||
commands::docker_commands::get_container_info,
|
||||
commands::docker_commands::list_sibling_containers,
|
||||
// Projects
|
||||
commands::project_commands::list_projects,
|
||||
commands::project_commands::add_project,
|
||||
@@ -497,9 +496,7 @@ pub fn run() {
|
||||
commands::terminal_commands::stop_audio_bridge,
|
||||
// Files
|
||||
commands::file_commands::list_container_files,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::download_container_backup,
|
||||
commands::file_commands::upload_file_to_container,
|
||||
commands::file_commands::read_container_file,
|
||||
commands::file_commands::rename_container_path,
|
||||
commands::file_commands::create_container_directory,
|
||||
@@ -689,6 +686,183 @@ mod tests {
|
||||
/// pulls in `core:image:default` → `allow-from-path`, which is an
|
||||
/// unconditional `std::fs::read` of any host path with no scope check, and
|
||||
/// nothing in the frontend has ever imported `@tauri-apps/api/image`.
|
||||
/// Every `#[tauri::command]` is registered, and every registration names a
|
||||
/// command that exists.
|
||||
///
|
||||
/// This is the shape of the bug that caused the original OAuth-callback
|
||||
/// complaint: `set_auth_bridge_enabled` existed, worked, and had a typed
|
||||
/// frontend wrapper — with **zero call sites**. The switch the docs told
|
||||
/// users to flip was never wired to anything, so the bridge stayed off and
|
||||
/// every login callback was refused. Nothing failed; the feature was simply
|
||||
/// absent, and no test noticed because both halves compiled.
|
||||
///
|
||||
/// The reverse direction matters too, and for a sharper reason: a command
|
||||
/// that is registered but reachable from nowhere is still IPC surface a
|
||||
/// compromised webview can call. `list_sibling_containers` — which returned
|
||||
/// every container on the daemon, including the user's unrelated work —
|
||||
/// sat in exactly that state, and this test is what found it. It has since
|
||||
/// been removed at all four levels: registration, command, docker helper,
|
||||
/// and the frontend wrapper and type.
|
||||
///
|
||||
/// So this asserts the two lists agree, and leaves *deciding* what belongs
|
||||
/// on them to a human. It cannot see frontend call sites; `tsc` and the
|
||||
/// vitest suite cover that side.
|
||||
#[test]
|
||||
fn every_command_is_registered_exactly_once() {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let mut defined: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
// Walk the source tree for the command attribute and take the `fn` name
|
||||
// that follows.
|
||||
//
|
||||
// The first version of this matched `line.trim() == "#[tauri::command]"`
|
||||
// exactly and broke on the first non-`#` line. An audit got five real,
|
||||
// compiling, unregistered commands past it — `#[tauri::command(async)]`,
|
||||
// `#[tauri::command(rename_all = "snake_case")]`, a trailing comment,
|
||||
// spaces in the path, and a bare `#[command]` after `use tauri::command`
|
||||
// — plus `pub(crate) fn` and a `///` line between attribute and `fn`.
|
||||
// Every one of those is a command the frontend could not call, which is
|
||||
// the bug this test exists for, and the test stayed green.
|
||||
//
|
||||
// The asymmetry matters: confusion on the *definition* side is a silent
|
||||
// pass, while on the *registration* side it fails loudly against
|
||||
// legitimate code — and rustc already covers that direction. So this
|
||||
// errs toward over-matching definitions.
|
||||
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim();
|
||||
// `#[tauri::command]`, `#[tauri::command(async)]`,
|
||||
// `#[tauri :: command]`, a bare `#[command]` under
|
||||
// `use tauri::command`, and any of those with a
|
||||
// trailing comment.
|
||||
let attr = t.strip_prefix("#[").map(|a| {
|
||||
a.split(']').next().unwrap_or("").replace(' ', "")
|
||||
});
|
||||
let is_command_attr = attr.is_some_and(|a| {
|
||||
a == "command" || a == "tauri::command"
|
||||
|| a.starts_with("command(")
|
||||
|| a.starts_with("tauri::command(")
|
||||
});
|
||||
if !is_command_attr {
|
||||
continue;
|
||||
}
|
||||
// Skip further attributes and doc comments rather than
|
||||
// giving up at the first line that is not an attribute.
|
||||
for next in lines.iter().skip(i + 1) {
|
||||
let t = next.trim();
|
||||
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Any visibility, then `fn` or `async fn`.
|
||||
let after_vis = t
|
||||
.strip_prefix("pub(crate) ")
|
||||
.or_else(|| t.strip_prefix("pub(super) "))
|
||||
.or_else(|| t.strip_prefix("pub(in crate) "))
|
||||
.or_else(|| t.strip_prefix("pub "))
|
||||
.unwrap_or(t);
|
||||
let after_async =
|
||||
after_vis.strip_prefix("async ").unwrap_or(after_vis);
|
||||
if let Some(rest) = after_async.strip_prefix("fn ") {
|
||||
if let Some(name) = rest.split(['(', '<']).next() {
|
||||
out.insert(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(
|
||||
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
|
||||
&mut defined,
|
||||
);
|
||||
|
||||
// The registration list, read from this file rather than from a macro
|
||||
// expansion so the test does not depend on `generate_handler!`'s shape.
|
||||
let this = include_str!("lib.rs");
|
||||
let handler = this
|
||||
.split_once("generate_handler![")
|
||||
.and_then(|(_, rest)| rest.split_once("])"))
|
||||
.map(|(inside, _)| inside)
|
||||
.expect("lib.rs should contain a generate_handler! list");
|
||||
// Line-based, not `split(',')`: the list is grouped under `// Docker`
|
||||
// style comments, and splitting on commas glues each comment to the
|
||||
// command that follows it. A `starts_with("//")` filter then drops that
|
||||
// command — silently, and once per group.
|
||||
let registered: BTreeSet<String> = handler
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||
.filter_map(|l| {
|
||||
l.trim_end_matches(',')
|
||||
.rsplit("::")
|
||||
.next()
|
||||
.map(|n| n.trim().to_string())
|
||||
})
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!defined.is_empty() && !registered.is_empty(),
|
||||
"the scan found nothing — it has stopped testing anything (defined={}, registered={})",
|
||||
defined.len(),
|
||||
registered.len()
|
||||
);
|
||||
|
||||
let unregistered: Vec<&String> = defined.difference(®istered).collect();
|
||||
assert!(
|
||||
unregistered.is_empty(),
|
||||
"these commands exist but are not registered, so the frontend cannot call them: {:?}",
|
||||
unregistered
|
||||
);
|
||||
|
||||
let undefined: Vec<&String> = registered.difference(&defined).collect();
|
||||
assert!(
|
||||
undefined.is_empty(),
|
||||
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
||||
undefined
|
||||
);
|
||||
|
||||
// "exactly once" was in this test's name and not in its body: both
|
||||
// sides were sets, so registering the same command twice in a
|
||||
// hand-maintained 118-line list compiled, warned about nothing, and
|
||||
// passed here.
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
let mut duplicated: Vec<&str> = Vec::new();
|
||||
for line in handler
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||
{
|
||||
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.contains(&name) {
|
||||
duplicated.push(name);
|
||||
} else {
|
||||
seen.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
duplicated.is_empty(),
|
||||
"these are registered more than once: {:?}",
|
||||
duplicated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_grants_are_the_ones_that_were_reviewed() {
|
||||
let raw = include_str!("../capabilities/default.json");
|
||||
|
||||
@@ -8,6 +8,100 @@ pub struct EnvVar {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Whether `key` is a name a shell will read back as an ordinary variable:
|
||||
/// `[A-Za-z_][A-Za-z0-9_]*`.
|
||||
///
|
||||
/// ## Why a charset rule, and not just the reserved-name list
|
||||
///
|
||||
/// `docker::container::is_reserved_env_key` answers a different question — "is
|
||||
/// this one of the names Triple-C manages itself" — and nothing anywhere asked
|
||||
/// what the *characters* were. A key is joined into `KEY=VALUE` and handed to
|
||||
/// the daemon, which puts it in the container's environment verbatim, so a name
|
||||
/// that is not an identifier travels through unchallenged.
|
||||
///
|
||||
/// The one that matters is `BASH_FUNC_name%%`, bash's wire format for an
|
||||
/// exported shell function: bash imports those at startup and the *body* is the
|
||||
/// value. Today that is latent rather than live — the image's `/bin/sh` is
|
||||
/// dash, which does not import them, and an auditor confirmed the vector fires
|
||||
/// under `bash -c` and not under `sh -c` in the shipped image. But the
|
||||
/// pre-commit scrub runs `/bin/sh -c` **as root**, `/bin/sh` is whatever
|
||||
/// `ubuntu:24.04` points it at, and nothing pins that. One base-image change,
|
||||
/// or one call site spelled `bash`, turns a stored project setting into root
|
||||
/// code execution inside the container at commit time.
|
||||
///
|
||||
/// So the rule is the shape of the thing rather than a list of the names that
|
||||
/// are known to be dangerous: `IFS`, `LD_PRELOAD` and `PATH` are all perfectly
|
||||
/// good identifiers and are the user's business, while nothing legitimate needs
|
||||
/// a `%`, a `(` or a space in an environment variable name.
|
||||
///
|
||||
/// The key is judged **trimmed**, because that is what `create_container` sends
|
||||
/// — ` FOO ` already reaches the container as `FOO`, and refusing it here would
|
||||
/// break a setting that works.
|
||||
pub fn is_valid_env_key(key: &str) -> bool {
|
||||
let mut chars = key.trim().chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// Validate a custom environment variable list that is about to be stored,
|
||||
/// admitting the entries it is already stored with.
|
||||
///
|
||||
/// Same shape, and the same reasoning, as
|
||||
/// `commands::project_commands::validate_project_paths_update`: nothing ever
|
||||
/// checked these keys, so `projects.json` and `settings.json` in the field can
|
||||
/// hold whatever was typed. Holding every save to the new rule would make such
|
||||
/// a project unsavable *entirely* — `update_project` is the single command
|
||||
/// behind the whole Config tab — and would buy nothing, because the stored key
|
||||
/// is already being handed to every container that starts. An entry carried
|
||||
/// over verbatim is admitted; a new or edited one is held to the rule, which is
|
||||
/// what keeps the escalation closed, since escalation means *introducing* a bad
|
||||
/// key through this command.
|
||||
///
|
||||
/// Counted rather than set-tested, for the same reason as the folder rows: a
|
||||
/// second copy of an existing entry is a new entry.
|
||||
///
|
||||
/// The blank entry is not a violation. "+ Add variable" appends
|
||||
/// `{key: "", value: ""}` and saves the list immediately, so refusing it would
|
||||
/// turn the button itself into an error toast; `create_container` skips an
|
||||
/// empty key, so it reaches nothing.
|
||||
pub fn validate_env_vars_update(stored: &[EnvVar], incoming: &[EnvVar]) -> Result<(), String> {
|
||||
// An entry with no key is the placeholder, whatever is in its value:
|
||||
// `create_container` skips it, so it reaches nothing and there is nothing
|
||||
// to refuse. The editor saves on every blur, and typing the value before
|
||||
// the name is an ordinary way to fill a row in.
|
||||
let is_blank = |v: &EnvVar| v.key.trim().is_empty();
|
||||
|
||||
let mut carried: std::collections::HashMap<(&str, &str), usize> =
|
||||
std::collections::HashMap::new();
|
||||
for v in stored.iter().filter(|v| !is_blank(v)) {
|
||||
*carried
|
||||
.entry((v.key.as_str(), v.value.as_str()))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for v in incoming.iter().filter(|v| !is_blank(v)) {
|
||||
match carried.get_mut(&(v.key.as_str(), v.value.as_str())) {
|
||||
Some(remaining) if *remaining > 0 => {
|
||||
*remaining -= 1;
|
||||
}
|
||||
_ => {
|
||||
if !is_valid_env_key(&v.key) {
|
||||
return Err(format!(
|
||||
"'{}' is not a usable environment variable name. Use a letter or \
|
||||
underscore followed by letters, digits or underscores.",
|
||||
v.key
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProjectPath {
|
||||
pub host_path: String,
|
||||
@@ -85,6 +179,7 @@ impl PermissionMode {
|
||||
/// Settings for Claude Code CLI behavior inside the container.
|
||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(from = "StoredClaudeCodeSettings")]
|
||||
/// Every field is three-state, and the third state is load-bearing.
|
||||
///
|
||||
/// `None` means "not set at this level". For a *project* that is "inherit
|
||||
@@ -98,24 +193,24 @@ pub struct ClaudeCodeSettings {
|
||||
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
|
||||
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
|
||||
/// one. All three are distinct — "let it choose" is not "classic".
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tui_mode: Option<String>,
|
||||
/// Saved `/effort` level: `None` = unset, otherwise one of
|
||||
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
|
||||
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
|
||||
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
|
||||
/// zero value of this field has to mean "leave it on".
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_scroll_disabled: Option<bool>,
|
||||
/// Collapse tool output to one-line summaries. Written to settings.json as
|
||||
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focus_mode: Option<bool>,
|
||||
/// Show thinking summaries in responses
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub show_thinking_summaries: Option<bool>,
|
||||
/// Turn the session recap **off**.
|
||||
///
|
||||
@@ -128,16 +223,99 @@ pub struct ClaudeCodeSettings {
|
||||
/// never touched the control holds — as "the user turned the recap off" and
|
||||
/// silently disabled it for all of them. A new name lets the old key be
|
||||
/// ignored, which lands every existing project on the correct default.
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_recap_disabled: Option<bool>,
|
||||
/// Strip credentials from subprocess environments
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env_scrub: Option<bool>,
|
||||
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_caching_1h: Option<bool>,
|
||||
}
|
||||
|
||||
/// `ClaudeCodeSettings` in every shape `projects.json` and `settings.json` can
|
||||
/// be holding, which is what [`ClaudeCodeSettings`] is actually deserialised
|
||||
/// through.
|
||||
///
|
||||
/// ## The upgrade this exists to survive
|
||||
///
|
||||
/// Before the widening, the five booleans were plain `bool`s with
|
||||
/// `#[serde(default)]` and no `skip_serializing_if`, so **every** settings
|
||||
/// object ever written carries an explicit `"env_scrub": false` — not because
|
||||
/// anyone chose it, but because that is what a `bool` serialises to. Under the
|
||||
/// old merge (`if p.x { true } else { g.x }`) that `false` carried no
|
||||
/// information at all: it was the only value an unset switch could produce, and
|
||||
/// the global always won.
|
||||
///
|
||||
/// Read as `Some(false)` by the new code it becomes a *deliberate off* that
|
||||
/// beats a global `Some(true)` — so upgrading silently turned five settings off
|
||||
/// for every project that had ever opened this editor, `env_scrub` ("strip
|
||||
/// credentials from subprocess environments") among them. There is no store
|
||||
/// migration anywhere: `projects_store` parses these structs directly.
|
||||
///
|
||||
/// ## How an old record is told apart from a new one
|
||||
///
|
||||
/// By `enable_session_recap`. It was in the struct from the day it existed and
|
||||
/// was a plain `bool`, so its key is present in every pre-widening record and
|
||||
/// in no other — the field was *renamed* to `session_recap_disabled` precisely
|
||||
/// so the old key could be ignored (see the doc on that field), and the new
|
||||
/// code has never written it. Its presence is therefore an exact statement that
|
||||
/// these bytes were written by a binary in which `false` meant "unset", and the
|
||||
/// booleans are read back that way: `true` is a real choice and survives,
|
||||
/// `false` becomes `None` and inherits again.
|
||||
///
|
||||
/// Nothing marks a *new* record, and nothing needs to: absent is `None` (the
|
||||
/// fields skip serialising when unset) and a present `false` is the deliberate
|
||||
/// off the widening was for. That is also what keeps a downgrade survivable —
|
||||
/// an older binary reads an absent key as `false` through its own
|
||||
/// `#[serde(default)]`, where a `null` would fail to parse and take the whole
|
||||
/// of `projects.json` down with it, since `ProjectsStore` parses all-or-nothing
|
||||
/// and starts empty on an error.
|
||||
#[derive(Deserialize)]
|
||||
struct StoredClaudeCodeSettings {
|
||||
#[serde(default)]
|
||||
tui_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
effort: Option<String>,
|
||||
#[serde(default)]
|
||||
auto_scroll_disabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
focus_mode: Option<bool>,
|
||||
#[serde(default)]
|
||||
show_thinking_summaries: Option<bool>,
|
||||
#[serde(default)]
|
||||
session_recap_disabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
env_scrub: Option<bool>,
|
||||
#[serde(default)]
|
||||
prompt_caching_1h: Option<bool>,
|
||||
/// The pre-widening spelling of `session_recap_disabled`, and the *only*
|
||||
/// use of its value: presence dates the record. Its meaning was inverted
|
||||
/// and it never worked, so it is read for the marker and discarded.
|
||||
#[serde(default)]
|
||||
enable_session_recap: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<StoredClaudeCodeSettings> for ClaudeCodeSettings {
|
||||
fn from(stored: StoredClaudeCodeSettings) -> Self {
|
||||
let pre_widening = stored.enable_session_recap.is_some();
|
||||
// On a pre-widening record `false` is what an untouched switch wrote,
|
||||
// so it means "not set at this level" and must inherit. A `true` was a
|
||||
// real choice either way.
|
||||
let read = |v: Option<bool>| if pre_widening { v.filter(|on| *on) } else { v };
|
||||
ClaudeCodeSettings {
|
||||
tui_mode: stored.tui_mode,
|
||||
effort: stored.effort,
|
||||
auto_scroll_disabled: read(stored.auto_scroll_disabled),
|
||||
focus_mode: read(stored.focus_mode),
|
||||
show_thinking_summaries: read(stored.show_thinking_summaries),
|
||||
session_recap_disabled: read(stored.session_recap_disabled),
|
||||
env_scrub: read(stored.env_scrub),
|
||||
prompt_caching_1h: read(stored.prompt_caching_1h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
@@ -467,3 +645,170 @@ impl Project {
|
||||
val
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Custom environment variable names ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn an_env_var_name_has_to_be_a_shell_identifier() {
|
||||
for ok in ["PATH", "_", "_x", "MY_VAR2", "a", " SPACED_BY_THE_EDITOR "] {
|
||||
assert!(is_valid_env_key(ok), "'{}' should be a usable name", ok);
|
||||
}
|
||||
for bad in [
|
||||
// bash's wire format for an exported shell function: the value is
|
||||
// the body, and a `bash` that imports it runs it. The scrub exec is
|
||||
// `/bin/sh -c` as root, and nothing pins `/bin/sh` to dash.
|
||||
"BASH_FUNC_stat%%",
|
||||
"BASH_FUNC_ls()",
|
||||
"MY VAR",
|
||||
"2FAST",
|
||||
"WITH-DASH",
|
||||
"WITH.DOT",
|
||||
"",
|
||||
" ",
|
||||
"$(id)",
|
||||
"A=B",
|
||||
] {
|
||||
assert!(!is_valid_env_key(bad), "'{}' should be refused", bad);
|
||||
}
|
||||
}
|
||||
|
||||
fn env(key: &str, value: &str) -> EnvVar {
|
||||
EnvVar { key: key.to_string(), value: value.to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_env_var_name_cannot_be_introduced_but_a_stored_one_does_not_brick_the_editor() {
|
||||
let bad = [env("BASH_FUNC_stat%%", "() { id; }")];
|
||||
// Introducing it through the Config tab is the escalation.
|
||||
assert!(validate_env_vars_update(&[], &bad).is_err());
|
||||
// Already stored: it is handed to every container that starts whether
|
||||
// or not an unrelated save is allowed through, and refusing the save
|
||||
// would make every toggle on the Config tab fail.
|
||||
assert!(validate_env_vars_update(&bad, &bad).is_ok());
|
||||
// Editing its value is a new entry, and refused again.
|
||||
assert!(
|
||||
validate_env_vars_update(&bad, &[env("BASH_FUNC_stat%%", "() { rm -rf /; }")]).is_err()
|
||||
);
|
||||
// Fixing the name is what the message asks for, and it saves.
|
||||
assert!(validate_env_vars_update(&bad, &[env("STAT", "() { id; }")]).is_ok());
|
||||
// Dropping it entirely is always fine.
|
||||
assert!(validate_env_vars_update(&bad, &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blank_row_the_add_button_saves_is_not_an_error() {
|
||||
// "+ Add variable" appends an empty entry and saves the list at once,
|
||||
// so this is the button, not an attempt at anything.
|
||||
assert!(validate_env_vars_update(&[], &[env("", "")]).is_ok());
|
||||
// Typing the value before the name is an ordinary way to fill it in,
|
||||
// and an entry with no name reaches no container either way.
|
||||
assert!(validate_env_vars_update(&[], &[env("", "value-first")]).is_ok());
|
||||
assert!(validate_env_vars_update(&[], &[env("GOOD", "v"), env("", "")]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_entry_may_be_kept_but_not_multiplied() {
|
||||
let stored = [env("BAD NAME", "v")];
|
||||
assert!(validate_env_vars_update(&stored, &stored).is_ok());
|
||||
// A second copy is a new entry, and held to the rule.
|
||||
assert!(
|
||||
validate_env_vars_update(&stored, &[env("BAD NAME", "v"), env("BAD NAME", "v")])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Claude Code settings written before the fields were widened ───────
|
||||
|
||||
/// `projects.json` exactly as the shipped `main` binary wrote it: the five
|
||||
/// booleans were plain `bool`s that always serialised, so every project
|
||||
/// that ever opened the editor carries `false` for the ones it never
|
||||
/// touched.
|
||||
const MAIN_SHAPE_PROJECT: &str = r#"{
|
||||
"id": "p1",
|
||||
"name": "demo",
|
||||
"paths": [{ "host_path": "/home/u/demo", "mount_name": "demo" }],
|
||||
"container_id": null,
|
||||
"status": "stopped",
|
||||
"backend": "anthropic",
|
||||
"bedrock_config": null,
|
||||
"ollama_config": null,
|
||||
"openai_compatible_config": null,
|
||||
"allow_docker_access": false,
|
||||
"ssh_key_path": null,
|
||||
"git_user_name": null,
|
||||
"git_user_email": null,
|
||||
"claude_code_settings": {
|
||||
"tui_mode": "fullscreen",
|
||||
"effort": null,
|
||||
"auto_scroll_disabled": false,
|
||||
"focus_mode": false,
|
||||
"show_thinking_summaries": false,
|
||||
"enable_session_recap": false,
|
||||
"env_scrub": false,
|
||||
"prompt_caching_1h": false
|
||||
},
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn a_setting_stored_as_false_by_the_old_binary_still_inherits_the_global() {
|
||||
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||
let stored = project.claude_code_settings.expect("settings should parse");
|
||||
|
||||
// Read verbatim these would be `Some(false)`, which under
|
||||
// `docker::container::merge_claude_code_settings` beats the global.
|
||||
assert_eq!(stored.env_scrub, None);
|
||||
assert_eq!(stored.auto_scroll_disabled, None);
|
||||
assert_eq!(stored.focus_mode, None);
|
||||
assert_eq!(stored.show_thinking_summaries, None);
|
||||
assert_eq!(stored.prompt_caching_1h, None);
|
||||
assert_eq!(stored.session_recap_disabled, None);
|
||||
// A value the user did choose is untouched.
|
||||
assert_eq!(stored.tui_mode.as_deref(), Some("fullscreen"));
|
||||
|
||||
// The merge rule itself, spelled the way
|
||||
// `merge_claude_code_settings` spells it. `main` resolved this with
|
||||
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
|
||||
// and it has to go on winning, because the user never turned this off.
|
||||
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||
assert_eq!(
|
||||
stored.env_scrub.or(global.env_scrub),
|
||||
Some(true),
|
||||
"upgrading silently turned off 'strip credentials from subprocess environments'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_chosen_in_the_new_editor_still_beats_a_global_on() {
|
||||
// Same record without the pre-widening key: this `false` is the
|
||||
// deliberate off the widening exists to make expressible.
|
||||
let json = r#"{ "env_scrub": false }"#;
|
||||
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(chosen.env_scrub, Some(false));
|
||||
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unset_setting_is_written_as_absent_rather_than_null() {
|
||||
// A downgrade parses these fields as plain `bool` with
|
||||
// `#[serde(default)]`: an absent key is `false`, a `null` is a parse
|
||||
// error — and `ProjectsStore` parses all-or-nothing, so one project
|
||||
// with one null empties the whole list and the next save persists that.
|
||||
let json = serde_json::to_string(&ClaudeCodeSettings::default()).unwrap();
|
||||
assert_eq!(json, "{}");
|
||||
assert!(!json.contains("null"));
|
||||
|
||||
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
|
||||
let json = serde_json::to_string(&partial).unwrap();
|
||||
assert_eq!(json, r#"{"env_scrub":false}"#);
|
||||
// And it reads back as what it is.
|
||||
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(round_tripped, partial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +431,18 @@
|
||||
const mobileInput = document.getElementById('mobileInput');
|
||||
const btnEnter = document.getElementById('btnEnter');
|
||||
const btnNewline = document.getElementById('btnNewline');
|
||||
|
||||
// Whether the *active* session understands ESC+CR as "insert a newline".
|
||||
//
|
||||
// Only Claude Code does. `bash -l` has no readline binding for `\e\r`, so
|
||||
// sending it there is a silent no-op — which is worse from the mobile bar
|
||||
// than from a hardware key, because the bar puts a dedicated button on
|
||||
// screen that appears to do nothing. The xterm key handler is already scoped
|
||||
// this way; these two paths were not.
|
||||
function activeSessionTakesEscCr() {
|
||||
const s = activeSessionId && sessions[activeSessionId];
|
||||
return !!s && s.type === 'claude';
|
||||
}
|
||||
const btnTab = document.getElementById('btnTab');
|
||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||
@@ -590,7 +602,7 @@
|
||||
updateProjectList(msg.projects);
|
||||
break;
|
||||
case 'opened':
|
||||
onSessionOpened(msg.session_id, msg.project_name);
|
||||
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
|
||||
break;
|
||||
case 'output':
|
||||
onSessionOutput(msg.session_id, msg.data);
|
||||
@@ -641,8 +653,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function onSessionOpened(sessionId, projectName) {
|
||||
const sessionType = pendingSessionType || 'claude';
|
||||
function onSessionOpened(sessionId, projectName, serverSessionType) {
|
||||
// Prefer the type the *server* reports for this session. The old path read
|
||||
// a single `pendingSessionType` global set at request time, so opening two
|
||||
// sessions before the first reply landed swapped their labels — routine on
|
||||
// mobile, where nothing disables the buttons. That was cosmetic until
|
||||
// Shift+Enter became type-dependent: a Claude session labelled `shell`
|
||||
// sends a bare CR and submits a half-written prompt.
|
||||
//
|
||||
// The fallback keeps an older server working, and defaults to `claude`,
|
||||
// which is the safe direction — ESC+CR is an unbound no-op in bash, while
|
||||
// a bare CR in Claude Code loses the prompt.
|
||||
const sessionType = serverSessionType || pendingSessionType || 'claude';
|
||||
pendingSessionType = null;
|
||||
|
||||
// Create terminal
|
||||
@@ -791,6 +813,7 @@
|
||||
switchToSession(remaining[remaining.length - 1]);
|
||||
} else {
|
||||
activeSessionId = null;
|
||||
syncNewlineButton();
|
||||
emptyState.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -817,6 +840,7 @@
|
||||
|
||||
function switchToSession(sessionId) {
|
||||
activeSessionId = sessionId;
|
||||
syncNewlineButton();
|
||||
|
||||
// Update tab styles
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
@@ -896,7 +920,7 @@
|
||||
// reasoning, as the terminal's own key handler above. A hardware
|
||||
// keyboard on a tablet is the only way to reach this; the phone case is
|
||||
// the dedicated newline button beside Enter.
|
||||
sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r');
|
||||
sendTerminalInput(e.shiftKey && activeSessionTakesEscCr() ? '\x1b\r' : '\r');
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
sendTerminalInput('\t');
|
||||
@@ -904,7 +928,27 @@
|
||||
});
|
||||
|
||||
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
||||
btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); };
|
||||
btnNewline.onclick = () => {
|
||||
if (!activeSessionTakesEscCr()) { mobileInput.focus(); return; }
|
||||
sendTerminalInput('\x1b\r');
|
||||
mobileInput.focus();
|
||||
};
|
||||
|
||||
// Keep the button's affordance honest: on a shell tab there is no byte that
|
||||
// means "newline without running the line", so the control is disabled
|
||||
// rather than left looking live.
|
||||
function syncNewlineButton() {
|
||||
const usable = activeSessionTakesEscCr();
|
||||
btnNewline.disabled = !usable;
|
||||
btnNewline.title = usable
|
||||
? 'Insert a newline without submitting (Shift+Enter)'
|
||||
: 'Only Claude sessions support this — a shell runs the line instead';
|
||||
}
|
||||
|
||||
// With no session open yet, `activeSessionTakesEscCr()` is already false —
|
||||
// but nothing had called this, so the button rendered live before the first
|
||||
// tab existed.
|
||||
syncNewlineButton();
|
||||
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ enum ServerMessage {
|
||||
Opened {
|
||||
session_id: String,
|
||||
project_name: String,
|
||||
/// Echoed back so the client can label the session from the reply
|
||||
/// rather than from a global set at request time.
|
||||
///
|
||||
/// Without it the client correlates through a single
|
||||
/// `pendingSessionType`, so opening two sessions before the first
|
||||
/// reply lands swaps their labels. That used to be cosmetic; it stopped
|
||||
/// being cosmetic when Shift+Enter became type-dependent, because a
|
||||
/// Claude session mislabelled as a shell now submits a half-written
|
||||
/// prompt instead of inserting a newline.
|
||||
session_type: String,
|
||||
},
|
||||
Output {
|
||||
session_id: String,
|
||||
@@ -319,6 +329,11 @@ async fn handle_open(
|
||||
let _ = out_tx.send(ServerMessage::Opened {
|
||||
session_id,
|
||||
project_name,
|
||||
// Derived from the same match that chose `cmd` above, not echoed from
|
||||
// the request: anything that is not exactly "bash" runs Claude, so
|
||||
// echoing the raw value would label an unrecognised string as its own
|
||||
// type and put the client back where it started.
|
||||
session_type: if session_type == Some("bash") { "bash" } else { "claude" }.to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -60,12 +60,18 @@ describe("ClaudeCodeSettingsEditor", () => {
|
||||
});
|
||||
|
||||
it("offers every effort level Claude Code accepts", () => {
|
||||
// Verified against the shipped `claude` binary's own schema rather than
|
||||
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
|
||||
// checked externally — which is the whole weakness of this test. It can
|
||||
// only prove the editor agrees with this list, never that the list is the
|
||||
// one Claude Code reads. The same blind spot is why `effort` and
|
||||
// `focusMode` were confidently wrong for months.
|
||||
renderEditor(null);
|
||||
expect(
|
||||
Array.from(
|
||||
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
||||
).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["", "low", "medium", "high", "xhigh"]);
|
||||
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
|
||||
});
|
||||
|
||||
describe("project scope", () => {
|
||||
@@ -193,4 +199,27 @@ describe("ClaudeCodeSettingsEditor", () => {
|
||||
expect(screen.getByRole("switch", { name: label })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A settings object with nothing set at this level arrives as `{}`: the Rust
|
||||
* struct skips serialising a field it has no value for, which is what keeps
|
||||
* an older binary able to parse `projects.json` after a downgrade. It is also
|
||||
* the exact shape a project stored before the fields were widened is read
|
||||
* back as — every one of its `false`s meant "unset" — so reading absent as
|
||||
* "off" would show a switch the user never touched as a deliberate choice.
|
||||
*/
|
||||
it("reads an absent field as Global rather than as Off", () => {
|
||||
renderEditor({} as ClaudeCodeSettings, "project");
|
||||
expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global");
|
||||
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
|
||||
it("still collapses to null when an absent-field object is edited back", () => {
|
||||
const onSave = renderEditor({} as ClaudeCodeSettings, "global");
|
||||
// Off and straight back on: the round trip has to land on `null`, or an
|
||||
// untouched global stops being indistinguishable from one never opened.
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
expect(onSave).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,15 +37,20 @@ export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||
* overrides a global on, so a settings object holding one has to be persisted.
|
||||
*/
|
||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||
// `== null`, not `===`: an unset field is *absent* on the wire, not null.
|
||||
// The Rust struct skips serialising one it has no value for, so a project
|
||||
// whose stored settings were all "unset" arrives here as `{}` — and reading
|
||||
// that as "off" is exactly the mistake the three-state control exists to
|
||||
// avoid. See the note on `ClaudeCodeSettings` in `lib/types.ts`.
|
||||
return (
|
||||
s.tui_mode === null &&
|
||||
s.effort === null &&
|
||||
s.auto_scroll_disabled === null &&
|
||||
s.focus_mode === null &&
|
||||
s.show_thinking_summaries === null &&
|
||||
s.session_recap_disabled === null &&
|
||||
s.env_scrub === null &&
|
||||
s.prompt_caching_1h === null
|
||||
s.tui_mode == null &&
|
||||
s.effort == null &&
|
||||
s.auto_scroll_disabled == null &&
|
||||
s.focus_mode == null &&
|
||||
s.show_thinking_summaries == null &&
|
||||
s.session_recap_disabled == null &&
|
||||
s.env_scrub == null &&
|
||||
s.prompt_caching_1h == null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +68,15 @@ const BOOLEAN_FIELDS: {
|
||||
hint: string;
|
||||
invert?: boolean;
|
||||
}[] = [
|
||||
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
||||
{
|
||||
key: "focus_mode",
|
||||
label: "Focus mode",
|
||||
// It summarises tool *calls*, not all output — and it does nothing at all
|
||||
// unless the fullscreen renderer is on, which is a separate switch above.
|
||||
// Saying so here is cheaper than the user concluding the setting is broken,
|
||||
// which is the complaint that started this whole round of work.
|
||||
hint: "Summarises each tool call to one line, showing the last prompt and the final response. Needs TUI mode set to Fullscreen.",
|
||||
},
|
||||
{
|
||||
key: "show_thinking_summaries",
|
||||
label: "Thinking summaries",
|
||||
@@ -163,6 +176,9 @@ export default function ClaudeCodeSettingsEditor({
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="xhigh">Extra high</option>
|
||||
{/* `max` is accepted by the CLI and was missing here. Confirmed
|
||||
against the shipped claude binary's own schema, not just docs. */}
|
||||
<option value="max">Maximum</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
@@ -204,7 +220,7 @@ export default function ClaudeCodeSettingsEditor({
|
||||
// `stored` holds the deviation from Claude Code's default, so an
|
||||
// inverted field reads back the other way round — see BOOLEAN_FIELDS.
|
||||
const selected =
|
||||
stored === null ? "global" : (invert ? !stored : stored) ? "on" : "off";
|
||||
stored == null ? "global" : (invert ? !stored : stored) ? "on" : "off";
|
||||
|
||||
return (
|
||||
<SwitchRow
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,16 +4,12 @@ import FilesTab from "./FilesTab";
|
||||
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
||||
|
||||
const listContainerFiles = vi.fn();
|
||||
const downloadContainerFile = vi.fn(async () => {});
|
||||
const uploadFileToContainer = vi.fn(async () => {});
|
||||
const renameContainerPath = vi.fn(async () => "");
|
||||
const createContainerDirectory = vi.fn(async () => "");
|
||||
const readContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
||||
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
@@ -31,29 +27,6 @@ const toastText = () =>
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.join("\n");
|
||||
|
||||
const save = vi.fn(async () => "/host/out");
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: (o: unknown) => save(o),
|
||||
open: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
/** The webview's window-wide native drag-drop listener, captured for driving. */
|
||||
type DragPayload =
|
||||
| { type: "enter" | "over"; position: { x: number; y: number }; paths: string[] }
|
||||
| { type: "leave" }
|
||||
| { type: "drop"; position: { x: number; y: number }; paths: string[] };
|
||||
let dragHandler: ((e: { payload: DragPayload }) => void | Promise<void>) | null = null;
|
||||
const unlistenDrag = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: async (cb: (e: { payload: DragPayload }) => void) => {
|
||||
dragHandler = cb;
|
||||
return unlistenDrag;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
|
||||
|
||||
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
@@ -82,39 +55,17 @@ async function renderTab() {
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Fire the native drop payload at a point inside the pane's stubbed rect. */
|
||||
async function drop(paths: string[], position = { x: 100, y: 100 }) {
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "drop", position, paths } });
|
||||
});
|
||||
}
|
||||
|
||||
/** Every row that is part of the grid's roving tabindex, in order. */
|
||||
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
||||
/** The rows that are actually tab stops. There must never be more than one. */
|
||||
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
|
||||
|
||||
/** Fire a drop without awaiting it — for the paths that stop to ask a question. */
|
||||
function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
|
||||
let pending: unknown;
|
||||
act(() => {
|
||||
pending = dragHandler?.({ payload: { type: "drop", position, paths } });
|
||||
});
|
||||
return pending as Promise<void> | undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dragHandler = null;
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||
entry("notes.txt"),
|
||||
]);
|
||||
// jsdom lays nothing out, so the pane's hit-test rect has to be supplied.
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
// Not implemented in jsdom; the image preview needs both halves.
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
@@ -224,7 +175,9 @@ describe("FilesTab viewer", () => {
|
||||
});
|
||||
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
||||
expect(screen.queryByAltText("huge.png")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Save to host…" })).toBeTruthy();
|
||||
// The way out is named, and it is not a host path this pane could write:
|
||||
// a terminal inside the container, or a backup.
|
||||
expect(screen.getByText(/take a backup/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so in words when only a prefix of a big text file came back", async () => {
|
||||
@@ -239,7 +192,7 @@ describe("FilesTab viewer", () => {
|
||||
expect(screen.getByText("first megabyte")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers Save to host for a file it cannot render", async () => {
|
||||
it("says there is no preview, and where to open the file instead", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
|
||||
readContainerFile.mockResolvedValue(contents("a\x00b"));
|
||||
await renderTab();
|
||||
@@ -314,191 +267,6 @@ describe("FilesTab new folder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab host drag-and-drop", () => {
|
||||
it("uploads dropped paths into the directory on screen, then re-lists", async () => {
|
||||
await renderTab();
|
||||
listContainerFiles.mockClear();
|
||||
await drop(["/host/a.png", "/host/b.png"]);
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace");
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace");
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||
});
|
||||
|
||||
it("drops into the directory the user has navigated to", async () => {
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
await drop(["/host/a.png"]);
|
||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace/src");
|
||||
});
|
||||
|
||||
it("ignores a drop outside the pane — the listener is window-wide", async () => {
|
||||
// This is the whole routing discipline: the terminal's listener is live at
|
||||
// the same time, and only the hit-test keeps them apart.
|
||||
await renderTab();
|
||||
await drop(["/host/a.png"], { x: 5000, y: 5000 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("divides the payload position by devicePixelRatio on Windows only", async () => {
|
||||
// Only wry's WebView2 backend hands over *physical* pixels; the macOS and
|
||||
// GTK ones deliver logical points and `tauri-runtime-wry` does not rescale
|
||||
// them. At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside the
|
||||
// 800x600 pane — but the same payload on a HiDPI Mac or Linux box really
|
||||
// is (900, 900) and belongs to nobody.
|
||||
const originalDpr = window.devicePixelRatio;
|
||||
const originalUa = window.navigator.userAgent;
|
||||
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
configurable: true,
|
||||
});
|
||||
await renderTab();
|
||||
await drop(["/host/a.png"], { x: 900, y: 900 });
|
||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
|
||||
configurable: true,
|
||||
});
|
||||
vi.mocked(uploadFileToContainer).mockClear();
|
||||
await drop(["/host/a.png"], { x: 900, y: 900 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
// …and the *unhalved* point still lands, which is the half a HiDPI Mac
|
||||
// user was losing.
|
||||
await drop(["/host/a.png"], { x: 400, y: 300 });
|
||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
value: originalDpr,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value: originalUa,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a drop that lands on a toast floating over the pane", async () => {
|
||||
// Round 1. `ToastHost` is `fixed bottom-4 right-4 z-[60]` and 24rem wide,
|
||||
// and its error cards stay until dismissed — so a z-order gate asking "is
|
||||
// what is painted here part of my pane?" made the bottom-right corner of
|
||||
// this pane refuse drops for as long as one error was on screen. jsdom has
|
||||
// no `elementFromPoint`, so that branch only ran when a test supplied one;
|
||||
// the gate no longer asks, and this pins that nothing painted over a pane
|
||||
// can refuse a drop on its own account.
|
||||
await renderTab();
|
||||
const toastCard = document.createElement("div");
|
||||
document.body.appendChild(toastCard);
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => toastCard,
|
||||
});
|
||||
|
||||
await drop(["/host/a.png"], { x: 700, y: 550 });
|
||||
expect(uploadFileToContainer).toHaveBeenCalled();
|
||||
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
toastCard.remove();
|
||||
});
|
||||
|
||||
it("refuses a drop while a dialog is open, toast painted over it or not", async () => {
|
||||
// Round 2, which is the reason this file exists in its current shape. The
|
||||
// refusal pushes a toast; `ToastHost` is `z-[60]` and the `Modal` backdrop
|
||||
// is `z-50` in the same stacking context, so the *toast* becomes the
|
||||
// topmost element over a covered pane. A gate that asked `elementFromPoint`
|
||||
// "is a blocker painted here?" then answered no and uploaded into the
|
||||
// directory the dialog was covering — one refused drop was all it took to
|
||||
// open the hole. Both stubs below therefore have to be refused.
|
||||
await renderTab();
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.setAttribute("data-blocks-drop", "true");
|
||||
document.body.appendChild(backdrop);
|
||||
const toastCard = document.createElement("div"); // z-[60], above the backdrop
|
||||
document.body.appendChild(toastCard);
|
||||
const stub = (top: Element) =>
|
||||
Object.defineProperty(document, "elementFromPoint", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: () => top,
|
||||
});
|
||||
|
||||
stub(backdrop);
|
||||
await drop(["/host/a.png"], { x: 400, y: 300 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
|
||||
stub(toastCard);
|
||||
await drop(["/host/a.png"], { x: 700, y: 550 });
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
|
||||
delete (document as Partial<Document>).elementFromPoint;
|
||||
toastCard.remove();
|
||||
backdrop.remove();
|
||||
});
|
||||
|
||||
it("highlights the pane while a drag hovers it, and drops the highlight on leave", async () => {
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
await dragHandler?.({
|
||||
payload: { type: "over", position: { x: 100, y: 100 }, paths: [] },
|
||||
});
|
||||
});
|
||||
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "leave" } });
|
||||
});
|
||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab save to host", () => {
|
||||
it("copies a file out to the path the user picks", async () => {
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save to host… — notes.txt" }));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
|
||||
});
|
||||
|
||||
it("does not offer a directory download, which cannot work", async () => {
|
||||
await renderTab();
|
||||
expect(screen.queryByRole("button", { name: "Save to host… — src" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab drop hit test", () => {
|
||||
it("uploads nothing when a dialog is covering the pane", async () => {
|
||||
// The pane still has its rect underneath the viewer's `fixed inset-0`
|
||||
// portal, which is exactly why a rect alone was the wrong test.
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
await drop(["/host/a.png"]);
|
||||
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not paint the hint under a dialog either", async () => {
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
|
||||
});
|
||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab grid focus", () => {
|
||||
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
|
||||
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
|
||||
@@ -592,22 +360,28 @@ describe("FilesTab grid semantics", () => {
|
||||
await renderTab();
|
||||
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
|
||||
expect(rename.textContent).toBe("Rename");
|
||||
expect(rename.getAttribute("aria-label")).toContain("Rename");
|
||||
const saveTo = screen.getByRole("button", { name: "Save to host… — notes.txt" });
|
||||
expect(saveTo.getAttribute("aria-label")).toContain(saveTo.textContent!);
|
||||
expect(rename.getAttribute("aria-label")).toContain(rename.textContent!);
|
||||
});
|
||||
|
||||
it("mounts the live region empty, then fills it", async () => {
|
||||
// A `role="status"` node inserted already carrying its text is frequently
|
||||
// not announced at all, which is how every one of these went by in silence.
|
||||
createContainerDirectory.mockResolvedValue("/workspace/new");
|
||||
await renderTab();
|
||||
const live = screen.getByRole("status");
|
||||
expect(live.textContent).toBe("");
|
||||
|
||||
await drop(["/host/a.png"]);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
|
||||
});
|
||||
const input = screen.getByLabelText("New folder name");
|
||||
fireEvent.change(input, { target: { value: "new" } });
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
// Same node throughout — it is never unmounted.
|
||||
expect(screen.getByRole("status")).toBe(live);
|
||||
expect(live.textContent).toContain("Uploaded 1 item");
|
||||
expect(live.textContent).toContain('Created "new"');
|
||||
});
|
||||
|
||||
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
|
||||
@@ -618,130 +392,3 @@ describe("FilesTab grid semantics", () => {
|
||||
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab overwrite prompt", () => {
|
||||
it("asks before replacing, and re-uploads with overwrite on Replace", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
|
||||
await renderTab();
|
||||
|
||||
const pending = dropWithoutWaiting(["/host/notes.txt"]);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog.textContent).toContain("notes.txt");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenLastCalledWith(
|
||||
"p1",
|
||||
"/host/notes.txt",
|
||||
"/workspace",
|
||||
true,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("uploads nothing more on Skip", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
|
||||
await renderTab();
|
||||
|
||||
const pending = dropWithoutWaiting(["/host/notes.txt"]);
|
||||
await screen.findByRole("dialog");
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the blanket answers only when files are queued behind this one", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
|
||||
await renderTab();
|
||||
|
||||
const pending = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
|
||||
await screen.findByRole("dialog");
|
||||
expect(screen.getByRole("button", { name: "Replace all" })).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Skip all" }));
|
||||
await pending;
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dismissal. `Modal` gives every dialog Escape, a ✕ and click-outside for free,
|
||||
* and `OverwriteConfirmModal` maps all three onto `onChoose("skip")` — because
|
||||
* the destructive answer has to be chosen, and because a dialog that is closed
|
||||
* rather than answered must not leave the batch waiting forever or throw away
|
||||
* the files behind it.
|
||||
*/
|
||||
describe("FilesTab overwrite prompt dismissal", () => {
|
||||
/**
|
||||
* Drop two files where the first name is taken, and stop at the dialog. The
|
||||
* unsettled batch comes back wrapped — returning it bare from an `async`
|
||||
* helper would adopt it, and awaiting the helper would then wait for an
|
||||
* upload that cannot proceed until the helper has returned.
|
||||
*/
|
||||
async function dropIntoConflict(): Promise<{ batch: Promise<void> | undefined }> {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
|
||||
await renderTab();
|
||||
const batch = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
|
||||
await screen.findByRole("dialog");
|
||||
return { batch };
|
||||
}
|
||||
|
||||
/** What every dismissal has to leave behind: one skip, one upload, no clobber. */
|
||||
function expectSkippedAndCarriedOn() {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
||||
expect(uploadFileToContainer).toHaveBeenLastCalledWith("p1", "/host/b.txt", "/workspace");
|
||||
expect(uploadFileToContainer.mock.calls.some((call) => call[3] === true)).toBe(false);
|
||||
expect(screen.getByRole("status").textContent).toContain("skipped 1");
|
||||
}
|
||||
|
||||
it("counts Escape as a Skip", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await batch;
|
||||
});
|
||||
expectSkippedAndCarriedOn();
|
||||
});
|
||||
|
||||
it("counts the ✕ as a Skip", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
|
||||
await batch;
|
||||
});
|
||||
expectSkippedAndCarriedOn();
|
||||
});
|
||||
|
||||
it("counts a click on the backdrop as a Skip", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
// The overlay is the dialog panel's parent — `Modal` only closes when the
|
||||
// click landed on the overlay itself, not on anything inside the panel.
|
||||
const overlay = screen.getByRole("dialog").parentElement!;
|
||||
await act(async () => {
|
||||
fireEvent.click(overlay);
|
||||
await batch;
|
||||
});
|
||||
expectSkippedAndCarriedOn();
|
||||
});
|
||||
|
||||
it("does not dismiss on a click inside the dialog", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
fireEvent.click(screen.getByRole("dialog"));
|
||||
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
||||
await batch;
|
||||
});
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import type { FileEntry, Project } from "../../../lib/types";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import { classifyDrop, isDropTarget, DROP_BLOCKED_TOAST } from "../../../lib/dropTarget";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import FileViewerModal from "./FileViewerModal";
|
||||
import OverwriteConfirmModal from "./OverwriteConfirmModal";
|
||||
import { formatBytes } from "./format";
|
||||
|
||||
interface Props {
|
||||
@@ -17,7 +13,15 @@ interface Props {
|
||||
const PARENT_ROW = "..";
|
||||
|
||||
/**
|
||||
* The project's file manager.
|
||||
* The project's file browser.
|
||||
*
|
||||
* Container-side only: it lists, opens, renames and creates folders inside the
|
||||
* container, and it does no host filesystem I/O at all. A file gets *into* a
|
||||
* container by being dropped onto the Terminal tab, and a whole tree comes back
|
||||
* out through "Back up container" in the project's Workspace settings. Four
|
||||
* successive audits found that host paths crossing IPC were where the criticals
|
||||
* lived; those two paths are the ones that survived, and this pane is not one
|
||||
* of them.
|
||||
*
|
||||
* Interaction model, chosen to match every desktop file manager rather than
|
||||
* the old half-and-half: **single click selects, double click opens**. That
|
||||
@@ -42,16 +46,10 @@ export default function FilesTab({ project }: Props) {
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
busy,
|
||||
completed,
|
||||
conflict,
|
||||
resolveConflict,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
} = useFileManager(project.id);
|
||||
@@ -65,8 +63,6 @@ export default function FilesTab({ project }: Props) {
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [folderDraft, setFolderDraft] = useState("");
|
||||
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
||||
/** A host drag is currently over this pane. */
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
/** The row that owns the grid's single tab stop. */
|
||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||
|
||||
@@ -234,58 +230,6 @@ export default function FilesTab({ project }: Props) {
|
||||
goUp();
|
||||
}, [currentPath, goUp]);
|
||||
|
||||
// Host → container drag and drop.
|
||||
//
|
||||
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
|
||||
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
|
||||
// it), which blocks HTML5 drag inside the webview on Windows, and only the
|
||||
// native payload carries real file *paths*. The listener is window-wide, so
|
||||
// routing is `classifyDrop` — the rect hit test, which says *whose* drop it
|
||||
// is, plus the document-wide question a rect cannot answer: is a modal or a
|
||||
// blocking overlay on screen at all? That second half is deliberately not a
|
||||
// per-point z-order test; `lib/dropTarget.ts` records the two ways that went
|
||||
// wrong.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
const payload = event.payload;
|
||||
if (payload.type === "leave") {
|
||||
setDragOver(false);
|
||||
return;
|
||||
}
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
setDragOver(isDropTarget(paneRef.current, payload.position));
|
||||
return;
|
||||
}
|
||||
if (payload.type !== "drop") return;
|
||||
setDragOver(false);
|
||||
const verdict = classifyDrop(paneRef.current, payload.position);
|
||||
// Aimed at this pane and refused anyway: say so. Nothing else would —
|
||||
// the file just never appears in the listing.
|
||||
if (verdict === "blocked") {
|
||||
console.warn("[drop] refused: a dialog or overlay is open", payload.position);
|
||||
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
|
||||
return;
|
||||
}
|
||||
if (verdict !== "accept") return;
|
||||
const paths = payload.paths ?? [];
|
||||
if (paths.length === 0) return;
|
||||
await uploadPaths(paths);
|
||||
});
|
||||
if (cancelled) un();
|
||||
else unlisten = un;
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [running, uploadPaths]);
|
||||
|
||||
const breadcrumbs =
|
||||
currentPath === "/"
|
||||
? [{ label: "/", path: "/" }]
|
||||
@@ -324,10 +268,10 @@ export default function FilesTab({ project }: Props) {
|
||||
/**
|
||||
* The live region's text. One region, always mounted, filled and emptied —
|
||||
* a `role="status"` node that is *inserted* already carrying its text is
|
||||
* frequently not announced at all, which is how "uploading 3 items…" and
|
||||
* every completion notice used to go by in silence.
|
||||
* frequently not announced at all, which is how every completion notice used
|
||||
* to go by in silence.
|
||||
*/
|
||||
const liveText = busy ? busy : (completed ?? "");
|
||||
const liveText = completed ?? "";
|
||||
|
||||
return (
|
||||
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
||||
@@ -361,9 +305,6 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
<Button onClick={uploadFile} className="ml-1">
|
||||
Upload file
|
||||
</Button>
|
||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -372,9 +313,9 @@ export default function FilesTab({ project }: Props) {
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* The one failure that stays inline: it explains why the grid below is
|
||||
empty, it is in context, and there are no rows for it to scroll
|
||||
behind. Every *transient* failure — upload, rename, mkdir,
|
||||
save-to-host — goes to `ToastHost` instead, which is above
|
||||
the file viewer's overlay and does not scroll away. */}
|
||||
behind. Every *transient* failure — rename, new folder — goes to
|
||||
`ToastHost` instead, which is above the file viewer's overlay and
|
||||
does not scroll away. */}
|
||||
{error && (
|
||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||
{error}
|
||||
@@ -560,18 +501,6 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Save to host… — ${entry.name}`}
|
||||
className="ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry);
|
||||
}}
|
||||
>
|
||||
Save to host…
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
@@ -594,34 +523,11 @@ export default function FilesTab({ project }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop hint. Purely decorative — the native listener is what accepts the
|
||||
drop, so this must never intercept pointer events. */}
|
||||
{dragOver && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-[var(--accent)] bg-[var(--bg-primary)]/70"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Drop files into {currentPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && (
|
||||
<OverwriteConfirmModal
|
||||
name={conflict.name}
|
||||
directory={conflict.directory}
|
||||
remaining={conflict.remaining}
|
||||
onChoose={resolveConflict}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<FileViewerModal
|
||||
projectId={project.id}
|
||||
entry={viewing}
|
||||
onClose={() => setViewing(null)}
|
||||
onSaveToHost={downloadFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { OverwriteChoice } from "../../../lib/uploadErrors";
|
||||
import Button from "../../ui/Button";
|
||||
import Modal from "../../ui/Modal";
|
||||
|
||||
interface Props {
|
||||
/** Bare name of the file that is already there. */
|
||||
name: string;
|
||||
/** Container directory it is going into. */
|
||||
directory: string;
|
||||
/** How many more files are queued behind this one. */
|
||||
remaining: number;
|
||||
onChoose: (choice: OverwriteChoice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "That name is taken — replace it?"
|
||||
*
|
||||
* This exists because the backend stopped overwriting silently, and a raw
|
||||
* error string would have been a worse answer than the old silent clobber: it
|
||||
* tells the user their drop failed without telling them it *can* succeed. The
|
||||
* dialog names the file and the directory, because a drop is aimed with a
|
||||
* mouse and "notes.txt" alone does not say which `notes.txt`.
|
||||
*
|
||||
* The blanket answers only appear when there is something to apply them to — a
|
||||
* single-file drop with "Replace all" on it invites the reflex of clicking the
|
||||
* widest button for no benefit.
|
||||
*
|
||||
* Dismissing (Escape, ✕, click-outside) is a **skip**, never a replace: the
|
||||
* destructive answer has to be chosen explicitly.
|
||||
*/
|
||||
export default function OverwriteConfirmModal({ name, directory, remaining, onChoose }: Props) {
|
||||
const footer = (
|
||||
<>
|
||||
{remaining > 0 && (
|
||||
<>
|
||||
<Button size="md" onClick={() => onChoose("skip-all")}>
|
||||
Skip all
|
||||
</Button>
|
||||
<Button size="md" onClick={() => onChoose("replace-all")}>
|
||||
Replace all
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="md" onClick={() => onChoose("skip")}>
|
||||
Skip
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={() => onChoose("replace")}>
|
||||
Replace
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="A file with that name is already there"
|
||||
description={directory}
|
||||
onClose={() => onChoose("skip")}
|
||||
footer={footer}
|
||||
widthClassName="w-[30rem]"
|
||||
>
|
||||
<p className="text-[13px] text-[var(--text-primary)]">
|
||||
<span className="font-mono">{name}</span> already exists in{" "}
|
||||
<span className="font-mono">{directory}</span>. Replacing it overwrites the container's
|
||||
copy, and that cannot be undone from here.
|
||||
</p>
|
||||
{remaining > 0 && (
|
||||
<p className="mt-2 text-xs text-[var(--text-secondary)]">
|
||||
{remaining} more file{remaining === 1 ? "" : "s"} still to upload.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -109,7 +109,16 @@ export default function RuntimeSection({
|
||||
|
||||
<ConfigGroup
|
||||
title="Claude Code settings"
|
||||
description="Per-project CLI behaviour. Anything left on Global follows Settings; Off overrides a global On."
|
||||
description={
|
||||
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
|
||||
"Off overrides a global On. Changing any of these recreates the container, " +
|
||||
"which commits a new image layer — so flipping switches repeatedly costs disk. " +
|
||||
"Turning TUI mode, Effort level, Focus mode or Session recap back to Global " +
|
||||
"also needs the base image updated first: those four are cleared by removing a " +
|
||||
"key, and an older image's startup script ignores the instruction to remove it. " +
|
||||
"Update the base image from Overview. TUI mode, Effort level and Focus mode " +
|
||||
"visibly refuse to switch off until you do; Session recap just stays off silently."
|
||||
}
|
||||
>
|
||||
<ClaudeCodeSettingsEditor
|
||||
scope="project"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import WorkspaceSection from "./WorkspaceSection";
|
||||
import type { Project } from "../../../../lib/types";
|
||||
|
||||
// The Browse button is the OS folder picker.
|
||||
const open = vi.fn();
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: (...args: unknown[]) => open(...args),
|
||||
}));
|
||||
|
||||
const baseProject: Project = {
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
llamacpp_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: true,
|
||||
full_permissions: false,
|
||||
permission_mode: null,
|
||||
ssh_key_path: null,
|
||||
ca_cert_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const save = vi.fn().mockResolvedValue(true);
|
||||
|
||||
function renderSection(over: Partial<Project> = {}, disabled = false) {
|
||||
return render(
|
||||
<WorkspaceSection
|
||||
project={{ ...baseProject, ...over }}
|
||||
save={save}
|
||||
disabled={disabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Every folder list this component has sent to `update_project`. */
|
||||
function savedLists() {
|
||||
return save.mock.calls
|
||||
.filter(([patch]) => "paths" in patch)
|
||||
.map(([patch]) => patch.paths);
|
||||
}
|
||||
|
||||
describe("WorkspaceSection — the blank row is never stored", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/**
|
||||
* The bug this file exists for. `create_container` mounts every stored row
|
||||
* unfiltered, so a persisted `{host_path: "", mount_name: ""}` becomes
|
||||
* `{"Target": "/workspace/", "Source": ""}` and the daemon refuses the whole
|
||||
* container with `field Source must not be empty` — the project can never be
|
||||
* started or recreated again. Click "+ Add folder", blur a field, and it is
|
||||
* bricked.
|
||||
*/
|
||||
it("drops the placeholder row when a real edit is saved", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||
fireEvent.blur(hostPath);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("drops it when Browse fills a different row in", async () => {
|
||||
open.mockResolvedValueOnce("/src/api-v2");
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
// The picker is awaited inside the handler, so the state update that
|
||||
// follows it lands outside the click.
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Browse" })[0]);
|
||||
});
|
||||
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("drops it when a row is removed", () => {
|
||||
renderSection({
|
||||
paths: [
|
||||
{ host_path: "/src/api", mount_name: "api" },
|
||||
{ host_path: "/src/web", mount_name: "web" },
|
||||
],
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove folder 2" }));
|
||||
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("never sends a row with an empty host path, whatever the route", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||
fireEvent.blur(hostPath);
|
||||
|
||||
for (const list of savedLists()) {
|
||||
for (const row of list) {
|
||||
expect(row.host_path).not.toBe("");
|
||||
expect(row.mount_name).not.toBe("");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceSection — what a blur is allowed to save", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/**
|
||||
* Both inputs save on blur, so tabbing from the host path to the mount name
|
||||
* fires a save with the name still empty — which `update_project` refuses,
|
||||
* turning an ordinary keystroke into an error toast.
|
||||
*/
|
||||
it("holds a half-filled row back until it is complete", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
const newHostPath = screen.getByLabelText("Folder 2 host path");
|
||||
fireEvent.change(newHostPath, { target: { value: "/src/web" } });
|
||||
fireEvent.blur(newHostPath);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
const newMountName = screen.getByLabelText("Folder 2 mount name");
|
||||
fireEvent.change(newMountName, { target: { value: "web" } });
|
||||
fireEvent.blur(newMountName);
|
||||
expect(savedLists()[0]).toEqual([
|
||||
{ host_path: "/src/api", mount_name: "api" },
|
||||
{ host_path: "/src/web", mount_name: "web" },
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Blurring out of an untouched field is not an edit. Saving anyway would
|
||||
* round-trip the filtered list through `project` and take the empty row away
|
||||
* while the user was still filling it in.
|
||||
*/
|
||||
it("saves nothing when the blur changed nothing", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
fireEvent.blur(screen.getByLabelText("Folder 1 mount name"));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Folder 2 host path")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still saves a rename, which does not go through the folder list", () => {
|
||||
renderSection();
|
||||
const name = screen.getByDisplayValue("api-server");
|
||||
fireEvent.change(name, { target: { value: "api-v2" } });
|
||||
fireEvent.blur(name);
|
||||
expect(save).toHaveBeenCalledWith({ name: "api-v2" });
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,14 @@ interface Props {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** Whether two folder lists are the same rows in the same order. */
|
||||
function sameRows(a: ProjectPath[], b: ProjectPath[]): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every((row, i) => row.host_path === b[i].host_path && row.mount_name === b[i].mount_name)
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||
@@ -19,6 +27,27 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
setPaths(project.paths ?? []);
|
||||
}, [project]);
|
||||
|
||||
/**
|
||||
* Persist a folder list, minus the rows that are only in it because the UI
|
||||
* put them there.
|
||||
*
|
||||
* **The blank row must never reach the store.** "+ Add folder" inserts
|
||||
* `{host_path: "", mount_name: ""}` deliberately, and `create_container`
|
||||
* mounts every stored row unfiltered — a stored blank one becomes
|
||||
* `{"Target": "/workspace/", "Source": ""}`, which the daemon rejects with
|
||||
* `field Source must not be empty`. The project then cannot be started or
|
||||
* recreated at all, from a click and a blur. `AddProjectDialog` has always
|
||||
* filtered this; this section computed the filtered list and then saved the
|
||||
* unfiltered one.
|
||||
*
|
||||
* Every save goes through here for that reason — Browse and Remove write the
|
||||
* list too, and either can be holding a blank row from an earlier click.
|
||||
*/
|
||||
const persist = (rows: ProjectPath[]) => {
|
||||
const filled = rows.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||
return save({ paths: filled });
|
||||
};
|
||||
|
||||
/**
|
||||
* Save only when every row is fully filled in.
|
||||
*
|
||||
@@ -27,12 +56,18 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
* a half-filled row is refused — so the unconditional save turned an ordinary
|
||||
* keystroke into an error toast. A blank row is *not* incomplete: the
|
||||
* "+ Add folder" button adds one deliberately, and it is dropped on save.
|
||||
*
|
||||
* A blur that changed nothing saves nothing, which is what keeps the blank
|
||||
* row on screen while it is being filled in: persisting the filtered list
|
||||
* would round-trip through `project` and take the empty row away under the
|
||||
* cursor.
|
||||
*/
|
||||
const saveIfComplete = () => {
|
||||
const filled = paths.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||
const halfFilled = filled.some((p) => !p.host_path.trim() || !p.mount_name.trim());
|
||||
if (halfFilled) return;
|
||||
return save({ paths });
|
||||
if (sameRows(filled, project.paths ?? [])) return;
|
||||
return persist(paths);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -106,7 +141,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
mount_name: updated[i].mount_name || basename,
|
||||
};
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
persist(updated);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -137,7 +172,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
onClick={() => {
|
||||
const updated = paths.filter((_, j) => j !== i);
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
persist(updated);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
|
||||
@@ -4,15 +4,11 @@ import { useFileManager } from "./useFileManager";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
|
||||
const listContainerFiles = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
const uploadFileToContainer = vi.fn();
|
||||
const renameContainerPath = vi.fn();
|
||||
const createContainerDirectory = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
||||
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
@@ -35,13 +31,6 @@ const toastText = () =>
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.join("\n");
|
||||
|
||||
const save = vi.fn();
|
||||
const openDialog = vi.fn();
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: (opts: unknown) => save(opts),
|
||||
open: (opts: unknown) => openDialog(opts),
|
||||
}));
|
||||
|
||||
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
name,
|
||||
path: `/workspace/${name}`,
|
||||
@@ -100,48 +89,6 @@ describe("useFileManager navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager uploads", () => {
|
||||
it("uploads every dropped path into the current directory, then re-lists once", async () => {
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.png", "/host/b.png"]);
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace/app");
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace/app");
|
||||
// One refresh for the batch, not one per file.
|
||||
expect(listContainerFiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports a failed upload but still lists whatever did land", async () => {
|
||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]);
|
||||
});
|
||||
// Inline `error` is reserved for the listing failure the user can see in
|
||||
// context; a failed upload goes where it cannot scroll away.
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(toastText()).toContain("too large");
|
||||
expect(listContainerFiles).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the file picker is cancelled", async () => {
|
||||
openDialog.mockResolvedValue(null);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFile();
|
||||
});
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager rename and mkdir", () => {
|
||||
it("sends the bare new name, never a path, and re-lists on success", async () => {
|
||||
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
|
||||
@@ -204,47 +151,22 @@ describe("useFileManager rename and mkdir", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager save to host", () => {
|
||||
it("writes to the path the user picked", async () => {
|
||||
save.mockResolvedValue("/host/Downloads/a.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.downloadFile(file("a.txt"));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
"/workspace/a.txt",
|
||||
"/host/Downloads/a.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a refused download — a directory is no longer written as garbage", async () => {
|
||||
save.mockResolvedValue("/host/Downloads/src");
|
||||
downloadContainerFile.mockRejectedValue("/workspace/src is a folder — download its files individually");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.downloadFile(file("src", { is_directory: true }));
|
||||
});
|
||||
expect(toastText()).toContain("is a folder");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager stays where the user is", () => {
|
||||
it("does not drag the pane back when the user navigates away mid-upload", async () => {
|
||||
it("does not drag the pane back when the user navigates away mid-operation", async () => {
|
||||
// The closure captured `/workspace`; the user is in `/workspace/src` by the
|
||||
// time the copy finishes. Re-listing the *captured* path is what used to
|
||||
// time the rename finishes. Re-listing the *captured* path is what used to
|
||||
// yank them out of the directory they had walked into.
|
||||
let failUpload: (reason: unknown) => void = () => {};
|
||||
let failRename: (reason: unknown) => void = () => {};
|
||||
// `Once`, deliberately: `clearAllMocks` clears calls but not
|
||||
// implementations, so a never-settling one would hang every test after it.
|
||||
uploadFileToContainer.mockImplementationOnce(
|
||||
() => new Promise((_resolve, reject) => { failUpload = reject; }),
|
||||
renameContainerPath.mockImplementationOnce(
|
||||
() => new Promise((_resolve, reject) => { failRename = reject; }),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<void>;
|
||||
let rename!: Promise<boolean>;
|
||||
await act(async () => {
|
||||
upload = result.current.uploadPaths(["/host/big.bin"]);
|
||||
rename = result.current.renameEntry(file("big.bin"), "bigger.bin");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -255,13 +177,13 @@ describe("useFileManager stays where the user is", () => {
|
||||
listContainerFiles.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
failUpload("cp: no space left on device");
|
||||
await upload;
|
||||
failRename("mv: no space left on device");
|
||||
await rename;
|
||||
});
|
||||
|
||||
expect(result.current.currentPath).toBe("/workspace/src");
|
||||
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
|
||||
// No re-list of the directory the upload targeted…
|
||||
// No re-list of the directory the rename targeted…
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
// …and no failure text painted over the listing that replaced it.
|
||||
expect(result.current.error).toBeNull();
|
||||
@@ -269,13 +191,14 @@ describe("useFileManager stays where the user is", () => {
|
||||
});
|
||||
|
||||
it("re-lists when the user stayed put, which is the ordinary case", async () => {
|
||||
renameContainerPath.mockResolvedValue("/workspace/b.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.png"]);
|
||||
await result.current.renameEntry(file("a.txt"), "b.txt");
|
||||
});
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||
});
|
||||
@@ -316,248 +239,11 @@ describe("useFileManager stays where the user is", () => {
|
||||
await result.current.navigate("/root");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
// The pane never left /workspace, so an upload started now targets it.
|
||||
// The pane never left /workspace, so a new folder made now lands there.
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.png"]);
|
||||
await result.current.createFolder("new");
|
||||
});
|
||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager overwrite prompt", () => {
|
||||
const alreadyThere = "FILE_EXISTS: /workspace/a.txt already exists";
|
||||
|
||||
it("asks rather than clobbering, and replaces on demand", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<void>;
|
||||
await act(async () => {
|
||||
upload = result.current.uploadPaths(["/host/a.txt"]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
|
||||
expect(result.current.conflict?.directory).toBe("/workspace");
|
||||
// One file, so there is nothing for a blanket answer to apply to.
|
||||
expect(result.current.conflict?.remaining).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("replace");
|
||||
await upload;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||
expect(result.current.conflict).toBeNull();
|
||||
});
|
||||
|
||||
it("skips without uploading anything when the user says so", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<void>;
|
||||
await act(async () => {
|
||||
upload = result.current.uploadPaths(["/host/a.txt"]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict).not.toBeNull());
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("skip");
|
||||
await upload;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||
// A skip is a choice, not a failure — nothing to report.
|
||||
expect(toastText()).not.toContain("could not be uploaded");
|
||||
});
|
||||
|
||||
it("asks once for a batch when the answer is Replace all", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/b.txt already exists");
|
||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<void>;
|
||||
await act(async () => {
|
||||
upload = result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict?.remaining).toBe(1));
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("replace-all");
|
||||
await upload;
|
||||
});
|
||||
|
||||
expect(result.current.conflict).toBeNull();
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(4, "p1", "/host/b.txt", "/workspace", true);
|
||||
});
|
||||
|
||||
it("leaves an unrelated failure alone — no prompt offering a button that cannot work", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/huge.bin"]);
|
||||
});
|
||||
expect(result.current.conflict).toBeNull();
|
||||
expect(toastText()).toContain("too large");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The loop, end to end. The prompt only earns its place if the *batch* survives
|
||||
* it: one answer, given once, has to leave every other file in the drop exactly
|
||||
* where it would have been.
|
||||
*/
|
||||
describe("useFileManager overwrite prompt closes the loop", () => {
|
||||
const clash = (name: string) => `FILE_EXISTS: /workspace/${name} already exists`;
|
||||
|
||||
/**
|
||||
* Start an upload and wait for it to stop at the prompt, handing back the
|
||||
* still-unsettled batch.
|
||||
*
|
||||
* Wrapped in an object on purpose: an `async` function that returned the
|
||||
* promise itself would *adopt* it, so awaiting the helper would wait for the
|
||||
* whole upload — which cannot finish until the question is answered, which
|
||||
* cannot happen until the helper returns. That deadlock looks exactly like
|
||||
* the hang these tests exist to rule out.
|
||||
*/
|
||||
async function uploadUntilPrompt(
|
||||
result: { current: ReturnType<typeof useFileManager> },
|
||||
paths: string[],
|
||||
): Promise<{ batch: Promise<void> }> {
|
||||
let batch!: Promise<void>;
|
||||
await act(async () => {
|
||||
batch = result.current.uploadPaths(paths);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict).not.toBeNull());
|
||||
return { batch };
|
||||
}
|
||||
|
||||
it("replaces the file that clashed and still uploads the rest of the batch", async () => {
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt")) // 1: a.txt, no overwrite
|
||||
.mockResolvedValueOnce(undefined) // 2: a.txt, overwrite: true
|
||||
.mockResolvedValueOnce(undefined); // 3: b.txt, no clash
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
|
||||
expect(result.current.conflict?.name).toBe("a.txt");
|
||||
expect(result.current.conflict?.remaining).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("replace");
|
||||
await batch;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
|
||||
// The retry is the whole point: same file, same directory, overwrite on.
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||
// …and "Replace" answered for *that* file only, so the next one is offered
|
||||
// to the backend the safe way round.
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
|
||||
expect(result.current.conflict).toBeNull();
|
||||
expect(result.current.completed).toContain("Uploaded 2 items");
|
||||
expect(toastText()).not.toContain("could not be uploaded");
|
||||
});
|
||||
|
||||
it("moves on to the next file on Skip rather than ending the batch", async () => {
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt"))
|
||||
.mockResolvedValueOnce(undefined); // b.txt still goes
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("skip");
|
||||
await batch;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.txt", "/workspace");
|
||||
// Nothing was overwritten.
|
||||
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
|
||||
expect(result.current.completed).toContain("skipped 1");
|
||||
});
|
||||
|
||||
it("dismissing the dialog is a Skip — the batch carries on", async () => {
|
||||
// `OverwriteConfirmModal` maps Escape / ✕ / click-outside onto this exact
|
||||
// call, so a dismissal must not hang the loop or abort the drop.
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
|
||||
await act(async () => {
|
||||
// What `Modal`'s `onClose` produces.
|
||||
result.current.resolveConflict("skip");
|
||||
await batch;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.completed).toContain("Uploaded 1 item, skipped 1");
|
||||
expect(result.current.busy).toBeNull();
|
||||
});
|
||||
|
||||
it("answers every remaining clash with Skip all, asking only once", async () => {
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt"))
|
||||
.mockRejectedValueOnce(clash("b.txt"))
|
||||
.mockRejectedValueOnce(clash("c.txt"));
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt", "/host/c.txt"]);
|
||||
expect(result.current.conflict?.remaining).toBe(2);
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("skip-all");
|
||||
await batch;
|
||||
});
|
||||
|
||||
// Three attempts, no second prompt, nothing replaced.
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
|
||||
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
|
||||
expect(result.current.conflict).toBeNull();
|
||||
expect(result.current.completed).toContain("skipped 3");
|
||||
});
|
||||
|
||||
it("puts a picked file through exactly the road a dropped one takes", async () => {
|
||||
// The Upload button and the native drop listener are one routine —
|
||||
// `uploadPaths` — so the prompt, the retry and the blanket answers cannot
|
||||
// drift apart between them. This is that claim, from the picker end.
|
||||
openDialog.mockResolvedValueOnce(["/host/a.txt", "/host/b.txt"]);
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt"))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let picked!: Promise<void>;
|
||||
await act(async () => {
|
||||
picked = result.current.uploadFile();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("replace");
|
||||
await picked;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
|
||||
});
|
||||
|
||||
it("does not leave the batch waiting for an answer that can never arrive", async () => {
|
||||
// The pane unmounted mid-prompt (tab closed, container stopped). The upload
|
||||
// promise has to settle, or `busy` never clears and the loop leaks.
|
||||
uploadFileToContainer.mockRejectedValueOnce(clash("a.txt"));
|
||||
const { result, unmount } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt"]);
|
||||
unmount();
|
||||
await expect(batch).resolves.toBeUndefined();
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -567,8 +253,6 @@ describe("useFileManager overwrite prompt closes the loop", () => {
|
||||
* reported that way is a sentence nobody reads.
|
||||
*/
|
||||
describe("useFileManager surfaces written refusals as prose", () => {
|
||||
const hiddenFolder =
|
||||
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
|
||||
const outsideRoots =
|
||||
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
|
||||
|
||||
@@ -576,10 +260,10 @@ describe("useFileManager surfaces written refusals as prose", () => {
|
||||
const lastToast = () => pushToast.mock.calls.at(-1)?.[0];
|
||||
|
||||
it("puts the write-root refusal in the headline, not behind Details", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(outsideRoots);
|
||||
createContainerDirectory.mockRejectedValueOnce(outsideRoots);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.txt"]);
|
||||
await result.current.createFolder("new");
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe(outsideRoots);
|
||||
@@ -587,39 +271,24 @@ describe("useFileManager surfaces written refusals as prose", () => {
|
||||
expect(lastToast().message).not.toMatch(/^Error:/);
|
||||
});
|
||||
|
||||
it("says it once for a whole batch that failed the same way", async () => {
|
||||
// The refusal is about the target directory, so every file in the drop
|
||||
// fails identically — three copies of the same sentence is not detail.
|
||||
uploadFileToContainer.mockRejectedValue(outsideRoots);
|
||||
it("unwraps an `Error` rather than stamping \"Error:\" on prose", async () => {
|
||||
renameContainerPath.mockRejectedValueOnce(new Error(outsideRoots));
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
|
||||
await result.current.renameEntry(file("a.txt"), "b.txt");
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe(outsideRoots);
|
||||
expect(lastToast().detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does the same for a refused save to the host", async () => {
|
||||
save.mockResolvedValue("/home/me/.ssh/a.txt");
|
||||
downloadContainerFile.mockRejectedValueOnce(new Error(hiddenFolder));
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.downloadFile(file("a.txt"));
|
||||
});
|
||||
|
||||
// Unwrapped: an `Error` on the way through must not stamp "Error:" on prose.
|
||||
expect(lastToast().message).toBe(hiddenFolder);
|
||||
});
|
||||
|
||||
it("keeps the hook's own headline when the failure is not a written refusal", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("no space left on device");
|
||||
createContainerDirectory.mockRejectedValueOnce("no space left on device");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.txt"]);
|
||||
await result.current.createFolder("new");
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe("A file could not be uploaded");
|
||||
expect(lastToast().message).toBe('Could not create "new"');
|
||||
expect(lastToast().detail).toBe("no space left on device");
|
||||
});
|
||||
});
|
||||
|
||||
+20
-235
@@ -1,36 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
import {
|
||||
errorText,
|
||||
fileExistsPath,
|
||||
isFileExistsError,
|
||||
readableRefusal,
|
||||
type OverwriteChoice,
|
||||
} from "../lib/uploadErrors";
|
||||
|
||||
/**
|
||||
* One upload waiting on the user to say whether it may replace what is there.
|
||||
* `remaining` is how many files are queued behind this one, which is what
|
||||
* decides whether the blanket answers are worth offering.
|
||||
*/
|
||||
export interface UploadConflict {
|
||||
/** Host file being uploaded. */
|
||||
hostPath: string;
|
||||
/** Bare name, for the prompt. */
|
||||
name: string;
|
||||
/** Container directory it is going into. */
|
||||
directory: string;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
/** `/a/b/c.txt` and `C:\a\b\c.txt` both give `c.txt`. */
|
||||
function baseName(path: string): string {
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
import { errorText, readableRefusal } from "../lib/refusalText";
|
||||
|
||||
/**
|
||||
* ## Where failures are reported
|
||||
@@ -41,22 +13,19 @@ function baseName(path: string): string {
|
||||
* (empty) grid. It is on screen, it is in context, it explains why there are
|
||||
* no rows, and it is not transient — it stands until the directory lists.
|
||||
*
|
||||
* Every **transient operation** failure — upload, rename, create folder,
|
||||
* save-to-host — goes to `ToastHost` instead. Those used to land
|
||||
* in the same inline `error` div, which is the first child of the *scrolling*
|
||||
* list: three hundred rows down, a refused rename produced no visible change
|
||||
* at all, just a rename box that stayed open for no stated reason. Worse, the
|
||||
* file viewer routes its "Save to host…" through the same call, and the viewer
|
||||
* is a `fixed inset-0` portal at `z-50` — so that failure reported *behind* the
|
||||
* dialog that caused it. The toast host is a persistent `aria-live` region at
|
||||
* `z-[60]`, i.e. the one place in the app that is above a modal and does not
|
||||
* scroll away.
|
||||
* Every **transient operation** failure — rename, create folder — goes to
|
||||
* `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||
* is the first child of the *scrolling* list: three hundred rows down, a
|
||||
* refused rename produced no visible change at all, just a rename box that
|
||||
* stayed open for no stated reason. The toast host is a persistent `aria-live`
|
||||
* region at `z-[60]`, i.e. the one place in the app that is above a modal and
|
||||
* does not scroll away.
|
||||
*
|
||||
* ## Where the current directory lives
|
||||
*
|
||||
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
|
||||
* after an await). Every long operation captures the directory it targets at
|
||||
* the start and compares it against the ref at the end: a 200 MB upload into
|
||||
* the start and compares it against the ref at the end: a slow rename in
|
||||
* `/workspace` must not drag the pane back out of `src/` because that is where
|
||||
* the closure happened to be created. The ref moves at the *start* of a
|
||||
* navigation rather than when the listing lands, because the question being
|
||||
@@ -68,14 +37,11 @@ export function useFileManager(projectId: string) {
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
/**
|
||||
* What just finished. A live region that only ever says "uploading…" tells a
|
||||
* screen reader user when to start waiting and never when to stop.
|
||||
* What just finished, for the live region — a rename or a new folder is a
|
||||
* change a sighted user sees in the grid and a screen reader user does not.
|
||||
*/
|
||||
const [completed, setCompleted] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<UploadConflict | null>(null);
|
||||
|
||||
const currentPathRef = useRef(currentPath);
|
||||
|
||||
@@ -87,45 +53,29 @@ export function useFileManager(projectId: string) {
|
||||
*/
|
||||
const navGeneration = useRef(0);
|
||||
|
||||
const startWork = useCallback((note: string) => {
|
||||
setBusy(note);
|
||||
setCompleted(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Report a failed operation, given the headline this hook would write and the
|
||||
* raw failures behind it.
|
||||
* raw failure behind it.
|
||||
*
|
||||
* The headline is what the *hook* knows ("Could not rename …"); it is a
|
||||
* category, not an explanation. Some backend refusals are already a finished
|
||||
* sentence written for the person reading it — a hidden host folder, a
|
||||
* container path outside the roots this panel may change — and those used to
|
||||
* sentence written for the person reading it — a container path outside the
|
||||
* roots this panel may change, a name it will not create — and those used to
|
||||
* arrive as the toast's `detail`, which `ToastHost` renders as collapsed
|
||||
* monospace behind a "Details" button. So the sentence that said what was
|
||||
* wrong and what to do about it was hidden under a headline that said
|
||||
* neither. When every failure reduces to the *same* such sentence — which is
|
||||
* the normal case, since these refusals are about the target directory and so
|
||||
* fail identically for every file in a batch — it becomes the headline and
|
||||
* there is nothing left to hide.
|
||||
* neither. When there is such a sentence it becomes the headline, and there
|
||||
* is nothing left to hide.
|
||||
*/
|
||||
const report = useCallback((message: string, ...causes: unknown[]) => {
|
||||
const refusals = causes.map(readableRefusal);
|
||||
const shared =
|
||||
causes.length > 0 && refusals.every((r) => r !== null)
|
||||
? [...new Set(refusals as string[])]
|
||||
: [];
|
||||
const promoted = shared.length === 1 ? shared[0] : null;
|
||||
const report = useCallback((message: string, cause: unknown) => {
|
||||
const promoted = readableRefusal(cause);
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: promoted ?? message,
|
||||
detail: promoted || causes.length === 0 ? undefined : causes.map(errorText).join("\n"),
|
||||
detail: promoted ? undefined : errorText(cause),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback((message: string) => {
|
||||
useAppState.getState().pushToast({ kind: "success", message });
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback(
|
||||
async (path: string) => {
|
||||
const mine = ++navGeneration.current;
|
||||
@@ -163,164 +113,6 @@ export function useFileManager(projectId: string) {
|
||||
navigate(currentPathRef.current);
|
||||
}, [navigate]);
|
||||
|
||||
/** Copy an entry out to a host path the user picks. */
|
||||
const downloadFile = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
try {
|
||||
const hostPath = await save({ defaultPath: entry.name });
|
||||
if (!hostPath) return;
|
||||
// Every sibling operation sets `busy`; this one did not, so a 200 MB
|
||||
// copy was a click, then a frozen-looking pane, then nothing.
|
||||
startWork(`Saving "${entry.name}" to the host…`);
|
||||
try {
|
||||
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
||||
setCompleted(`Saved "${entry.name}" to ${hostPath}.`);
|
||||
confirm(`Saved "${entry.name}" to the host.`);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
} catch (e) {
|
||||
report(`Could not save "${entry.name}" to the host`, e);
|
||||
}
|
||||
},
|
||||
[projectId, startWork, report, confirm],
|
||||
);
|
||||
|
||||
/**
|
||||
* The pending answer to `conflict`. Kept in a ref rather than state because
|
||||
* the upload loop is `await`ing it — it needs the resolver, not a re-render.
|
||||
*/
|
||||
const conflictResolver = useRef<((choice: OverwriteChoice) => void) | null>(null);
|
||||
|
||||
const resolveConflict = useCallback((choice: OverwriteChoice) => {
|
||||
const resolve = conflictResolver.current;
|
||||
conflictResolver.current = null;
|
||||
setConflict(null);
|
||||
resolve?.(choice);
|
||||
}, []);
|
||||
|
||||
// A pane unmounted mid-prompt (the tab was closed, the container stopped)
|
||||
// would otherwise leave the upload loop awaiting an answer that can never
|
||||
// come. Skipping is the safe reading of "the dialog went away".
|
||||
useEffect(
|
||||
() => () => {
|
||||
conflictResolver.current?.("skip-all");
|
||||
conflictResolver.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const askOverwrite = useCallback(
|
||||
(hostPath: string, directory: string, remaining: number, containerPath: string | null) =>
|
||||
new Promise<OverwriteChoice>((resolve) => {
|
||||
// One batch asks one question at a time — the loop awaits each answer —
|
||||
// so a resolver still sitting here belongs to a *different* batch (two
|
||||
// drops in flight at once, or a drop landing while the Upload button's
|
||||
// batch is still copying). Installing over it would leave that batch
|
||||
// awaiting an answer no dialog can ever produce: a silent hang, with
|
||||
// its file neither uploaded nor skipped. Skipping it is the same
|
||||
// reading of "the dialog went away" the unmount cleanup uses.
|
||||
conflictResolver.current?.("skip");
|
||||
conflictResolver.current = resolve;
|
||||
setConflict({
|
||||
hostPath,
|
||||
name: baseName(containerPath ?? hostPath),
|
||||
directory,
|
||||
remaining,
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Copy host files into the current directory. Shared by the Upload button and
|
||||
* the native drag-drop listener, so a dropped file and a picked one take the
|
||||
* same path — including the one refresh at the end rather than one per file.
|
||||
*
|
||||
* The backend refuses to overwrite unless asked to, so a name clash is not a
|
||||
* failure here: it is a question, and the answer can be given once for the
|
||||
* whole batch.
|
||||
*/
|
||||
const uploadPaths = useCallback(
|
||||
async (hostPaths: string[]) => {
|
||||
if (hostPaths.length === 0) return;
|
||||
// The directory this upload is *for*. Compared against the live ref at
|
||||
// the end, because the user is free to walk away while it copies.
|
||||
const target = currentPathRef.current;
|
||||
startWork(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`);
|
||||
/** Raw failures, kept unstringified so `report` can read their shape. */
|
||||
const failures: unknown[] = [];
|
||||
let uploaded = 0;
|
||||
let skipped = 0;
|
||||
/** A "…all" answer, applied to every remaining clash without asking. */
|
||||
let blanket: OverwriteChoice | null = null;
|
||||
try {
|
||||
for (let i = 0; i < hostPaths.length; i++) {
|
||||
const hostPath = hostPaths[i];
|
||||
try {
|
||||
await commands.uploadFileToContainer(projectId, hostPath, target);
|
||||
uploaded++;
|
||||
continue;
|
||||
} catch (e) {
|
||||
if (!isFileExistsError(e)) {
|
||||
failures.push(e);
|
||||
continue;
|
||||
}
|
||||
const choice: OverwriteChoice =
|
||||
blanket ??
|
||||
(await askOverwrite(
|
||||
hostPath,
|
||||
target,
|
||||
hostPaths.length - i - 1,
|
||||
fileExistsPath(e),
|
||||
));
|
||||
if (choice === "replace-all" || choice === "skip-all") blanket = choice;
|
||||
if (choice === "skip" || choice === "skip-all") {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await commands.uploadFileToContainer(projectId, hostPath, target, true);
|
||||
uploaded++;
|
||||
} catch (e) {
|
||||
failures.push(e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
const summary =
|
||||
`Uploaded ${uploaded} item${uploaded === 1 ? "" : "s"}` +
|
||||
(skipped > 0 ? `, skipped ${skipped}` : "") +
|
||||
(failures.length > 0 ? `, ${failures.length} failed` : "") +
|
||||
".";
|
||||
setCompleted(summary);
|
||||
|
||||
if (failures.length > 0) {
|
||||
report(
|
||||
failures.length === 1 ? "A file could not be uploaded" : `${failures.length} files could not be uploaded`,
|
||||
...failures,
|
||||
);
|
||||
}
|
||||
// Only re-list if the user is still looking at the directory this went
|
||||
// into. Navigating away during a slow copy used to drag the pane back.
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
},
|
||||
[projectId, navigate, startWork, report, askOverwrite],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(async () => {
|
||||
try {
|
||||
const selected = await openDialog({ multiple: true, directory: false });
|
||||
if (!selected) return;
|
||||
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
|
||||
} catch (e) {
|
||||
report("Could not open the file picker", e);
|
||||
}
|
||||
}, [uploadPaths, report]);
|
||||
|
||||
/**
|
||||
* Rename in place. `newName` is a bare name — Rust rejects anything with a
|
||||
* `/` in it, so this can never turn into a move. Resolves true on success so
|
||||
@@ -368,19 +160,12 @@ export function useFileManager(projectId: string) {
|
||||
loading,
|
||||
/** Inline, in-context: why the listing on screen is empty. */
|
||||
error,
|
||||
busy,
|
||||
/** What the last operation finished doing, for the live region. */
|
||||
completed,
|
||||
/** An upload waiting for a Replace / Skip answer, or `null`. */
|
||||
conflict,
|
||||
resolveConflict,
|
||||
setError,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -7,8 +7,6 @@ export const checkImageExists = () => invoke<boolean>("check_image_exists");
|
||||
export const buildImage = () => invoke<void>("build_image");
|
||||
export const getContainerInfo = (projectId: string) =>
|
||||
invoke<ContainerInfo | null>("get_container_info", { projectId });
|
||||
export const listSiblingContainers = () =>
|
||||
invoke<SiblingContainer[]>("list_sibling_containers");
|
||||
|
||||
// Projects
|
||||
export const listProjects = () => invoke<Project[]>("list_projects");
|
||||
@@ -71,25 +69,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. */
|
||||
|
||||
+13
-15
@@ -162,23 +162,28 @@ export interface OpenAiCompatibleConfig {
|
||||
* project that is "inherit the global value", and on the global settings it is
|
||||
* "leave Claude Code's own default alone". `false` is a deliberate off, which
|
||||
* is what lets a project turn a globally-enabled setting back off.
|
||||
*
|
||||
* Every field is optional as well as nullable: the Rust struct skips
|
||||
* serialising a field it has no value for, so an object with nothing set at
|
||||
* this level arrives as `{}`. Absent and `null` mean the same thing, which is
|
||||
* why every read of one of these has to use `== null` rather than `=== null`.
|
||||
*/
|
||||
export interface ClaudeCodeSettings {
|
||||
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
|
||||
tui_mode: string | null;
|
||||
tui_mode?: string | null;
|
||||
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
|
||||
effort: string | null;
|
||||
auto_scroll_disabled: boolean | null;
|
||||
effort?: string | null;
|
||||
auto_scroll_disabled?: boolean | null;
|
||||
/** Written as `viewMode: "focus"`. */
|
||||
focus_mode: boolean | null;
|
||||
show_thinking_summaries: boolean | null;
|
||||
focus_mode?: boolean | null;
|
||||
show_thinking_summaries?: boolean | null;
|
||||
/**
|
||||
* Turns the session recap **off**. Held in the disabled sense because Claude
|
||||
* Code's recap is on by default — see the Rust doc on `ClaudeCodeSettings`.
|
||||
*/
|
||||
session_recap_disabled: boolean | null;
|
||||
env_scrub: boolean | null;
|
||||
prompt_caching_1h: boolean | null;
|
||||
session_recap_disabled?: boolean | null;
|
||||
env_scrub?: boolean | null;
|
||||
prompt_caching_1h?: boolean | null;
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
@@ -188,13 +193,6 @@ export interface ContainerInfo {
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface SiblingContainer {
|
||||
id: string;
|
||||
names: string[] | null;
|
||||
image: string;
|
||||
state: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+33
-11
@@ -454,27 +454,48 @@ fi
|
||||
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
|
||||
# volume, so without this the container keeps trying to run the SSO refresh even
|
||||
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
|
||||
# Replace ~/.claude.json atomically: write a sibling temp file, then rename.
|
||||
#
|
||||
# `> "$CLAUDE_JSON"` truncates before it writes, so a write that fails part-way
|
||||
# — a full home volume being the obvious way, and bounding that volume is what
|
||||
# half this release is about — leaves the file unparseable. It holds the OAuth
|
||||
# account, and the damage does not self-heal: the next start's `jq` fails on the
|
||||
# corrupt file, `MERGED` comes back empty, and the `[ -n "$MERGED" ]` guard
|
||||
# skips the write that would have repaired it. `triple-c-task-runner` has done
|
||||
# it this way all along.
|
||||
write_claude_json() {
|
||||
_wcj_tmp="${CLAUDE_JSON}.triple-c-tmp"
|
||||
if printf '%s\n' "$1" > "$_wcj_tmp" 2>/dev/null; then
|
||||
mv -f "$_wcj_tmp" "$CLAUDE_JSON" 2>/dev/null || rm -f "$_wcj_tmp"
|
||||
else
|
||||
rm -f "$_wcj_tmp"
|
||||
echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)"
|
||||
return 1
|
||||
fi
|
||||
# By name, after the rename, so these land on the new inode.
|
||||
chown claude:claude "$CLAUDE_JSON"
|
||||
chmod 600 "$CLAUDE_JSON"
|
||||
}
|
||||
|
||||
CLAUDE_JSON="/home/claude/.claude.json"
|
||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||
if [ -f "$CLAUDE_JSON" ]; then
|
||||
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
||||
write_claude_json "$MERGED"
|
||||
fi
|
||||
else
|
||||
printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON"
|
||||
# No existing file, so there is nothing to destroy — but go through the
|
||||
# same helper so the owner and mode are set in one place.
|
||||
write_claude_json "$(printf '{"awsAuthRefresh":"%s"}' "$AWS_SSO_AUTH_REFRESH_CMD")"
|
||||
fi
|
||||
chown claude:claude "$CLAUDE_JSON"
|
||||
chmod 600 "$CLAUDE_JSON"
|
||||
unset AWS_SSO_AUTH_REFRESH_CMD
|
||||
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
|
||||
# Only rewrite when the key is actually present, to avoid a needless jq
|
||||
# reformat of ~/.claude.json on every start of a non-SSO backend.
|
||||
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
||||
chown claude:claude "$CLAUDE_JSON"
|
||||
chmod 600 "$CLAUDE_JSON"
|
||||
write_claude_json "$MERGED"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -491,16 +512,17 @@ if [ -f "$CLAUDE_JSON" ]; then
|
||||
# Only rewrite when the value isn't already true, to avoid a needless jq
|
||||
# reformat of ~/.claude.json on every single start.
|
||||
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
|
||||
# Atomic, via `write_claude_json` — see its comment for why a plain
|
||||
# `>` on this file can permanently destroy the OAuth login.
|
||||
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
||||
write_claude_json "$MERGED"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
printf '{"shiftEnterKeyBindingInstalled":true}\n' > "$CLAUDE_JSON"
|
||||
# Nothing to destroy, but the helper owns the owner/mode too.
|
||||
write_claude_json '{"shiftEnterKeyBindingInstalled":true}'
|
||||
fi
|
||||
chown claude:claude "$CLAUDE_JSON"
|
||||
chmod 600 "$CLAUDE_JSON"
|
||||
|
||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
|
||||
Reference in New Issue
Block a user