diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 81a290d..7c4fa93 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -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 diff --git a/TECHNICAL.md b/TECHNICAL.md index d33a14d..211d3e9 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -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 diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 2c13a4c..cd7aa77 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -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", diff --git a/app/src-tauri/gen/schemas/capabilities.json b/app/src-tauri/gen/schemas/capabilities.json index ed7fb0c..4d6cfc3 100644 --- a/app/src-tauri/gen/schemas/capabilities.json +++ b/app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"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.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"}]}]}} \ No newline at end of file +{"default":{"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`) 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.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"}]}]}} \ No newline at end of file diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 23a9265..564012f 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -402,7 +402,7 @@ fn validate_project_paths_update( /// 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>, @@ -615,6 +615,22 @@ fn classify_mount_source(host_path: &str) -> Option { }); } + // 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()); @@ -754,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 @@ -1467,6 +1483,37 @@ mod tests { /// 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"] { diff --git a/app/src-tauri/src/commands/settings_commands.rs b/app/src-tauri/src/commands/settings_commands.rs index 883a052..5a8b0c6 100644 --- a/app/src-tauri/src/commands/settings_commands.rs +++ b/app/src-tauri/src/commands/settings_commands.rs @@ -25,6 +25,29 @@ pub async fn update_settings( &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 diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index 15c2276..41359c8 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -216,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 diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 63dda7b..01950bd 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -668,10 +668,23 @@ fn merge_claude_code_settings( /// Compute a fingerprint for the Claude Code settings so we can detect changes. /// The `sandbox_enabled` flag is included so that toggling sandbox mode forces -/// a container recreation (re-injecting the merged settings.json). When -/// sandbox is off the historical fingerprint is preserved unchanged so that -/// upgrading triple-c does not spuriously flag every existing container for -/// recreation. +/// a container recreation (re-injecting the merged settings.json). +/// +/// **This formula changed, and the change is not free.** It used to read +/// `format!("{}", bool)`; the booleans are now `Option` and it reads +/// `format!("{:?}")`, because `None` (inherit) and `Some(false)` (a deliberate +/// off) must not hash alike — conflating them leaves a container un-recreated +/// on a real change. The consequence is that **every existing project holding +/// a settings object gets a different fingerprint on first launch after this +/// upgrade, and is recreated once.** A recreation commits a snapshot layer, so +/// that is a one-off disk cost per project, paid silently. +/// +/// An earlier version of this comment claimed the opposite — that "the +/// historical fingerprint is preserved unchanged so that upgrading triple-c +/// does not spuriously flag every existing container for recreation." That was +/// carried over from before the widening and was false the moment the format +/// string changed. It is recorded here because a reviewer who believed it would +/// conclude the churn cannot happen. fn compute_claude_code_settings_fingerprint( settings: Option<&ClaudeCodeSettings>, sandbox_enabled: bool, @@ -775,7 +788,7 @@ fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec { /// taken back: turning the setting off simply omits the key, the merge /// preserves whatever was there, and the setting stays on forever. Only a /// destructive Reset — which also deletes the OAuth login, skills and -/// transcripts — ever cleared it. Four of the five keys here were sticky that +/// transcripts — ever cleared it. Four of the six keys here were sticky that /// way; the `sandbox` block already carried the workaround and the comment /// explaining it, and this is the same treatment applied to the rest. /// @@ -1150,7 +1163,22 @@ fn project_path_mounts(paths: &[crate::models::project::ProjectPath]) -> Vec String { /// volume was untouched and the figure was `65536` — exactly the debris that /// really was next to the mount, so the byte accounting follows the flag too. /// -/// It is one of the six prerequisites now, and an image without it is +/// It is one of the seven prerequisites now, and an image without it is /// [`SCRUB_UNAVAILABLE_MARKER`] rather than a scrub that runs unguarded. That /// is a real cost — an Alpine or busybox base image stops being scrubbed and /// keeps its debris — and it is the cheaper of the two: declining costs disk, diff --git a/app/src-tauri/src/docker/migration.rs b/app/src-tauri/src/docker/migration.rs index b338faa..3425970 100644 --- a/app/src-tauri/src/docker/migration.rs +++ b/app/src-tauri/src/docker/migration.rs @@ -369,6 +369,15 @@ pub fn set_delta(from: &BTreeSet, base: &BTreeSet) -> Vec Vec { let mut out: Vec = 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/` 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/` 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, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 75b8924..51a5053 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -713,8 +713,22 @@ mod tests { let mut defined: BTreeSet = BTreeSet::new(); - // Walk the source tree for `#[tauri::command]` and take the `fn` name - // on the following non-attribute line. + // 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) { let Ok(entries) = std::fs::read_dir(dir) else { return }; for entry in entries.flatten() { @@ -725,20 +739,39 @@ mod tests { 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() { - if line.trim() != "#[tauri::command]" { + 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('#') { + if t.starts_with('#') || t.starts_with("//") || t.is_empty() { continue; } - if let Some(rest) = t - .strip_prefix("pub async fn ") - .or_else(|| t.strip_prefix("pub fn ")) - .or_else(|| t.strip_prefix("async fn ")) - .or_else(|| t.strip_prefix("fn ")) - { + // 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()); } @@ -799,6 +832,35 @@ mod tests { "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] diff --git a/app/src-tauri/src/web_terminal/terminal.html b/app/src-tauri/src/web_terminal/terminal.html index 6527294..8e6259a 100644 --- a/app/src-tauri/src/web_terminal/terminal.html +++ b/app/src-tauri/src/web_terminal/terminal.html @@ -602,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); @@ -653,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 diff --git a/app/src-tauri/src/web_terminal/ws_handler.rs b/app/src-tauri/src/web_terminal/ws_handler.rs index bcafb11..50c198e 100644 --- a/app/src-tauri/src/web_terminal/ws_handler.rs +++ b/app/src-tauri/src/web_terminal/ws_handler.rs @@ -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(()) diff --git a/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx b/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx index 85f5ff3..75cf30d 100644 --- a/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx +++ b/app/src/components/projects/ClaudeCodeSettingsEditor.test.tsx @@ -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", () => { diff --git a/app/src/components/projects/ClaudeCodeSettingsEditor.tsx b/app/src/components/projects/ClaudeCodeSettingsEditor.tsx index e3b5bb3..167998b 100644 --- a/app/src/components/projects/ClaudeCodeSettingsEditor.tsx +++ b/app/src/components/projects/ClaudeCodeSettingsEditor.tsx @@ -68,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", @@ -168,6 +176,9 @@ export default function ClaudeCodeSettingsEditor({ + {/* `max` is accepted by the CLI and was missing here. Confirmed + against the shipped claude binary's own schema, not just docs. */} + } /> diff --git a/app/src/components/projects/home/config/RuntimeSection.tsx b/app/src/components/projects/home/config/RuntimeSection.tsx index 9bbb19e..7fe7a2e 100644 --- a/app/src/components/projects/home/config/RuntimeSection.tsx +++ b/app/src/components/projects/home/config/RuntimeSection.tsx @@ -111,11 +111,13 @@ export default function RuntimeSection({ title="Claude Code settings" description={ "Per-project CLI behaviour. Anything left on Global follows Settings; " + - "Off overrides a global On. Turning TUI mode, Effort level or Focus mode " + - "back to Global needs the container's base image updated first — those " + - "three 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 " + - "if one of them will not switch off." + "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." } > "$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,38 +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 - # Write to a temp file and rename, never `> "$CLAUDE_JSON"`. - # - # `>` truncates before it writes, so a write that fails part-way — a - # full home volume is the obvious way, and bounding that volume is - # what half this release is about — leaves the file unparseable. This - # file holds the OAuth account, and the damage does not self-heal: the - # next start's `jq` fails on the corrupt file, `MERGED` is empty, and - # the guard below skips the write that would have repaired it. So the - # failure mode is a permanently lost login, for a purely cosmetic flag - # that suppresses a "run /terminal-setup" tip. - # - # `triple-c-task-runner` already does it this way; this block was - # modelled on the awsAuthRefresh one above, which has the same flaw but - # only fires when a Bedrock SSO command is configured. + # 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 - CLAUDE_JSON_TMP="${CLAUDE_JSON}.triple-c-tmp" - if printf '%s\n' "$MERGED" > "$CLAUDE_JSON_TMP" 2>/dev/null; then - mv -f "$CLAUDE_JSON_TMP" "$CLAUDE_JSON" 2>/dev/null || rm -f "$CLAUDE_JSON_TMP" - else - # Out of space, or the volume went read-only. The original is - # untouched, which is the whole point. - rm -f "$CLAUDE_JSON_TMP" - echo "entrypoint: warning — could not set shiftEnterKeyBindingInstalled (leaving ~/.claude.json as it was)" - fi + 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