Close the blockers from the fifth audit
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 10m5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 4m31s
Build App (Preview) / build-linux (pull_request) Successful in 5m21s
Build App (Preview) / build-windows (pull_request) Successful in 19m1s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 10m5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 4m31s
Build App (Preview) / build-linux (pull_request) Successful in 5m21s
Build App (Preview) / build-windows (pull_request) Successful in 19m1s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Docs and disclosure. HOW-TO-USE.md's settings table still described the pre-fix behaviour — and help_commands.rs fetches that file from GitHub main at runtime, ahead of the embedded copy, so it would have reached every user's Help dialog the moment this merged. The Config tab named three settings that need a base-image update; there are four, and the omitted one (Session recap) is the one that fails *without* the "won't switch off" symptom the warning teaches. Both now also state the cost nobody had written down: changing any of these recreates the container, which commits a layer. Two stale comments that told a reviewer the code was safe when it was not. compute_claude_code_settings_fingerprint still claimed the historical fingerprint is preserved so an upgrade cannot churn every container — carried over from before the widening, false since the format string changed. And capabilities/default.json, which is the reviewed threat model of record, described a "Save to host…" action this branch deletes. Security and correctness. update_settings validated env vars and nothing else, so the *global* default_ssh_key_path — the fallback for every project without an override — took `/` and read-only bind-mounted the host, which entrypoint.sh then copies into the home volume. classify_ mount_source ran canonicalize on the raw string, which resolves a relative path against Triple-C's own cwd, so `.` and `..` were accepted or refused depending on where the app was launched; the daemon then refuses the mount and the project can never start. Its test passed only because its examples did not exist under app/src-tauri. bind_mount_exclusions still derived a path from every row while project_path_mounts had learned to skip unmountable ones, so a legacy row made /workspace/<name> ordinary container content that a migration would then exclude from staging and destroy. The skip is also logged now rather than silently dropping a folder. The terminal's file-in path checked is_dir() but not file type, so a dropped FIFO blocked forever with no timeout — and it is the only route in now. The web terminal labelled sessions from a global set at request time, so two quick opens swapped them; harmless until Shift+Enter became type-dependent, at which point a mislabelled Claude session submitted a half-written prompt. Opened now carries the type. Every ~/.claude.json write goes through one atomic helper. The awsAuthRefresh branches still truncated in place — the same corruption the Shift+Enter block was fixed for twenty lines later, and its own comment said so. Demonstrated: a failed write now leaves the original byte-identical. And the registration test I added yesterday could pass while the property was false: an audit got five real unregistered commands past its exact-string attribute match, and "exactly once" was in its name but not its body. Mutation-checked against all six shapes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
+18
-9
@@ -615,18 +615,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
|
|||||||
|
|
||||||
| Setting | What It Does |
|
| Setting | What It Does |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
| **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 process as summaries during responses |
|
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||||
| **Session Recap** | Provides context when returning to a session after being away |
|
| **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 Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
| **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
|
### MCP Servers
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -504,7 +504,7 @@ triple-c/
|
|||||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||||
│ ├── docker_commands.rs # Docker status, image ops
|
│ ├── 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
|
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"identifier": "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.",
|
"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"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:event:allow-listen",
|
"core:event:allow-listen",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -402,7 +402,7 @@ fn validate_project_paths_update(
|
|||||||
/// Same grandfathering as the folder list, for the same reason: a value already
|
/// 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
|
/// stored is already mounted on every start, and refusing an unrelated save
|
||||||
/// does not unmount it. Only a *change* is held to the rule.
|
/// 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,
|
label: &str,
|
||||||
stored: Option<&str>,
|
stored: Option<&str>,
|
||||||
incoming: Option<&str>,
|
incoming: Option<&str>,
|
||||||
@@ -615,6 +615,22 @@ fn classify_mount_source(host_path: &str) -> Option<UnmountableHostPath> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
let canonical = std::fs::canonicalize(raw)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|p| p.to_string_lossy().into_owned());
|
.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.
|
// 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
|
// `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
|
// 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
|
// save is not the place a container is adopted. It is assigned by
|
||||||
// `start_project_container` through `projects_store::set_container_id` and
|
// `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
|
/// 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
|
/// 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.
|
/// 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]
|
#[test]
|
||||||
fn a_path_that_names_no_location_is_refused_rather_than_guessed_at() {
|
fn a_path_that_names_no_location_is_refused_rather_than_guessed_at() {
|
||||||
for relative in ["C:x", "C:Users\\jo", "relative/path", "./project"] {
|
for relative in ["C:x", "C:Users\\jo", "relative/path", "./project"] {
|
||||||
|
|||||||
@@ -25,6 +25,29 @@ pub async fn update_settings(
|
|||||||
&settings.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)?;
|
let saved = state.settings_store.update(settings)?;
|
||||||
|
|
||||||
// Persisting a setting is not the same as applying it. The gateway is the
|
// Persisting a setting is not the same as applying it. The gateway is the
|
||||||
|
|||||||
@@ -216,8 +216,23 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
let meta = tokio::fs::metadata(&host_path)
|
let meta = tokio::fs::metadata(&host_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||||
if meta.is_dir() {
|
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
// 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
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||||
|
|||||||
@@ -668,10 +668,23 @@ fn merge_claude_code_settings(
|
|||||||
|
|
||||||
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
|
/// 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
|
/// The `sandbox_enabled` flag is included so that toggling sandbox mode forces
|
||||||
/// a container recreation (re-injecting the merged settings.json). When
|
/// a container recreation (re-injecting the merged settings.json).
|
||||||
/// sandbox is off the historical fingerprint is preserved unchanged so that
|
///
|
||||||
/// upgrading triple-c does not spuriously flag every existing container for
|
/// **This formula changed, and the change is not free.** It used to read
|
||||||
/// recreation.
|
/// `format!("{}", bool)`; the booleans are now `Option<bool>` 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(
|
fn compute_claude_code_settings_fingerprint(
|
||||||
settings: Option<&ClaudeCodeSettings>,
|
settings: Option<&ClaudeCodeSettings>,
|
||||||
sandbox_enabled: bool,
|
sandbox_enabled: bool,
|
||||||
@@ -775,7 +788,7 @@ fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
|
|||||||
/// taken back: turning the setting off simply omits the key, the merge
|
/// taken back: turning the setting off simply omits the key, the merge
|
||||||
/// preserves whatever was there, and the setting stays on forever. Only a
|
/// preserves whatever was there, and the setting stays on forever. Only a
|
||||||
/// destructive Reset — which also deletes the OAuth login, skills and
|
/// 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
|
/// way; the `sandbox` block already carried the workaround and the comment
|
||||||
/// explaining it, and this is the same treatment applied to the rest.
|
/// 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<Mou
|
|||||||
// Trimmed, because a name of `" "` targets `/workspace/ ` and a source
|
// Trimmed, because a name of `" "` targets `/workspace/ ` and a source
|
||||||
// of `" "` is a path the daemon will happily create at the filesystem
|
// of `" "` is a path the daemon will happily create at the filesystem
|
||||||
// root — neither is what anyone typed on purpose.
|
// root — neither is what anyone typed on purpose.
|
||||||
.filter(|pp| !pp.mount_name.trim().is_empty() && !pp.host_path.trim().is_empty())
|
.filter(|pp| {
|
||||||
|
let keep = !pp.mount_name.trim().is_empty() && !pp.host_path.trim().is_empty();
|
||||||
|
if !keep {
|
||||||
|
// Silence here means a folder the user configured simply does
|
||||||
|
// not appear in the container, with no error and no toast.
|
||||||
|
// Skipping is still right — the alternative is a project that
|
||||||
|
// cannot start — but it should leave a trace.
|
||||||
|
log::warn!(
|
||||||
|
"Skipping an unmountable project path row (host_path={:?}, mount_name={:?}): \
|
||||||
|
both are required. The project will start without it.",
|
||||||
|
pp.host_path,
|
||||||
|
pp.mount_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
keep
|
||||||
|
})
|
||||||
.map(|pp| Mount {
|
.map(|pp| Mount {
|
||||||
target: Some(format!("/workspace/{}", pp.mount_name)),
|
target: Some(format!("/workspace/{}", pp.mount_name)),
|
||||||
source: Some(pp.host_path.clone()),
|
source: Some(pp.host_path.clone()),
|
||||||
@@ -2187,7 +2215,8 @@ const SCRUB_MARKER: &str = "###TRIPLE-C-SCRUBBED ";
|
|||||||
/// Marker the scrub script prints **instead of** [`SCRUB_MARKER`] when it
|
/// Marker the scrub script prints **instead of** [`SCRUB_MARKER`] when it
|
||||||
/// cannot run at all, followed by what was missing.
|
/// cannot run at all, followed by what was missing.
|
||||||
///
|
///
|
||||||
/// H3: the script needs six external tools and the root filesystem's device id,
|
/// H3: the script needs five external tools, the root filesystem's device id,
|
||||||
|
/// and an `rm` that honours `--one-file-system` — seven prerequisites in all,
|
||||||
/// and on a base image that has none of them where it looked it used to run its
|
/// and on a base image that has none of them where it looked it used to run its
|
||||||
/// seven patterns, delete nothing, and print `###TRIPLE-C-SCRUBBED 0` — a
|
/// seven patterns, delete nothing, and print `###TRIPLE-C-SCRUBBED 0` — a
|
||||||
/// number indistinguishable from an honest "there was nothing to take". A scrub
|
/// number indistinguishable from an honest "there was nothing to take". A scrub
|
||||||
@@ -2469,7 +2498,7 @@ pub(crate) fn snapshot_scrub_script() -> String {
|
|||||||
/// volume was untouched and the figure was `65536` — exactly the debris that
|
/// 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.
|
/// 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
|
/// [`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
|
/// 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,
|
/// keeps its debris — and it is the cheaper of the two: declining costs disk,
|
||||||
|
|||||||
@@ -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> {
|
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
||||||
let mut out: Vec<String> = paths
|
let mut out: Vec<String> = paths
|
||||||
.iter()
|
.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))
|
.map(|p| format!("/workspace/{}", p.mount_name))
|
||||||
.collect();
|
.collect();
|
||||||
out.sort();
|
out.sort();
|
||||||
@@ -1368,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::*;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
||||||
|
|||||||
+72
-10
@@ -713,8 +713,22 @@ mod tests {
|
|||||||
|
|
||||||
let mut defined: BTreeSet<String> = BTreeSet::new();
|
let mut defined: BTreeSet<String> = BTreeSet::new();
|
||||||
|
|
||||||
// Walk the source tree for `#[tauri::command]` and take the `fn` name
|
// Walk the source tree for the command attribute and take the `fn` name
|
||||||
// on the following non-attribute line.
|
// 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>) {
|
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
@@ -725,20 +739,39 @@ mod tests {
|
|||||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
for (i, line) in lines.iter().enumerate() {
|
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;
|
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) {
|
for next in lines.iter().skip(i + 1) {
|
||||||
let t = next.trim();
|
let t = next.trim();
|
||||||
if t.starts_with('#') {
|
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(rest) = t
|
// Any visibility, then `fn` or `async fn`.
|
||||||
.strip_prefix("pub async fn ")
|
let after_vis = t
|
||||||
.or_else(|| t.strip_prefix("pub fn "))
|
.strip_prefix("pub(crate) ")
|
||||||
.or_else(|| t.strip_prefix("async fn "))
|
.or_else(|| t.strip_prefix("pub(super) "))
|
||||||
.or_else(|| t.strip_prefix("fn "))
|
.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() {
|
if let Some(name) = rest.split(['(', '<']).next() {
|
||||||
out.insert(name.trim().to_string());
|
out.insert(name.trim().to_string());
|
||||||
}
|
}
|
||||||
@@ -799,6 +832,35 @@ mod tests {
|
|||||||
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
||||||
undefined
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -602,7 +602,7 @@
|
|||||||
updateProjectList(msg.projects);
|
updateProjectList(msg.projects);
|
||||||
break;
|
break;
|
||||||
case 'opened':
|
case 'opened':
|
||||||
onSessionOpened(msg.session_id, msg.project_name);
|
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
|
||||||
break;
|
break;
|
||||||
case 'output':
|
case 'output':
|
||||||
onSessionOutput(msg.session_id, msg.data);
|
onSessionOutput(msg.session_id, msg.data);
|
||||||
@@ -653,8 +653,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSessionOpened(sessionId, projectName) {
|
function onSessionOpened(sessionId, projectName, serverSessionType) {
|
||||||
const sessionType = pendingSessionType || 'claude';
|
// 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;
|
pendingSessionType = null;
|
||||||
|
|
||||||
// Create terminal
|
// Create terminal
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ enum ServerMessage {
|
|||||||
Opened {
|
Opened {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
project_name: 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 {
|
Output {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@@ -319,6 +329,11 @@ async fn handle_open(
|
|||||||
let _ = out_tx.send(ServerMessage::Opened {
|
let _ = out_tx.send(ServerMessage::Opened {
|
||||||
session_id,
|
session_id,
|
||||||
project_name,
|
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(())
|
Ok(())
|
||||||
|
|||||||
@@ -60,12 +60,18 @@ describe("ClaudeCodeSettingsEditor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("offers every effort level Claude Code accepts", () => {
|
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);
|
renderEditor(null);
|
||||||
expect(
|
expect(
|
||||||
Array.from(
|
Array.from(
|
||||||
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
||||||
).map((o) => o.getAttribute("value")),
|
).map((o) => o.getAttribute("value")),
|
||||||
).toEqual(["", "low", "medium", "high", "xhigh"]);
|
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("project scope", () => {
|
describe("project scope", () => {
|
||||||
|
|||||||
@@ -68,7 +68,15 @@ const BOOLEAN_FIELDS: {
|
|||||||
hint: string;
|
hint: string;
|
||||||
invert?: boolean;
|
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",
|
key: "show_thinking_summaries",
|
||||||
label: "Thinking summaries",
|
label: "Thinking summaries",
|
||||||
@@ -168,6 +176,9 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
<option value="medium">Medium</option>
|
<option value="medium">Medium</option>
|
||||||
<option value="high">High</option>
|
<option value="high">High</option>
|
||||||
<option value="xhigh">Extra 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>
|
</select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -111,11 +111,13 @@ export default function RuntimeSection({
|
|||||||
title="Claude Code settings"
|
title="Claude Code settings"
|
||||||
description={
|
description={
|
||||||
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
|
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
|
||||||
"Off overrides a global On. Turning TUI mode, Effort level or Focus mode " +
|
"Off overrides a global On. Changing any of these recreates the container, " +
|
||||||
"back to Global needs the container's base image updated first — those " +
|
"which commits a new image layer — so flipping switches repeatedly costs disk. " +
|
||||||
"three are cleared by removing a key, and an older image's startup script " +
|
"Turning TUI mode, Effort level, Focus mode or Session recap back to Global " +
|
||||||
"ignores the instruction to remove it. Update the base image from Overview " +
|
"also needs the base image updated first: those four are cleared by removing a " +
|
||||||
"if one of them will not switch off."
|
"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
|
<ClaudeCodeSettingsEditor
|
||||||
|
|||||||
+33
-33
@@ -454,27 +454,48 @@ fi
|
|||||||
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
|
# 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
|
# 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.
|
# 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"
|
CLAUDE_JSON="/home/claude/.claude.json"
|
||||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||||
if [ -f "$CLAUDE_JSON" ]; then
|
if [ -f "$CLAUDE_JSON" ]; then
|
||||||
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
write_claude_json "$MERGED"
|
||||||
fi
|
fi
|
||||||
else
|
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
|
fi
|
||||||
chown claude:claude "$CLAUDE_JSON"
|
|
||||||
chmod 600 "$CLAUDE_JSON"
|
|
||||||
unset AWS_SSO_AUTH_REFRESH_CMD
|
unset AWS_SSO_AUTH_REFRESH_CMD
|
||||||
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
|
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
|
# 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.
|
# reformat of ~/.claude.json on every start of a non-SSO backend.
|
||||||
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
write_claude_json "$MERGED"
|
||||||
chown claude:claude "$CLAUDE_JSON"
|
|
||||||
chmod 600 "$CLAUDE_JSON"
|
|
||||||
fi
|
fi
|
||||||
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
|
# Only rewrite when the value isn't already true, to avoid a needless jq
|
||||||
# reformat of ~/.claude.json on every single start.
|
# reformat of ~/.claude.json on every single start.
|
||||||
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
|
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
|
||||||
# Write to a temp file and rename, never `> "$CLAUDE_JSON"`.
|
# Atomic, via `write_claude_json` — see its comment for why a plain
|
||||||
#
|
# `>` on this file can permanently destroy the OAuth login.
|
||||||
# `>` 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.
|
|
||||||
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
CLAUDE_JSON_TMP="${CLAUDE_JSON}.triple-c-tmp"
|
write_claude_json "$MERGED"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
printf '{"shiftEnterKeyBindingInstalled":true}\n' > "$CLAUDE_JSON"
|
# Nothing to destroy, but the helper owns the owner/mode too.
|
||||||
|
write_claude_json '{"shiftEnterKeyBindingInstalled":true}'
|
||||||
fi
|
fi
|
||||||
chown claude:claude "$CLAUDE_JSON"
|
|
||||||
chmod 600 "$CLAUDE_JSON"
|
|
||||||
|
|
||||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||||
if [ -S /var/run/docker.sock ]; then
|
if [ -S /var/run/docker.sock ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user