Terminal newlines, OAuth callback, Claude Code settings, and the Files tab #30

Merged
jknapp merged 62 commits from ship/core into main 2026-08-25 18:02:58 +00:00
16 changed files with 339 additions and 77 deletions
Showing only changes of commit 016de8f641 - Show all commits
+18 -9
View File
@@ -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
+1 -1
View File
@@ -504,7 +504,7 @@ triple-c/
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
│ ├── aws_commands.rs # AWS profile/region discovery
│ ├── docker_commands.rs # Docker status, image ops
│ ├── file_commands.rs # File browser (list/download/upload)
│ ├── file_commands.rs # File browser (browse, view, rename)
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
│ ├── install_helper_commands.rs # Guided Docker installation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"identifier": "default",
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is now \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API and never touches this permission; the Files tab is browse, view and rename only. An earlier version of this sentence pointed at a \"Save to host…\" action, which was removed in the same round that removed drag-out — this file is the reviewed threat model of record, so a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
"windows": ["main"],
"permissions": [
"core:event:allow-listen",
File diff suppressed because one or more lines are too long
+49 -2
View File
@@ -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<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)
.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"] {
@@ -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
@@ -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
+37 -8
View File
@@ -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<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(
settings: Option<&ClaudeCodeSettings>,
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
/// 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<Mou
// Trimmed, because a name of `" "` targets `/workspace/ ` and a source
// of `" "` is a path the daemon will happily create at the filesystem
// 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 {
target: Some(format!("/workspace/{}", pp.mount_name)),
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
/// 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
/// seven patterns, delete nothing, and print `###TRIPLE-C-SCRUBBED 0` — a
/// 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
/// 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,
+33
View File
@@ -369,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
let mut out: Vec<String> = paths
.iter()
// **The same filter `project_path_mounts` applies, and it has to be.**
// That function skips a row with an empty `host_path` or `mount_name`
// so a legacy row cannot brick the create. The consequence is that
// `/workspace/<name>` for such a row is *not* a bind mount — it is
// ordinary writable-layer content. Excluding it here would tell
// `compute_verbatim_paths` to skip staging it, and the container swap
// would then destroy whatever the user has put there. The two
// predicates must agree or a migration silently eats a directory.
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
.map(|p| format!("/workspace/{}", p.mount_name))
.collect();
out.sort();
@@ -1368,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
#[cfg(test)]
mod tests {
/// The mount filter and the migration's exclusion list must agree.
///
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
/// writable-layer content rather than a bind mount — and if this function
/// still excluded it, `compute_verbatim_paths` would skip staging it and
/// the container swap would destroy whatever is there. A migration eating a
/// directory is the quietest kind of data loss there is.
#[test]
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
let paths = vec![
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
// Legacy shapes that `project_path_mounts` skips.
ProjectPath { host_path: "".into(), mount_name: "data".into() },
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
];
let excluded = bind_mount_exclusions(&paths);
assert_eq!(
excluded,
vec!["/workspace/code".to_string()],
"only rows that are actually mounted may be excluded from staging"
);
}
use super::*;
use crate::models::{
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
+72 -10
View File
@@ -713,8 +713,22 @@ mod tests {
let mut defined: BTreeSet<String> = 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<String>) {
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]
+13 -3
View File
@@ -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
@@ -46,6 +46,16 @@ enum ServerMessage {
Opened {
session_id: String,
project_name: String,
/// Echoed back so the client can label the session from the reply
/// rather than from a global set at request time.
///
/// Without it the client correlates through a single
/// `pendingSessionType`, so opening two sessions before the first
/// reply lands swaps their labels. That used to be cosmetic; it stopped
/// being cosmetic when Shift+Enter became type-dependent, because a
/// Claude session mislabelled as a shell now submits a half-written
/// prompt instead of inserting a newline.
session_type: String,
},
Output {
session_id: String,
@@ -319,6 +329,11 @@ async fn handle_open(
let _ = out_tx.send(ServerMessage::Opened {
session_id,
project_name,
// Derived from the same match that chose `cmd` above, not echoed from
// the request: anything that is not exactly "bash" runs Claude, so
// echoing the raw value would label an unrecognised string as its own
// type and put the client back where it started.
session_type: if session_type == Some("bash") { "bash" } else { "claude" }.to_string(),
});
Ok(())
@@ -60,12 +60,18 @@ describe("ClaudeCodeSettingsEditor", () => {
});
it("offers every effort level Claude Code accepts", () => {
// Verified against the shipped `claude` binary's own schema rather than
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
// checked externally — which is the whole weakness of this test. It can
// only prove the editor agrees with this list, never that the list is the
// one Claude Code reads. The same blind spot is why `effort` and
// `focusMode` were confidently wrong for months.
renderEditor(null);
expect(
Array.from(
screen.getByLabelText("Effort level").querySelectorAll("option"),
).map((o) => o.getAttribute("value")),
).toEqual(["", "low", "medium", "high", "xhigh"]);
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
});
describe("project scope", () => {
@@ -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({
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Extra high</option>
{/* `max` is accepted by the CLI and was missing here. Confirmed
against the shipped claude binary's own schema, not just docs. */}
<option value="max">Maximum</option>
</select>
}
/>
@@ -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."
}
>
<ClaudeCodeSettingsEditor
+33 -33
View File
@@ -454,27 +454,48 @@ fi
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
# volume, so without this the container keeps trying to run the SSO refresh even
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
# Replace ~/.claude.json atomically: write a sibling temp file, then rename.
#
# `> "$CLAUDE_JSON"` truncates before it writes, so a write that fails part-way
# — a full home volume being the obvious way, and bounding that volume is what
# half this release is about — leaves the file unparseable. It holds the OAuth
# account, and the damage does not self-heal: the next start's `jq` fails on the
# corrupt file, `MERGED` comes back empty, and the `[ -n "$MERGED" ]` guard
# skips the write that would have repaired it. `triple-c-task-runner` has done
# it this way all along.
write_claude_json() {
_wcj_tmp="${CLAUDE_JSON}.triple-c-tmp"
if printf '%s\n' "$1" > "$_wcj_tmp" 2>/dev/null; then
mv -f "$_wcj_tmp" "$CLAUDE_JSON" 2>/dev/null || rm -f "$_wcj_tmp"
else
rm -f "$_wcj_tmp"
echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)"
return 1
fi
# By name, after the rename, so these land on the new inode.
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
}
CLAUDE_JSON="/home/claude/.claude.json"
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
if [ -f "$CLAUDE_JSON" ]; then
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
write_claude_json "$MERGED"
fi
else
printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON"
# No existing file, so there is nothing to destroy — but go through the
# same helper so the owner and mode are set in one place.
write_claude_json "$(printf '{"awsAuthRefresh":"%s"}' "$AWS_SSO_AUTH_REFRESH_CMD")"
fi
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
unset AWS_SSO_AUTH_REFRESH_CMD
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
# Only rewrite when the key is actually present, to avoid a needless jq
# reformat of ~/.claude.json on every start of a non-SSO backend.
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
write_claude_json "$MERGED"
fi
fi
@@ -491,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