Compare commits

...
Author SHA1 Message Date
jknapp af59dc8bec Resume a session as a Claude tab so Shift+Enter inserts a newline (#67)
Secret Scan / scan (push) Successful in 6s
Build App / compute-version (push) Successful in 9s
Build App / build-macos (push) Successful in 3m25s
Build App / build-windows (push) Successful in 7m1s
Build App / build-linux (push) Successful in 7m44s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 18s
2026-10-17 02:22:13 +00:00
jknapp 412abd9348 Keep container SSH keys across restarts (#66)
Build App / compute-version (push) Successful in 5s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m27s
Build App / build-windows (push) Successful in 7m1s
Build App / build-linux (push) Successful in 8m42s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 18s
Build Container / build-container (push) Successful in 10m58s
2026-10-16 19:46:23 +00:00
19 changed files with 361 additions and 108 deletions

No files matched your search

+1 -1
View File
@@ -302,7 +302,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
approach converges on the worst state: a 400 MB browser present with its libraries gone.
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
**`/home/claude` in the image is seed-only.** It is the mount point of the named volume
-1
View File
@@ -107,7 +107,6 @@
"allow-get-gateway-auth-token",
"allow-regenerate-gateway-auth-token",
"allow-list-claude-sessions",
"allow-resume-session-command",
"allow-list-container-capabilities",
"allow-list-scheduled-tasks",
"allow-add-scheduled-task",
+1 -1
View File
@@ -1 +1 @@
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
+1 -1
View File
@@ -1 +1 @@
{"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 — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `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` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (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 either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
{"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 — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `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` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (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 either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
@@ -896,12 +896,6 @@
"const": "allow-rename-container-path",
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
},
{
"description": "Enables the resume_session_command command without any pre-configured scope.",
"type": "string",
"const": "allow-resume-session-command",
"markdownDescription": "Enables the resume_session_command command without any pre-configured scope."
},
{
"description": "Enables the rollback_migration command without any pre-configured scope.",
"type": "string",
@@ -1712,12 +1706,6 @@
"const": "deny-rename-container-path",
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
},
{
"description": "Denies the resume_session_command command without any pre-configured scope.",
"type": "string",
"const": "deny-resume-session-command",
"markdownDescription": "Denies the resume_session_command command without any pre-configured scope."
},
{
"description": "Denies the rollback_migration command without any pre-configured scope.",
"type": "string",
@@ -896,12 +896,6 @@
"const": "allow-rename-container-path",
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
},
{
"description": "Enables the resume_session_command command without any pre-configured scope.",
"type": "string",
"const": "allow-resume-session-command",
"markdownDescription": "Enables the resume_session_command command without any pre-configured scope."
},
{
"description": "Enables the rollback_migration command without any pre-configured scope.",
"type": "string",
@@ -1712,12 +1706,6 @@
"const": "deny-rename-container-path",
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
},
{
"description": "Denies the resume_session_command command without any pre-configured scope.",
"type": "string",
"const": "deny-resume-session-command",
"markdownDescription": "Denies the resume_session_command command without any pre-configured scope."
},
{
"description": "Denies the rollback_migration command without any pre-configured scope.",
"type": "string",
+1 -31
View File
@@ -301,7 +301,7 @@ fn validate_task_id(id: &str) -> Result<(), String> {
}
/// Strict allowlist for session ids (Claude Code uses UUIDs).
fn validate_session_id(id: &str) -> Result<(), String> {
pub(crate) fn validate_session_id(id: &str) -> Result<(), String> {
let valid = !id.is_empty()
&& id.len() <= 64
&& id.starts_with(|c: char| c.is_ascii_alphanumeric())
@@ -422,36 +422,6 @@ pub async fn list_claude_sessions(
.collect())
}
/// Build the shell command line that resumes a session, for the frontend to
/// drop into a terminal.
///
/// The flag spelling was checked against the CLI in the container image:
/// `claude --resume <session-id>` (short form `-r`).
///
/// The project's permission mode is folded in so the resumed session behaves
/// like a freshly opened one. The session id is validated first, and the
/// returned string contains only allowlisted characters.
#[tauri::command]
pub async fn resume_session_command(
project_id: String,
session_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
validate_session_id(&session_id)?;
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let mut parts = vec!["claude".to_string()];
parts.extend(project.effective_permission_mode().cli_args());
parts.push("--resume".to_string());
parts.push(session_id);
Ok(parts.join(" "))
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Capabilities
// ─────────────────────────────────────────────────────────────────────────────
@@ -36,7 +36,7 @@ pub fn validate_settings_update(
// 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
// then merges `/tmp/.host-ssh` into `~/.ssh`, recursively copying it into the
// home volume this release exists to bound.
//
// Grandfathered the same way project paths are: a value carried over
+65 -10
View File
@@ -11,12 +11,18 @@ use crate::AppState;
/// validates the AWS session first, and runs `aws sso login` if it has expired
/// so the user can re-authenticate (the URL is clickable via xterm.js
/// WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
fn build_terminal_cmd(
project: &Project,
state: &AppState,
session_name: Option<&str>,
resume_session_id: Option<&str>,
) -> Vec<String> {
let settings = state.settings_store.get();
build_claude_terminal_cmd(
project,
settings.global_aws.aws_profile.as_deref(),
session_name,
resume_session_id,
)
}
@@ -53,10 +59,16 @@ fn shell_quote_arg(arg: &str) -> String {
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
/// profile rather than the whole [`AppState`].
///
/// `resume_session_id` adds `--resume <id>` (flag spelling checked against the
/// CLI in the container image) and drops `-n`: the resumed conversation keeps
/// the name it already has, and the tab label the caller passes as
/// `session_name` — often just "resume" — must not overwrite it.
fn build_claude_terminal_cmd(
project: &Project,
global_aws_profile: Option<&str>,
session_name: Option<&str>,
resume_session_id: Option<&str>,
) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
@@ -69,12 +81,15 @@ fn build_claude_terminal_cmd(
// The args are interpolated into a shell script string, so single-quote
// each one.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n{}", shell_quote_arg(n)))
.unwrap_or_default();
let session_flags = match resume_session_id {
Some(id) => format!(" --resume{}", shell_quote_arg(id)),
None => session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n{}", shell_quote_arg(n)))
.unwrap_or_default(),
};
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
let claude_cmd = format!("exec claude{}{}", permission_flags, session_flags);
if !is_bedrock_profile {
return vec![
@@ -135,6 +150,7 @@ pub async fn open_terminal_session(
session_id: String,
session_type: Option<String>,
session_name: Option<String>,
resume_session_id: Option<String>,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
@@ -148,9 +164,21 @@ pub async fn open_terminal_session(
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
if let Some(id) = resume_session_id.as_deref() {
crate::commands::inspect_commands::validate_session_id(id)?;
}
let cmd = match session_type.as_deref() {
Some("bash") if resume_session_id.is_some() => {
return Err("Only a Claude session can resume a conversation".to_string());
}
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
_ => build_terminal_cmd(
&project,
&state,
session_name.as_deref(),
resume_session_id.as_deref(),
),
};
let output_event = format!("terminal-output-{}", session_id);
@@ -414,7 +442,7 @@ mod tests {
/// wrapper rather than a bare `claude` argv.
#[test]
fn build_terminal_cmd_updates_before_launching_claude() {
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None, None);
assert_eq!(cmd[0], "bash");
assert_eq!(cmd[1], "-c");
@@ -444,6 +472,7 @@ mod tests {
&project("anthropic", serde_json::Value::Null),
None,
Some("Bob's tab; rm -rf /"),
None,
);
assert!(
@@ -459,7 +488,7 @@ mod tests {
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
let mut p = project("anthropic", serde_json::Value::Null);
p.full_permissions = true;
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
let cmd = build_claude_terminal_cmd(&p, None, Some(""), None);
assert!(
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
@@ -474,7 +503,7 @@ mod tests {
fn build_terminal_cmd_passes_auto_permission_mode() {
let mut p = project("anthropic", serde_json::Value::Null);
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
let cmd = build_claude_terminal_cmd(&p, None, None);
let cmd = build_claude_terminal_cmd(&p, None, None, None);
assert!(
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
@@ -497,6 +526,7 @@ mod tests {
})),
None,
Some("it's fine"),
None,
);
assert_eq!(cmd[0], "bash");
@@ -512,6 +542,31 @@ mod tests {
);
}
/// Resume goes through the same launch as a new session — prelude,
/// permission flags — and passes the id instead of the tab label, so a
/// label of "resume" does not rename the conversation.
#[test]
fn build_terminal_cmd_resumes_without_renaming() {
let mut p = project("anthropic", serde_json::Value::Null);
p.full_permissions = true;
let cmd = build_claude_terminal_cmd(
&p,
None,
Some("resume"),
Some("e13d312d-2f38-4cf6-b0e0-1db60208a74c"),
);
assert!(cmd[2].contains(UPDATE_PRELUDE), "got: {}", cmd[2]);
assert!(
cmd[2].contains(
"exec claude '--dangerously-skip-permissions' --resume 'e13d312d-2f38-4cf6-b0e0-1db60208a74c'\n"
),
"got: {}",
cmd[2]
);
assert!(!cmd[2].contains(" -n "), "resume must not rename: {}", cmd[2]);
}
#[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() {
use crate::commands::file_commands::host_upload_name;
+134
View File
@@ -205,3 +205,137 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
let _ = buf.flush();
Ok(buf)
}
#[cfg(all(test, unix))]
mod tests {
use super::ENTRYPOINT;
use std::fs;
use std::path::Path;
use std::process::Command;
/// The `sync_host_ssh` function from `entrypoint.sh`, cut out between its
/// markers so it can be run against temp dirs instead of the real ~/.ssh.
fn sync_fn() -> &'static str {
let start = ENTRYPOINT.find("# >>> sync_host_ssh").expect("start marker");
let end = ENTRYPOINT.find("# <<< sync_host_ssh").expect("end marker");
&ENTRYPOINT[start..end]
}
fn sync(src: &Path, dest: &Path) -> String {
let script = format!("{}\nsync_host_ssh \"$1\" \"$2\"\n", sync_fn());
let out = Command::new("bash")
.arg("-c")
.arg(script)
.arg("sync")
.arg(src)
.arg(dest)
.output()
.expect("run bash");
assert!(out.status.success(), "sync failed: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn read(p: &Path) -> String {
fs::read_to_string(p).unwrap()
}
#[test]
fn container_created_keys_survive_restart() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::write(src.join("id_host"), "host-key").unwrap();
sync(&src, &dest);
fs::write(dest.join("id_container"), "made-in-container").unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("id_container")), "made-in-container");
assert_eq!(read(&dest.join("id_host")), "host-key");
}
#[test]
fn container_file_wins_over_same_named_host_file() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::write(src.join("id_host"), "host-key").unwrap();
sync(&src, &dest);
fs::write(dest.join("id_ed25519"), "container-key").unwrap();
fs::write(src.join("id_ed25519"), "host-ed25519").unwrap();
let out = sync(&src, &dest);
assert_eq!(read(&dest.join("id_ed25519")), "container-key");
assert!(out.contains("keeping container's"), "{out}");
}
#[test]
fn host_changes_propagate_until_edited_in_container() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::write(src.join("config"), "v1").unwrap();
fs::write(src.join("id_old"), "old").unwrap();
sync(&src, &dest);
fs::write(src.join("config"), "v2").unwrap();
fs::remove_file(src.join("id_old")).unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("config")), "v2");
assert!(!dest.join("id_old").exists(), "host-removed, unedited file is dropped");
fs::write(dest.join("config"), "edited in container").unwrap();
fs::write(src.join("config"), "v3").unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("config")), "edited in container");
fs::remove_file(src.join("config")).unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("config")), "edited in container");
}
#[test]
fn known_hosts_is_unioned() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(src.join("known_hosts"), "hostA key\n").unwrap();
fs::write(dest.join("known_hosts"), "containerB key\n").unwrap();
sync(&src, &dest);
let kh = read(&dest.join("known_hosts"));
assert!(kh.contains("hostA key") && kh.contains("containerB key"), "{kh}");
}
#[test]
fn upgrade_from_wipe_and_copy_backs_up_differing_files() {
// No manifest yet: the state an existing project is in on its first
// start with this entrypoint.
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(src.join("id_rsa"), "host").unwrap();
fs::write(dest.join("id_rsa"), "container").unwrap();
fs::write(dest.join("id_extra"), "extra").unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("id_rsa")), "host");
assert_eq!(read(&dest.join("id_rsa.triple-c-backup")), "container");
assert_eq!(read(&dest.join("id_extra")), "extra");
}
#[test]
fn nested_host_files_are_copied() {
let tmp = tempfile::tempdir().unwrap();
let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh"));
fs::create_dir_all(src.join("config.d")).unwrap();
fs::write(src.join("config.d/work"), "Host work").unwrap();
sync(&src, &dest);
assert_eq!(read(&dest.join("config.d/work")), "Host work");
}
}
-1
View File
@@ -645,7 +645,6 @@ pub fn run() {
commands::gateway_commands::regenerate_gateway_auth_token,
// Container introspection (sessions / capabilities / scheduler)
commands::inspect_commands::list_claude_sessions,
commands::inspect_commands::resume_session_command,
commands::inspect_commands::list_container_capabilities,
commands::inspect_commands::list_scheduled_tasks,
commands::inspect_commands::add_scheduled_task,
@@ -17,6 +17,7 @@ vi.mock("../../hooks/useProjectActions", () => ({
handleBackup: vi.fn(),
openClaudeTerminal: mockOpenClaudeTerminal,
openShell: vi.fn(),
resumeClaudeSession: vi.fn(),
openTerminalWithCommand: vi.fn(),
}),
}));
@@ -4,7 +4,6 @@ import {
listClaudeSessions,
listScheduledTasks,
getSchedulerNotifications,
resumeSessionCommand,
} from "../../../lib/tauri-commands";
import type { useProjectActions } from "../../../hooks/useProjectActions";
import type { SaveState } from "../../../hooks/useSaveState";
@@ -78,14 +77,8 @@ export default function OverviewTab({
};
}, [project.id, running, project.container_id]);
const handleResume = async (session: ClaudeSession) => {
try {
const command = await resumeSessionCommand(project.id, session.id);
await actions.openTerminalWithCommand(command, session.name ?? "resume");
} catch (e) {
console.error("Failed to build the resume command:", e);
}
};
const handleResume = (session: ClaudeSession) =>
actions.resumeClaudeSession(session.id, session.name ?? "resume");
return (
<div className="p-4 space-y-6 max-w-4xl">
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { ClaudeSession, Project } from "../../../lib/types";
import { listClaudeSessions, resumeSessionCommand } from "../../../lib/tauri-commands";
import { listClaudeSessions } from "../../../lib/tauri-commands";
import type { useProjectActions } from "../../../hooks/useProjectActions";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import { formatAge, formatBytes } from "./format";
@@ -18,7 +17,6 @@ interface Props {
export default function SessionsTab({ project, actions }: Props) {
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
const [loading, setLoading] = useState(false);
const pushToast = useAppState((s) => s.pushToast);
const running = project.status === "running";
const load = useCallback(() => {
@@ -35,18 +33,8 @@ export default function SessionsTab({ project, actions }: Props) {
useEffect(load, [load]);
const resume = async (session: ClaudeSession) => {
try {
const command = await resumeSessionCommand(project.id, session.id);
await actions.openTerminalWithCommand(command, session.name ?? "resume");
} catch (e) {
pushToast({
kind: "error",
message: "Could not resume that session",
detail: String(e),
});
}
};
const resume = (session: ClaudeSession) =>
actions.resumeClaudeSession(session.id, session.name ?? "resume");
return (
<div className="p-4 max-w-4xl">
+22 -3
View File
@@ -96,9 +96,27 @@ Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
}, [openTerminal, project.id, project.name, fail]);
/**
* Open a shell tab and type `command` into it. Used for [Resume] and for the
* capability drawer's "Manage in terminal" — the backend has no
* run-a-command entry point, so we drive the shell we just opened.
* Open a Claude tab on a saved conversation. A real Claude session, not a
* shell with `claude --resume` typed into it: the tab's session type is
* what Shift+Enter, the status-bar hint and Send to agent key off, so a
* resumed conversation in a "bash" tab submitted on Shift+Enter.
*/
const resumeClaudeSession = useCallback(
async (claudeSessionId: string, sessionLabel?: string) => {
try {
return await openTerminal(project.id, project.name, "claude", sessionLabel, claudeSessionId);
} catch (e) {
fail("Could not resume that session", e);
return null;
}
},
[openTerminal, project.id, project.name, fail],
);
/**
* Open a shell tab and type `command` into it. Used for the capability
* drawer's "Manage in terminal" — the backend has no run-a-command entry
* point, so we drive the shell we just opened.
*/
const openTerminalWithCommand = useCallback(
async (command: string, sessionLabel?: string) => {
@@ -170,6 +188,7 @@ Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
handleBackup,
openClaudeTerminal,
openShell,
resumeClaudeSession,
openTerminalWithCommand,
};
}
+23
View File
@@ -81,3 +81,26 @@ describe("useTerminal input ordering", () => {
expect(bySession).toContainEqual(["b", "2"]);
});
});
describe("useTerminal open", () => {
it("opens a resumed conversation as a Claude session", async () => {
// Resume used to open a "bash" tab and type `claude --resume` into it, and
// Shift+Enter keys off the tab's type — so it submitted instead of adding
// a newline. The id has to travel to the backend *and* the tab has to be
// registered as "claude".
const commands = await import("../lib/tauri-commands");
const { useAppState } = await import("../store/appState");
const { result } = renderHook(() => useTerminal());
const id = await result.current.open("p1", "proj", "claude", "my chat", "e13d312d-2f38");
expect(commands.openTerminalSession).toHaveBeenCalledWith(
"p1",
id,
"claude",
"my chat",
"e13d312d-2f38",
);
expect(useAppState.getState().sessions.find((s) => s.id === id)?.sessionType).toBe("claude");
});
});
+8 -2
View File
@@ -97,9 +97,15 @@ export function useTerminal() {
);
const open = useCallback(
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude", sessionName?: string) => {
async (
projectId: string,
projectName: string,
sessionType: "claude" | "bash" = "claude",
sessionName?: string,
resumeSessionId?: string,
) => {
const sessionId = crypto.randomUUID();
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName, resumeSessionId);
addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
return sessionId;
},
+8 -4
View File
@@ -65,8 +65,14 @@ export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId });
// Terminal
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
export const openTerminalSession = (
projectId: string,
sessionId: string,
sessionType?: string,
sessionName?: string,
resumeSessionId?: string,
) =>
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName, resumeSessionId });
export const terminalInput = (sessionId: string, data: number[]) =>
invoke<void>("terminal_input", { sessionId, data });
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
@@ -168,8 +174,6 @@ export const runDockerInstall = () => invoke<void>("run_docker_install");
// Container introspection — sessions
export const listClaudeSessions = (projectId: string) =>
invoke<ClaudeSession[]>("list_claude_sessions", { projectId });
export const resumeSessionCommand = (projectId: string, sessionId: string) =>
invoke<string>("resume_session_command", { projectId, sessionId });
// Container introspection — capabilities
export const listContainerCapabilities = (projectId: string) =>
+90 -4
View File
@@ -220,14 +220,100 @@ install_corporate_ca() {
install_corporate_ca
# ── SSH key setup ──────────────────────────────────────────────────────────
# Host SSH dir is mounted read-only at /tmp/.host-ssh.
# Copy to /home/claude/.ssh so we can fix permissions.
# Host SSH dir is mounted read-only at /tmp/.host-ssh and merged into
# /home/claude/.ssh (which lives on the home volume) so we can fix permissions.
#
# This used to `rm -rf ~/.ssh` and copy the host dir over it on every start,
# which silently deleted any key generated or saved inside the container and
# overwrote container edits to config/known_hosts. Now it is a merge:
# - Files the container created are never touched, even if the host later
# gains a file of the same name (the host copy is skipped with a warning).
# - A file previously copied from the host is refreshed when the host copy
# changes, and removed when the host drops it — but only while it is still
# byte-identical to what we copied. Once edited in the container it is the
# container's.
# - known_hosts is unioned, never replaced (ssh appends to it at runtime).
# `.triple-c-host-files` records "<sha256>\t<relative path>" for every file we
# copied, which is how the two kinds of file are told apart. Without it (first
# start after upgrading from the wipe-and-copy entrypoint) a differing file is
# backed up to `<name>.triple-c-backup` before the host copy replaces it.
#
# The function body between the markers is executed by a unit test in
# app/src-tauri/src/docker/image.rs; keep it self-contained.
# >>> sync_host_ssh
sync_host_ssh() {
local src="$1" dest="$2"
local manifest="$dest/.triple-c-host-files"
local legacy=0 new_manifest f rel target src_sum cur_sum recorded
_ssh_sum() {
if [ -L "$1" ]; then
printf 'link:%s' "$(readlink "$1")" | sha256sum | cut -d' ' -f1
else
sha256sum < "$1" | cut -d' ' -f1
fi
}
mkdir -p "$dest"
[ -f "$manifest" ] || legacy=1
new_manifest=$(mktemp)
while IFS= read -r -d '' f; do
rel="${f#"$src"/}"
[ "$rel" = "known_hosts" ] && continue
target="$dest/$rel"
src_sum=$(_ssh_sum "$f")
if [ -e "$target" ] || [ -L "$target" ]; then
cur_sum=$(_ssh_sum "$target")
if [ "$cur_sum" != "$src_sum" ]; then
recorded=""
[ "$legacy" = 0 ] && recorded=$(awk -F'\t' -v r="$rel" '$2 == r { print $1; exit }' "$manifest")
if [ "$legacy" = 1 ]; then
cp -a "$target" "$target.triple-c-backup"
echo "entrypoint: ~/.ssh/$rel differs from host copy; kept old version as $rel.triple-c-backup"
elif [ -z "$recorded" ] || [ "$recorded" != "$cur_sum" ]; then
echo "entrypoint: keeping container's ~/.ssh/$rel (host copy not applied)"
continue
fi
rm -f "$target"
mkdir -p "$(dirname "$target")"
cp -a "$f" "$target"
fi
else
mkdir -p "$(dirname "$target")"
cp -a "$f" "$target"
fi
printf '%s\t%s\n' "$src_sum" "$rel" >> "$new_manifest"
done < <(find "$src" \( -type f -o -type l \) -print0)
# Drop files the host no longer provides, unless edited in the container.
if [ "$legacy" = 0 ]; then
while IFS=$'\t' read -r recorded rel; do
[ -n "$rel" ] || continue
if [ ! -e "$src/$rel" ] && [ ! -L "$src/$rel" ] \
&& { [ -e "$dest/$rel" ] || [ -L "$dest/$rel" ]; } \
&& [ "$(_ssh_sum "$dest/$rel")" = "$recorded" ]; then
rm -f "$dest/$rel"
fi
done < "$manifest"
fi
if [ -f "$src/known_hosts" ]; then
cat "$src/known_hosts" >> "$dest/known_hosts"
sort -u -o "$dest/known_hosts" "$dest/known_hosts"
fi
mv "$new_manifest" "$manifest"
unset -f _ssh_sum
}
# <<< sync_host_ssh
if [ -d /tmp/.host-ssh ]; then
rm -rf /home/claude/.ssh
cp -a /tmp/.host-ssh /home/claude/.ssh
sync_host_ssh /tmp/.host-ssh /home/claude/.ssh
chown -R claude:claude /home/claude/.ssh
chmod 700 /home/claude/.ssh
find /home/claude/.ssh -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \;
find /home/claude/.ssh -type f -name "*.triple-c-backup" -exec chmod 600 {} \;
find /home/claude/.ssh -type f -name "*.pub" -exec chmod 644 {} \;
if [ -f /home/claude/.ssh/known_hosts ]; then
chmod 644 /home/claude/.ssh/known_hosts