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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user