Terminal newlines, OAuth callback, Claude Code settings, and the Files tab #30
+18
-9
@@ -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
@@ -504,7 +504,7 @@ triple-c/
|
||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File browser (list/download/upload)
|
||||
│ ├── file_commands.rs # File browser (browse, view, rename)
|
||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
||||
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is now \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API and never touches this permission; the Files tab is browse, view and rename only. An earlier version of this sentence pointed at a \"Save to host…\" action, which was removed in the same round that removed drag-out — this file is the reviewed threat model of record, so a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:event:allow-listen",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user