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 |
|
| Setting | What It Does |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
| **Focus Mode** | Summarises tool *calls* to one line each, showing the last prompt and the final response. **Needs the fullscreen renderer** — set TUI Mode to Fullscreen or this does nothing |
|
||||||
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
|
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||||
| **Session Recap** | Provides context when returning to a session after being away |
|
| **Session Recap** | A one-line recap when you return to the terminal after a few minutes away. **On by default** — the switch is how you turn it off |
|
||||||
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
||||||
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
|
| **Prompt Caching (1h)** | Requests a 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||||
|
|
||||||
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
|
Each switch has three states on a project: **Global** (follow Settings), **On**, and **Off**. Off is a
|
||||||
|
real choice — it overrides a global On, which a project could not previously do.
|
||||||
|
|
||||||
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
|
> These map to Claude Code environment variables and `~/.claude/settings.json` keys, and are applied
|
||||||
|
> when the container starts. Changing one stops and recreates the container.
|
||||||
|
>
|
||||||
|
> **Two caveats on an existing project.** Changing any of these recreates the container, and a
|
||||||
|
> recreation commits a new image layer — so flipping switches repeatedly costs disk. And
|
||||||
|
> **TUI Mode, Effort Level, Focus Mode and Session Recap cannot be returned to Global** until the
|
||||||
|
> project's base image is updated: those four are cleared by *removing* a key, and an older image's
|
||||||
|
> startup script ignores the instruction to remove it. Update the base image from the project's
|
||||||
|
> Overview tab first. The other switches work on any image.
|
||||||
|
|
||||||
### MCP Servers
|
### MCP Servers
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -504,7 +504,7 @@ triple-c/
|
|||||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||||
│ ├── docker_commands.rs # Docker status, image ops
|
│ ├── docker_commands.rs # Docker status, image ops
|
||||||
│ ├── file_commands.rs # File browser (list/download/upload)
|
│ ├── file_commands.rs # File browser (browse, view, rename)
|
||||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is now \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API and never touches this permission; the Files tab is browse, view and rename only. An earlier version of this sentence pointed at a \"Save to host…\" action, which was removed in the same round that removed drag-out — this file is the reviewed threat model of record, so a stale reference here is worse than none. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:event:allow-listen",
|
"core:event:allow-listen",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -402,7 +402,7 @@ fn validate_project_paths_update(
|
|||||||
/// Same grandfathering as the folder list, for the same reason: a value already
|
/// Same grandfathering as the folder list, for the same reason: a value already
|
||||||
/// stored is already mounted on every start, and refusing an unrelated save
|
/// stored is already mounted on every start, and refusing an unrelated save
|
||||||
/// does not unmount it. Only a *change* is held to the rule.
|
/// does not unmount it. Only a *change* is held to the rule.
|
||||||
fn validate_mounted_host_path(
|
pub(crate) fn validate_mounted_host_path(
|
||||||
label: &str,
|
label: &str,
|
||||||
stored: Option<&str>,
|
stored: Option<&str>,
|
||||||
incoming: Option<&str>,
|
incoming: Option<&str>,
|
||||||
@@ -615,6 +615,22 @@ fn classify_mount_source(host_path: &str) -> Option<UnmountableHostPath> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Absoluteness is judged on what the user typed, **before** resolution.
|
||||||
|
//
|
||||||
|
// `canonicalize` resolves a relative path against Triple-C's own working
|
||||||
|
// directory, so it hands back an absolute path and the `NotAbsolute` branch
|
||||||
|
// below never fires — it was reachable only when canonicalize *failed*,
|
||||||
|
// i.e. only for relative paths that happened not to exist. That made the
|
||||||
|
// verdict depend on where the app was launched from: `.` and `..` were
|
||||||
|
// accepted from the repo, refused from `/`. The daemon then refuses the
|
||||||
|
// mount outright (`invalid mount path: '..' mount path must be absolute`),
|
||||||
|
// so the project saved cleanly and could never start again — the bricking
|
||||||
|
// mode `project_path_mounts`'s filter exists to prevent, reached through
|
||||||
|
// the host-path half of the row instead of the mount-name half.
|
||||||
|
if split_host_root(&normalize_host_path(raw)).is_none() {
|
||||||
|
return Some(UnmountableHostPath::NotAbsolute);
|
||||||
|
}
|
||||||
|
|
||||||
let canonical = std::fs::canonicalize(raw)
|
let canonical = std::fs::canonicalize(raw)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|p| p.to_string_lossy().into_owned());
|
.map(|p| p.to_string_lossy().into_owned());
|
||||||
@@ -754,7 +770,7 @@ pub async fn update_project(
|
|||||||
// Fields this command does not get to write, whoever is calling it.
|
// Fields this command does not get to write, whoever is calling it.
|
||||||
//
|
//
|
||||||
// `container_id` is the one that matters: it is the handle the whole file
|
// `container_id` is the one that matters: it is the handle the whole file
|
||||||
// command surface resolves against, `list_sibling_containers` hands the
|
// command surface resolves against, `list_sibling_containers` used to hand the
|
||||||
// webview the ids of every other container on the daemon, and a project
|
// webview the ids of every other container on the daemon, and a project
|
||||||
// save is not the place a container is adopted. It is assigned by
|
// save is not the place a container is adopted. It is assigned by
|
||||||
// `start_project_container` through `projects_store::set_container_id` and
|
// `start_project_container` through `projects_store::set_container_id` and
|
||||||
@@ -1467,6 +1483,37 @@ mod tests {
|
|||||||
/// A drive-relative path (`C:x`, no separator) means "x under whatever the
|
/// A drive-relative path (`C:x`, no separator) means "x under whatever the
|
||||||
/// current directory on C: happens to be" — a location decided by the
|
/// current directory on C: happens to be" — a location decided by the
|
||||||
/// process rather than by the user, so it may be the drive root.
|
/// process rather than by the user, so it may be the drive root.
|
||||||
|
#[test]
|
||||||
|
fn a_relative_path_is_refused_however_it_resolves_from_here() {
|
||||||
|
// The previous test for this passed by coincidence: its four examples
|
||||||
|
// did not exist under `app/src-tauri`, so `canonicalize` failed and the
|
||||||
|
// `NotAbsolute` branch fired for the wrong reason. Creating a directory
|
||||||
|
// named `project` there flipped it red.
|
||||||
|
//
|
||||||
|
// These are paths that *do* exist relative to wherever the test runs,
|
||||||
|
// so they exercise the branch that used to be unreachable. Judged on
|
||||||
|
// the typed string, the answer is the same from any working directory —
|
||||||
|
// which is the property that matters, because the daemon refuses a
|
||||||
|
// relative mount source and the project would save fine and then never
|
||||||
|
// start.
|
||||||
|
for existing in [".", "..", "src", "./src"] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
classify_mount_source(existing),
|
||||||
|
Some(UnmountableHostPath::NotAbsolute)
|
||||||
|
),
|
||||||
|
"{} is relative and must be refused regardless of cwd",
|
||||||
|
existing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the fix must not have made an absolute path unreachable.
|
||||||
|
assert!(
|
||||||
|
classify_mount_source("/usr").is_none(),
|
||||||
|
"an ordinary absolute folder must still be accepted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_path_that_names_no_location_is_refused_rather_than_guessed_at() {
|
fn a_path_that_names_no_location_is_refused_rather_than_guessed_at() {
|
||||||
for relative in ["C:x", "C:Users\\jo", "relative/path", "./project"] {
|
for relative in ["C:x", "C:Users\\jo", "relative/path", "./project"] {
|
||||||
|
|||||||
@@ -25,6 +25,29 @@ pub async fn update_settings(
|
|||||||
&settings.global_custom_env_vars,
|
&settings.global_custom_env_vars,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// The same for the two host paths this struct owns. `update_project`
|
||||||
|
// validated its per-project overrides and this side validated nothing,
|
||||||
|
// which left the wider hole of the two: `default_ssh_key_path` is the
|
||||||
|
// fallback for **every** project without an override
|
||||||
|
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
|
||||||
|
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
|
||||||
|
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
|
||||||
|
// home volume this release exists to bound.
|
||||||
|
//
|
||||||
|
// Grandfathered the same way project paths are: a value carried over
|
||||||
|
// unchanged still saves, so a store written before this check cannot lock
|
||||||
|
// the user out of their own settings.
|
||||||
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
|
"SSH key path",
|
||||||
|
before.default_ssh_key_path.as_deref(),
|
||||||
|
settings.default_ssh_key_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
|
"CA certificate path",
|
||||||
|
before.ca_cert_path.as_deref(),
|
||||||
|
settings.ca_cert_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
let saved = state.settings_store.update(settings)?;
|
let saved = state.settings_store.update(settings)?;
|
||||||
|
|
||||||
// Persisting a setting is not the same as applying it. The gateway is the
|
// Persisting a setting is not the same as applying it. The gateway is the
|
||||||
|
|||||||
@@ -216,8 +216,23 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
let meta = tokio::fs::metadata(&host_path)
|
let meta = tokio::fs::metadata(&host_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||||
if meta.is_dir() {
|
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
// regular file, reports `len() == 0`, and passes both the directory check
|
||||||
|
// and the size cap below — and `std::fs::File::open` on one blocks forever
|
||||||
|
// with no writer, with no timeout anywhere on this path. The upload then
|
||||||
|
// never returns, the toast sticks on "Adding N files…" for the session and
|
||||||
|
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
||||||
|
// shape. With the Files tab's upload removed, this is the only route for
|
||||||
|
// getting a file into a container, so it is the wrong place to be clever.
|
||||||
|
if !meta.is_file() {
|
||||||
|
return Err(if meta.is_dir() {
|
||||||
|
format!("{} is a directory — drop individual files", host_path)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} is not a regular file — only ordinary files can be dropped into a terminal",
|
||||||
|
host_path
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard against ballooning host RAM: the file is packed into an in-memory
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||||
|
|||||||
@@ -668,10 +668,23 @@ fn merge_claude_code_settings(
|
|||||||
|
|
||||||
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
|
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
|
||||||
/// The `sandbox_enabled` flag is included so that toggling sandbox mode forces
|
/// The `sandbox_enabled` flag is included so that toggling sandbox mode forces
|
||||||
/// a container recreation (re-injecting the merged settings.json). When
|
/// a container recreation (re-injecting the merged settings.json).
|
||||||
/// sandbox is off the historical fingerprint is preserved unchanged so that
|
///
|
||||||
/// upgrading triple-c does not spuriously flag every existing container for
|
/// **This formula changed, and the change is not free.** It used to read
|
||||||
/// recreation.
|
/// `format!("{}", bool)`; the booleans are now `Option<bool>` and it reads
|
||||||
|
/// `format!("{:?}")`, because `None` (inherit) and `Some(false)` (a deliberate
|
||||||
|
/// off) must not hash alike — conflating them leaves a container un-recreated
|
||||||
|
/// on a real change. The consequence is that **every existing project holding
|
||||||
|
/// a settings object gets a different fingerprint on first launch after this
|
||||||
|
/// upgrade, and is recreated once.** A recreation commits a snapshot layer, so
|
||||||
|
/// that is a one-off disk cost per project, paid silently.
|
||||||
|
///
|
||||||
|
/// An earlier version of this comment claimed the opposite — that "the
|
||||||
|
/// historical fingerprint is preserved unchanged so that upgrading triple-c
|
||||||
|
/// does not spuriously flag every existing container for recreation." That was
|
||||||
|
/// carried over from before the widening and was false the moment the format
|
||||||
|
/// string changed. It is recorded here because a reviewer who believed it would
|
||||||
|
/// conclude the churn cannot happen.
|
||||||
fn compute_claude_code_settings_fingerprint(
|
fn compute_claude_code_settings_fingerprint(
|
||||||
settings: Option<&ClaudeCodeSettings>,
|
settings: Option<&ClaudeCodeSettings>,
|
||||||
sandbox_enabled: bool,
|
sandbox_enabled: bool,
|
||||||
@@ -775,7 +788,7 @@ fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec<String> {
|
|||||||
/// taken back: turning the setting off simply omits the key, the merge
|
/// taken back: turning the setting off simply omits the key, the merge
|
||||||
/// preserves whatever was there, and the setting stays on forever. Only a
|
/// preserves whatever was there, and the setting stays on forever. Only a
|
||||||
/// destructive Reset — which also deletes the OAuth login, skills and
|
/// destructive Reset — which also deletes the OAuth login, skills and
|
||||||
/// transcripts — ever cleared it. Four of the five keys here were sticky that
|
/// transcripts — ever cleared it. Four of the six keys here were sticky that
|
||||||
/// way; the `sandbox` block already carried the workaround and the comment
|
/// way; the `sandbox` block already carried the workaround and the comment
|
||||||
/// explaining it, and this is the same treatment applied to the rest.
|
/// explaining it, and this is the same treatment applied to the rest.
|
||||||
///
|
///
|
||||||
@@ -1150,7 +1163,22 @@ fn project_path_mounts(paths: &[crate::models::project::ProjectPath]) -> Vec<Mou
|
|||||||
// Trimmed, because a name of `" "` targets `/workspace/ ` and a source
|
// Trimmed, because a name of `" "` targets `/workspace/ ` and a source
|
||||||
// of `" "` is a path the daemon will happily create at the filesystem
|
// of `" "` is a path the daemon will happily create at the filesystem
|
||||||
// root — neither is what anyone typed on purpose.
|
// root — neither is what anyone typed on purpose.
|
||||||
.filter(|pp| !pp.mount_name.trim().is_empty() && !pp.host_path.trim().is_empty())
|
.filter(|pp| {
|
||||||
|
let keep = !pp.mount_name.trim().is_empty() && !pp.host_path.trim().is_empty();
|
||||||
|
if !keep {
|
||||||
|
// Silence here means a folder the user configured simply does
|
||||||
|
// not appear in the container, with no error and no toast.
|
||||||
|
// Skipping is still right — the alternative is a project that
|
||||||
|
// cannot start — but it should leave a trace.
|
||||||
|
log::warn!(
|
||||||
|
"Skipping an unmountable project path row (host_path={:?}, mount_name={:?}): \
|
||||||
|
both are required. The project will start without it.",
|
||||||
|
pp.host_path,
|
||||||
|
pp.mount_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
keep
|
||||||
|
})
|
||||||
.map(|pp| Mount {
|
.map(|pp| Mount {
|
||||||
target: Some(format!("/workspace/{}", pp.mount_name)),
|
target: Some(format!("/workspace/{}", pp.mount_name)),
|
||||||
source: Some(pp.host_path.clone()),
|
source: Some(pp.host_path.clone()),
|
||||||
@@ -2187,7 +2215,8 @@ const SCRUB_MARKER: &str = "###TRIPLE-C-SCRUBBED ";
|
|||||||
/// Marker the scrub script prints **instead of** [`SCRUB_MARKER`] when it
|
/// Marker the scrub script prints **instead of** [`SCRUB_MARKER`] when it
|
||||||
/// cannot run at all, followed by what was missing.
|
/// cannot run at all, followed by what was missing.
|
||||||
///
|
///
|
||||||
/// H3: the script needs six external tools and the root filesystem's device id,
|
/// H3: the script needs five external tools, the root filesystem's device id,
|
||||||
|
/// and an `rm` that honours `--one-file-system` — seven prerequisites in all,
|
||||||
/// and on a base image that has none of them where it looked it used to run its
|
/// and on a base image that has none of them where it looked it used to run its
|
||||||
/// seven patterns, delete nothing, and print `###TRIPLE-C-SCRUBBED 0` — a
|
/// seven patterns, delete nothing, and print `###TRIPLE-C-SCRUBBED 0` — a
|
||||||
/// number indistinguishable from an honest "there was nothing to take". A scrub
|
/// number indistinguishable from an honest "there was nothing to take". A scrub
|
||||||
@@ -2469,7 +2498,7 @@ pub(crate) fn snapshot_scrub_script() -> String {
|
|||||||
/// volume was untouched and the figure was `65536` — exactly the debris that
|
/// volume was untouched and the figure was `65536` — exactly the debris that
|
||||||
/// really was next to the mount, so the byte accounting follows the flag too.
|
/// really was next to the mount, so the byte accounting follows the flag too.
|
||||||
///
|
///
|
||||||
/// It is one of the six prerequisites now, and an image without it is
|
/// It is one of the seven prerequisites now, and an image without it is
|
||||||
/// [`SCRUB_UNAVAILABLE_MARKER`] rather than a scrub that runs unguarded. That
|
/// [`SCRUB_UNAVAILABLE_MARKER`] rather than a scrub that runs unguarded. That
|
||||||
/// is a real cost — an Alpine or busybox base image stops being scrubbed and
|
/// is a real cost — an Alpine or busybox base image stops being scrubbed and
|
||||||
/// keeps its debris — and it is the cheaper of the two: declining costs disk,
|
/// keeps its debris — and it is the cheaper of the two: declining costs disk,
|
||||||
|
|||||||
@@ -369,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
|
|||||||
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
||||||
let mut out: Vec<String> = paths
|
let mut out: Vec<String> = paths
|
||||||
.iter()
|
.iter()
|
||||||
|
// **The same filter `project_path_mounts` applies, and it has to be.**
|
||||||
|
// That function skips a row with an empty `host_path` or `mount_name`
|
||||||
|
// so a legacy row cannot brick the create. The consequence is that
|
||||||
|
// `/workspace/<name>` for such a row is *not* a bind mount — it is
|
||||||
|
// ordinary writable-layer content. Excluding it here would tell
|
||||||
|
// `compute_verbatim_paths` to skip staging it, and the container swap
|
||||||
|
// would then destroy whatever the user has put there. The two
|
||||||
|
// predicates must agree or a migration silently eats a directory.
|
||||||
|
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
|
||||||
.map(|p| format!("/workspace/{}", p.mount_name))
|
.map(|p| format!("/workspace/{}", p.mount_name))
|
||||||
.collect();
|
.collect();
|
||||||
out.sort();
|
out.sort();
|
||||||
@@ -1368,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
/// The mount filter and the migration's exclusion list must agree.
|
||||||
|
///
|
||||||
|
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
|
||||||
|
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
|
||||||
|
/// writable-layer content rather than a bind mount — and if this function
|
||||||
|
/// still excluded it, `compute_verbatim_paths` would skip staging it and
|
||||||
|
/// the container swap would destroy whatever is there. A migration eating a
|
||||||
|
/// directory is the quietest kind of data loss there is.
|
||||||
|
#[test]
|
||||||
|
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
|
||||||
|
let paths = vec![
|
||||||
|
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
|
||||||
|
// Legacy shapes that `project_path_mounts` skips.
|
||||||
|
ProjectPath { host_path: "".into(), mount_name: "data".into() },
|
||||||
|
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
|
||||||
|
];
|
||||||
|
let excluded = bind_mount_exclusions(&paths);
|
||||||
|
assert_eq!(
|
||||||
|
excluded,
|
||||||
|
vec!["/workspace/code".to_string()],
|
||||||
|
"only rows that are actually mounted may be excluded from staging"
|
||||||
|
);
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
||||||
|
|||||||
+72
-10
@@ -713,8 +713,22 @@ mod tests {
|
|||||||
|
|
||||||
let mut defined: BTreeSet<String> = BTreeSet::new();
|
let mut defined: BTreeSet<String> = BTreeSet::new();
|
||||||
|
|
||||||
// Walk the source tree for `#[tauri::command]` and take the `fn` name
|
// Walk the source tree for the command attribute and take the `fn` name
|
||||||
// on the following non-attribute line.
|
// that follows.
|
||||||
|
//
|
||||||
|
// The first version of this matched `line.trim() == "#[tauri::command]"`
|
||||||
|
// exactly and broke on the first non-`#` line. An audit got five real,
|
||||||
|
// compiling, unregistered commands past it — `#[tauri::command(async)]`,
|
||||||
|
// `#[tauri::command(rename_all = "snake_case")]`, a trailing comment,
|
||||||
|
// spaces in the path, and a bare `#[command]` after `use tauri::command`
|
||||||
|
// — plus `pub(crate) fn` and a `///` line between attribute and `fn`.
|
||||||
|
// Every one of those is a command the frontend could not call, which is
|
||||||
|
// the bug this test exists for, and the test stayed green.
|
||||||
|
//
|
||||||
|
// The asymmetry matters: confusion on the *definition* side is a silent
|
||||||
|
// pass, while on the *registration* side it fails loudly against
|
||||||
|
// legitimate code — and rustc already covers that direction. So this
|
||||||
|
// errs toward over-matching definitions.
|
||||||
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
|
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
@@ -725,20 +739,39 @@ mod tests {
|
|||||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
for (i, line) in lines.iter().enumerate() {
|
for (i, line) in lines.iter().enumerate() {
|
||||||
if line.trim() != "#[tauri::command]" {
|
let t = line.trim();
|
||||||
|
// `#[tauri::command]`, `#[tauri::command(async)]`,
|
||||||
|
// `#[tauri :: command]`, a bare `#[command]` under
|
||||||
|
// `use tauri::command`, and any of those with a
|
||||||
|
// trailing comment.
|
||||||
|
let attr = t.strip_prefix("#[").map(|a| {
|
||||||
|
a.split(']').next().unwrap_or("").replace(' ', "")
|
||||||
|
});
|
||||||
|
let is_command_attr = attr.is_some_and(|a| {
|
||||||
|
a == "command" || a == "tauri::command"
|
||||||
|
|| a.starts_with("command(")
|
||||||
|
|| a.starts_with("tauri::command(")
|
||||||
|
});
|
||||||
|
if !is_command_attr {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Skip further attributes and doc comments rather than
|
||||||
|
// giving up at the first line that is not an attribute.
|
||||||
for next in lines.iter().skip(i + 1) {
|
for next in lines.iter().skip(i + 1) {
|
||||||
let t = next.trim();
|
let t = next.trim();
|
||||||
if t.starts_with('#') {
|
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(rest) = t
|
// Any visibility, then `fn` or `async fn`.
|
||||||
.strip_prefix("pub async fn ")
|
let after_vis = t
|
||||||
.or_else(|| t.strip_prefix("pub fn "))
|
.strip_prefix("pub(crate) ")
|
||||||
.or_else(|| t.strip_prefix("async fn "))
|
.or_else(|| t.strip_prefix("pub(super) "))
|
||||||
.or_else(|| t.strip_prefix("fn "))
|
.or_else(|| t.strip_prefix("pub(in crate) "))
|
||||||
{
|
.or_else(|| t.strip_prefix("pub "))
|
||||||
|
.unwrap_or(t);
|
||||||
|
let after_async =
|
||||||
|
after_vis.strip_prefix("async ").unwrap_or(after_vis);
|
||||||
|
if let Some(rest) = after_async.strip_prefix("fn ") {
|
||||||
if let Some(name) = rest.split(['(', '<']).next() {
|
if let Some(name) = rest.split(['(', '<']).next() {
|
||||||
out.insert(name.trim().to_string());
|
out.insert(name.trim().to_string());
|
||||||
}
|
}
|
||||||
@@ -799,6 +832,35 @@ mod tests {
|
|||||||
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// "exactly once" was in this test's name and not in its body: both
|
||||||
|
// sides were sets, so registering the same command twice in a
|
||||||
|
// hand-maintained 118-line list compiled, warned about nothing, and
|
||||||
|
// passed here.
|
||||||
|
let mut seen: Vec<&str> = Vec::new();
|
||||||
|
let mut duplicated: Vec<&str> = Vec::new();
|
||||||
|
for line in handler
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||||
|
{
|
||||||
|
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if seen.contains(&name) {
|
||||||
|
duplicated.push(name);
|
||||||
|
} else {
|
||||||
|
seen.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
duplicated.is_empty(),
|
||||||
|
"these are registered more than once: {:?}",
|
||||||
|
duplicated
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -602,7 +602,7 @@
|
|||||||
updateProjectList(msg.projects);
|
updateProjectList(msg.projects);
|
||||||
break;
|
break;
|
||||||
case 'opened':
|
case 'opened':
|
||||||
onSessionOpened(msg.session_id, msg.project_name);
|
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
|
||||||
break;
|
break;
|
||||||
case 'output':
|
case 'output':
|
||||||
onSessionOutput(msg.session_id, msg.data);
|
onSessionOutput(msg.session_id, msg.data);
|
||||||
@@ -653,8 +653,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSessionOpened(sessionId, projectName) {
|
function onSessionOpened(sessionId, projectName, serverSessionType) {
|
||||||
const sessionType = pendingSessionType || 'claude';
|
// Prefer the type the *server* reports for this session. The old path read
|
||||||
|
// a single `pendingSessionType` global set at request time, so opening two
|
||||||
|
// sessions before the first reply landed swapped their labels — routine on
|
||||||
|
// mobile, where nothing disables the buttons. That was cosmetic until
|
||||||
|
// Shift+Enter became type-dependent: a Claude session labelled `shell`
|
||||||
|
// sends a bare CR and submits a half-written prompt.
|
||||||
|
//
|
||||||
|
// The fallback keeps an older server working, and defaults to `claude`,
|
||||||
|
// which is the safe direction — ESC+CR is an unbound no-op in bash, while
|
||||||
|
// a bare CR in Claude Code loses the prompt.
|
||||||
|
const sessionType = serverSessionType || pendingSessionType || 'claude';
|
||||||
pendingSessionType = null;
|
pendingSessionType = null;
|
||||||
|
|
||||||
// Create terminal
|
// Create terminal
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ enum ServerMessage {
|
|||||||
Opened {
|
Opened {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
project_name: String,
|
project_name: String,
|
||||||
|
/// Echoed back so the client can label the session from the reply
|
||||||
|
/// rather than from a global set at request time.
|
||||||
|
///
|
||||||
|
/// Without it the client correlates through a single
|
||||||
|
/// `pendingSessionType`, so opening two sessions before the first
|
||||||
|
/// reply lands swaps their labels. That used to be cosmetic; it stopped
|
||||||
|
/// being cosmetic when Shift+Enter became type-dependent, because a
|
||||||
|
/// Claude session mislabelled as a shell now submits a half-written
|
||||||
|
/// prompt instead of inserting a newline.
|
||||||
|
session_type: String,
|
||||||
},
|
},
|
||||||
Output {
|
Output {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@@ -319,6 +329,11 @@ async fn handle_open(
|
|||||||
let _ = out_tx.send(ServerMessage::Opened {
|
let _ = out_tx.send(ServerMessage::Opened {
|
||||||
session_id,
|
session_id,
|
||||||
project_name,
|
project_name,
|
||||||
|
// Derived from the same match that chose `cmd` above, not echoed from
|
||||||
|
// the request: anything that is not exactly "bash" runs Claude, so
|
||||||
|
// echoing the raw value would label an unrecognised string as its own
|
||||||
|
// type and put the client back where it started.
|
||||||
|
session_type: if session_type == Some("bash") { "bash" } else { "claude" }.to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -60,12 +60,18 @@ describe("ClaudeCodeSettingsEditor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("offers every effort level Claude Code accepts", () => {
|
it("offers every effort level Claude Code accepts", () => {
|
||||||
|
// Verified against the shipped `claude` binary's own schema rather than
|
||||||
|
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
|
||||||
|
// checked externally — which is the whole weakness of this test. It can
|
||||||
|
// only prove the editor agrees with this list, never that the list is the
|
||||||
|
// one Claude Code reads. The same blind spot is why `effort` and
|
||||||
|
// `focusMode` were confidently wrong for months.
|
||||||
renderEditor(null);
|
renderEditor(null);
|
||||||
expect(
|
expect(
|
||||||
Array.from(
|
Array.from(
|
||||||
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
||||||
).map((o) => o.getAttribute("value")),
|
).map((o) => o.getAttribute("value")),
|
||||||
).toEqual(["", "low", "medium", "high", "xhigh"]);
|
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("project scope", () => {
|
describe("project scope", () => {
|
||||||
|
|||||||
@@ -68,7 +68,15 @@ const BOOLEAN_FIELDS: {
|
|||||||
hint: string;
|
hint: string;
|
||||||
invert?: boolean;
|
invert?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
{
|
||||||
|
key: "focus_mode",
|
||||||
|
label: "Focus mode",
|
||||||
|
// It summarises tool *calls*, not all output — and it does nothing at all
|
||||||
|
// unless the fullscreen renderer is on, which is a separate switch above.
|
||||||
|
// Saying so here is cheaper than the user concluding the setting is broken,
|
||||||
|
// which is the complaint that started this whole round of work.
|
||||||
|
hint: "Summarises each tool call to one line, showing the last prompt and the final response. Needs TUI mode set to Fullscreen.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "show_thinking_summaries",
|
key: "show_thinking_summaries",
|
||||||
label: "Thinking summaries",
|
label: "Thinking summaries",
|
||||||
@@ -168,6 +176,9 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
<option value="medium">Medium</option>
|
<option value="medium">Medium</option>
|
||||||
<option value="high">High</option>
|
<option value="high">High</option>
|
||||||
<option value="xhigh">Extra high</option>
|
<option value="xhigh">Extra high</option>
|
||||||
|
{/* `max` is accepted by the CLI and was missing here. Confirmed
|
||||||
|
against the shipped claude binary's own schema, not just docs. */}
|
||||||
|
<option value="max">Maximum</option>
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -111,11 +111,13 @@ export default function RuntimeSection({
|
|||||||
title="Claude Code settings"
|
title="Claude Code settings"
|
||||||
description={
|
description={
|
||||||
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
|
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
|
||||||
"Off overrides a global On. Turning TUI mode, Effort level or Focus mode " +
|
"Off overrides a global On. Changing any of these recreates the container, " +
|
||||||
"back to Global needs the container's base image updated first — those " +
|
"which commits a new image layer — so flipping switches repeatedly costs disk. " +
|
||||||
"three are cleared by removing a key, and an older image's startup script " +
|
"Turning TUI mode, Effort level, Focus mode or Session recap back to Global " +
|
||||||
"ignores the instruction to remove it. Update the base image from Overview " +
|
"also needs the base image updated first: those four are cleared by removing a " +
|
||||||
"if one of them will not switch off."
|
"key, and an older image's startup script ignores the instruction to remove it. " +
|
||||||
|
"Update the base image from Overview. TUI mode, Effort level and Focus mode " +
|
||||||
|
"visibly refuse to switch off until you do; Session recap just stays off silently."
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ClaudeCodeSettingsEditor
|
<ClaudeCodeSettingsEditor
|
||||||
|
|||||||
+33
-33
@@ -454,27 +454,48 @@ fi
|
|||||||
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
|
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
|
||||||
# volume, so without this the container keeps trying to run the SSO refresh even
|
# volume, so without this the container keeps trying to run the SSO refresh even
|
||||||
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
|
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
|
||||||
|
# Replace ~/.claude.json atomically: write a sibling temp file, then rename.
|
||||||
|
#
|
||||||
|
# `> "$CLAUDE_JSON"` truncates before it writes, so a write that fails part-way
|
||||||
|
# — a full home volume being the obvious way, and bounding that volume is what
|
||||||
|
# half this release is about — leaves the file unparseable. It holds the OAuth
|
||||||
|
# account, and the damage does not self-heal: the next start's `jq` fails on the
|
||||||
|
# corrupt file, `MERGED` comes back empty, and the `[ -n "$MERGED" ]` guard
|
||||||
|
# skips the write that would have repaired it. `triple-c-task-runner` has done
|
||||||
|
# it this way all along.
|
||||||
|
write_claude_json() {
|
||||||
|
_wcj_tmp="${CLAUDE_JSON}.triple-c-tmp"
|
||||||
|
if printf '%s\n' "$1" > "$_wcj_tmp" 2>/dev/null; then
|
||||||
|
mv -f "$_wcj_tmp" "$CLAUDE_JSON" 2>/dev/null || rm -f "$_wcj_tmp"
|
||||||
|
else
|
||||||
|
rm -f "$_wcj_tmp"
|
||||||
|
echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# By name, after the rename, so these land on the new inode.
|
||||||
|
chown claude:claude "$CLAUDE_JSON"
|
||||||
|
chmod 600 "$CLAUDE_JSON"
|
||||||
|
}
|
||||||
|
|
||||||
CLAUDE_JSON="/home/claude/.claude.json"
|
CLAUDE_JSON="/home/claude/.claude.json"
|
||||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||||
if [ -f "$CLAUDE_JSON" ]; then
|
if [ -f "$CLAUDE_JSON" ]; then
|
||||||
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
write_claude_json "$MERGED"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON"
|
# No existing file, so there is nothing to destroy — but go through the
|
||||||
|
# same helper so the owner and mode are set in one place.
|
||||||
|
write_claude_json "$(printf '{"awsAuthRefresh":"%s"}' "$AWS_SSO_AUTH_REFRESH_CMD")"
|
||||||
fi
|
fi
|
||||||
chown claude:claude "$CLAUDE_JSON"
|
|
||||||
chmod 600 "$CLAUDE_JSON"
|
|
||||||
unset AWS_SSO_AUTH_REFRESH_CMD
|
unset AWS_SSO_AUTH_REFRESH_CMD
|
||||||
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
|
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
|
||||||
# Only rewrite when the key is actually present, to avoid a needless jq
|
# Only rewrite when the key is actually present, to avoid a needless jq
|
||||||
# reformat of ~/.claude.json on every start of a non-SSO backend.
|
# reformat of ~/.claude.json on every start of a non-SSO backend.
|
||||||
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
write_claude_json "$MERGED"
|
||||||
chown claude:claude "$CLAUDE_JSON"
|
|
||||||
chmod 600 "$CLAUDE_JSON"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -491,38 +512,17 @@ if [ -f "$CLAUDE_JSON" ]; then
|
|||||||
# Only rewrite when the value isn't already true, to avoid a needless jq
|
# Only rewrite when the value isn't already true, to avoid a needless jq
|
||||||
# reformat of ~/.claude.json on every single start.
|
# reformat of ~/.claude.json on every single start.
|
||||||
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
|
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
|
||||||
# Write to a temp file and rename, never `> "$CLAUDE_JSON"`.
|
# Atomic, via `write_claude_json` — see its comment for why a plain
|
||||||
#
|
# `>` on this file can permanently destroy the OAuth login.
|
||||||
# `>` truncates before it writes, so a write that fails part-way — a
|
|
||||||
# full home volume is the obvious way, and bounding that volume is
|
|
||||||
# what half this release is about — leaves the file unparseable. This
|
|
||||||
# file holds the OAuth account, and the damage does not self-heal: the
|
|
||||||
# next start's `jq` fails on the corrupt file, `MERGED` is empty, and
|
|
||||||
# the guard below skips the write that would have repaired it. So the
|
|
||||||
# failure mode is a permanently lost login, for a purely cosmetic flag
|
|
||||||
# that suppresses a "run /terminal-setup" tip.
|
|
||||||
#
|
|
||||||
# `triple-c-task-runner` already does it this way; this block was
|
|
||||||
# modelled on the awsAuthRefresh one above, which has the same flaw but
|
|
||||||
# only fires when a Bedrock SSO command is configured.
|
|
||||||
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
CLAUDE_JSON_TMP="${CLAUDE_JSON}.triple-c-tmp"
|
write_claude_json "$MERGED"
|
||||||
if printf '%s\n' "$MERGED" > "$CLAUDE_JSON_TMP" 2>/dev/null; then
|
|
||||||
mv -f "$CLAUDE_JSON_TMP" "$CLAUDE_JSON" 2>/dev/null || rm -f "$CLAUDE_JSON_TMP"
|
|
||||||
else
|
|
||||||
# Out of space, or the volume went read-only. The original is
|
|
||||||
# untouched, which is the whole point.
|
|
||||||
rm -f "$CLAUDE_JSON_TMP"
|
|
||||||
echo "entrypoint: warning — could not set shiftEnterKeyBindingInstalled (leaving ~/.claude.json as it was)"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
printf '{"shiftEnterKeyBindingInstalled":true}\n' > "$CLAUDE_JSON"
|
# Nothing to destroy, but the helper owns the owner/mode too.
|
||||||
|
write_claude_json '{"shiftEnterKeyBindingInstalled":true}'
|
||||||
fi
|
fi
|
||||||
chown claude:claude "$CLAUDE_JSON"
|
|
||||||
chmod 600 "$CLAUDE_JSON"
|
|
||||||
|
|
||||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||||
if [ -S /var/run/docker.sock ]; then
|
if [ -S /var/run/docker.sock ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user