From 0ac4e5030c86e94692088fd2d190979294d9625b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 10:51:34 -0700 Subject: [PATCH] Add permission modes and container introspection backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission modes: replaces the binary full_permissions flag with a PermissionMode enum (Plan/Default/AcceptEdits/Bypass). Flag mapping is defined once in PermissionMode::cli_args() and used by the terminal, the web terminal, and the scheduler: Plan -> --permission-mode plan Default -> (no flag) AcceptEdits -> --permission-mode acceptEdits Bypass -> --dangerously-skip-permissions Choices verified against `claude --permission-mode` on 2.1.226. full_permissions is retained and effective_permission_mode() falls back to it, so existing projects.json needs no migration. Bug fix: triple-c-task-runner ran `claude -p ... --dangerously-skip- permissions` unconditionally, ignoring the project's setting entirely. It now reads TRIPLE_C_PERMISSION_MODE, which is injected into the container, added to the reserved env blocklist, propagated through the entrypoint's cron env filter, and tracked by a new triple-c.permission-mode label so a change forces recreation. Introspection: new commands/inspect_commands.rs exposes read-only views into the container over docker exec — Claude sessions (parsed from ~/.claude/projects//.jsonl), installed capabilities (skills, agents, commands, hooks, plugins, natively-configured MCP servers), and the triple-c-scheduler task list, logs and notifications. Task/session ids are validated against a strict allowlist and every parameterized call runs as a bare argv vector via bollard, so no shell is involved. Stopped containers return empty results rather than errors. No UI yet; that lands with the Project Home view. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/commands/inspect_commands.rs | 951 ++++++++++++++++++ app/src-tauri/src/commands/mod.rs | 1 + .../src/commands/terminal_commands.rs | 18 +- app/src-tauri/src/docker/container.rs | 29 +- app/src-tauri/src/lib.rs | 11 + app/src-tauri/src/models/project.rs | 64 ++ app/src-tauri/src/web_terminal/ws_handler.rs | 18 +- app/src/lib/tauri-commands.ts | 28 +- app/src/lib/types.ts | 73 ++ container/entrypoint.sh | 2 +- container/triple-c-task-runner | 19 +- 11 files changed, 1193 insertions(+), 21 deletions(-) create mode 100644 app/src-tauri/src/commands/inspect_commands.rs diff --git a/app/src-tauri/src/commands/inspect_commands.rs b/app/src-tauri/src/commands/inspect_commands.rs new file mode 100644 index 0000000..84d9be1 --- /dev/null +++ b/app/src-tauri/src/commands/inspect_commands.rs @@ -0,0 +1,951 @@ +//! Read-only introspection of what lives inside a project's container. +//! +//! Three inventories are exposed to the GUI: +//! 1. Claude Code sessions (transcripts on the persistent config volume) +//! 2. Container capabilities (skills / agents / commands / hooks / plugins / MCP) +//! 3. Scheduled tasks managed by the in-container `triple-c-scheduler` +//! +//! Everything here is read-only except the explicitly-mutating scheduler +//! commands at the bottom of the file (enable/disable, run, remove, clear +//! notifications), which shell out to the scheduler's own subcommands rather +//! than editing its state files. +//! +//! ## Container access +//! +//! All work happens inside the container via the existing `docker exec` +//! plumbing in [`crate::docker::exec`] — no second mechanism is introduced. +//! The heavy lifting (walking dirs, grepping transcripts, parsing JSON with +//! `jq`) runs *in* the container and only a small JSON summary crosses the +//! wire, so multi-megabyte transcripts are never streamed back. +//! +//! `HOME` is passed explicitly on every exec: `docker exec` inherits the +//! container image's environment rather than the target user's, so `$HOME` is +//! not reliably `/home/claude` otherwise (see `download_container_backup`, +//! which does the same). +//! +//! ## Injection safety +//! +//! Two rules, applied together (defense in depth): +//! +//! * The `sh -c` scripts below are compile-time constants. No caller-supplied +//! value is ever interpolated into them. +//! * Every command that takes a caller-supplied id runs as a plain **argv +//! vector** with no shell in the process tree at all, so shell metacharacters +//! are inert by construction. On top of that, ids are validated against a +//! strict allowlist ([`validate_task_id`], [`validate_session_id`]) that +//! admits no shell metacharacters, no `/`, no `.` (so no path traversal into +//! the scheduler's task dir), and no leading `-` (so no option injection). +//! +//! ## Degradation +//! +//! A stopped or missing container is a normal state, not an error: the +//! read-only commands return empty/zero results. Only the mutating scheduler +//! commands fail loudly, since they cannot do anything useful without a +//! running container. + +use bollard::exec::{CreateExecOptions, StartExecOptions}; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::docker::client::get_docker; +use crate::docker::container::is_container_running; +use crate::docker::exec::{exec_oneshot_env, exec_oneshot_env_status}; +use crate::AppState; + +/// Newest N session transcripts to inspect. Caps the work done inside the +/// container regardless of how much history has accumulated on the volume. +const MAX_SESSIONS: usize = 50; + +/// Newest N scheduler notifications to return. +const MAX_NOTIFICATIONS: usize = 50; + +const CONTAINER_HOME: &str = "/home/claude"; + +// ───────────────────────────────────────────────────────────────────────────── +// Response models +// +// These live here (rather than in `models/`) so this feature is confined to a +// single file; they are IPC response shapes, not persisted state. +// ───────────────────────────────────────────────────────────────────────────── + +/// One Claude Code session transcript found inside the container. +#[derive(Debug, Clone, Serialize)] +pub struct ClaudeSession { + /// Session UUID (the transcript's filename stem, and what `--resume` takes). + pub id: String, + /// User-set display name (`claude -n `), if the session has one. + pub name: Option, + /// Best available one-line description: Claude's auto-generated title if it + /// produced one, otherwise the last prompt sent in the session. + pub summary: Option, + /// Transcript mtime as an ISO 8601 / RFC 3339 timestamp (UTC). + pub last_modified: String, + pub size_bytes: u64, + /// Approximate user + assistant turn count (counted by line, cheap). + pub message_count: u64, + /// The directory the session was started in. + pub cwd: Option, +} + +/// A single installed capability (skill, agent, command, hook event, …). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapabilityItem { + pub name: String, + pub description: Option, + /// `"user"` (from `~/.claude`) or `"project"` (from a mounted workspace). + pub scope: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CapabilityGroup { + pub count: u64, + pub items: Vec, +} + +/// Inventory of everything Claude Code has available inside the container. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ContainerCapabilities { + pub skills: CapabilityGroup, + pub agents: CapabilityGroup, + pub commands: CapabilityGroup, + /// One item per configured hook event; `count` is the total number of + /// individual hook handlers across all events. + pub hooks: CapabilityGroup, + pub plugins: CapabilityGroup, + pub mcp_servers: CapabilityGroup, +} + +/// A task managed by the in-container `triple-c-scheduler`. +/// +/// Mirrors the scheduler's own on-disk JSON schema +/// (`~/.claude/scheduler/tasks/.json`). +#[derive(Debug, Clone, Serialize)] +pub struct ScheduledTask { + pub id: String, + pub name: String, + pub prompt: String, + /// Cron expression. One-shot tasks are also stored as a cron expression; + /// see `at` for the original wall-clock time. + pub schedule: String, + /// `"recurring"` or `"once"` (the scheduler's `type` field). + pub task_type: String, + /// Original `--at` value (`"YYYY-MM-DD HH:MM"`) for one-shot tasks. + pub at: Option, + pub enabled: bool, + pub working_dir: String, + pub created_at: Option, + /// Derived from the newest file in `~/.claude/scheduler/logs//`; the + /// scheduler does not record this in the task JSON itself. + pub last_run: Option, + /// Only known for enabled one-shot tasks (their `at` time). Recurring cron + /// expressions are not evaluated here. + pub next_run: Option, +} + +/// A completion notice written by `triple-c-task-runner` after a task ran. +#[derive(Debug, Clone, Serialize)] +pub struct SchedulerNotification { + pub task_id: String, + pub task_name: Option, + /// `"SUCCESS"` or `"FAILED (exit code N)"`. + pub status: Option, + /// The runner's own human-readable timestamp line. + pub time: Option, + pub task_type: Option, + /// Tail of the run's log that the runner captured. + pub summary: Option, + /// Full notification text, verbatim. + pub body: String, + /// Notification file mtime, ISO 8601 (UTC). + pub created_at: String, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Resolve a project to a *running* container id. +/// +/// `Ok(None)` means "there is nothing to inspect" — no container recorded, or +/// the container exists but is stopped. Callers that are read-only turn that +/// into an empty result; mutating callers turn it into an error. +/// `Err` is reserved for a genuinely unknown project id. +async fn running_container_for( + project_id: &str, + state: &State<'_, AppState>, +) -> Result, String> { + let project = state + .projects_store + .get(project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let container_id = match project.container_id { + Some(id) => id, + None => return Ok(None), + }; + + if is_container_running(&container_id).await.unwrap_or(false) { + Ok(Some(container_id)) + } else { + Ok(None) + } +} + +/// Same as [`running_container_for`], but a stopped container is an error. +/// Used by the mutating scheduler commands. +async fn require_running_container( + project_id: &str, + state: &State<'_, AppState>, +) -> Result { + running_container_for(project_id, state).await?.ok_or_else(|| { + "Container is not running — start the project first.".to_string() + }) +} + +fn home_env() -> Vec { + vec![format!("HOME={}", CONTAINER_HOME)] +} + +/// Run one of this module's constant scripts under `sh -c` and return stdout. +/// +/// The scripts redirect their own stderr to `/dev/null` (`exec 2>/dev/null` on +/// the first line) so the combined stream `exec_oneshot_env` returns is pure +/// stdout and stays parseable as JSON. A non-zero exit therefore surfaces as +/// empty output, which the callers treat as "nothing to report". +async fn run_script(container_id: &str, script: impl Into) -> Result { + exec_oneshot_env( + container_id, + vec!["sh".to_string(), "-c".to_string(), script.into()], + home_env(), + ) + .await +} + +/// Parse script output as JSON, degrading to a default value (and a log line) +/// rather than failing the whole command if the container returned something +/// unexpected. +fn parse_or_default(raw: &str, what: &str) -> T { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return T::default(); + } + match serde_json::from_str::(trimmed) { + Ok(v) => v, + Err(e) => { + log::warn!( + "Failed to parse {} JSON from container ({}): {}", + what, + e, + trimmed.chars().take(300).collect::() + ); + T::default() + } + } +} + +fn epoch_to_iso(epoch: i64) -> String { + chrono::DateTime::from_timestamp(epoch, 0) + .unwrap_or_default() + .to_rfc3339() +} + +/// Strict allowlist for scheduler task ids. +/// +/// The scheduler generates ids as 8 lowercase hex chars (`head -c 4 +/// /dev/urandom | od -An -tx1`). This accepts that plus a small tolerant +/// superset, while admitting **no** shell metacharacters, no `/` or `.` (so a +/// crafted id cannot escape `~/.claude/scheduler/tasks/`), and no leading `-` +/// (so it cannot be mistaken for an option). Combined with argv-only execution +/// this makes shell injection structurally impossible. +fn validate_task_id(id: &str) -> Result<(), String> { + let valid = !id.is_empty() + && id.len() <= 64 + && id.starts_with(|c: char| c.is_ascii_alphanumeric()) + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if valid { + Ok(()) + } else { + Err(format!("Invalid scheduler task id: {:?}", id)) + } +} + +/// Strict allowlist for session ids (Claude Code uses UUIDs). +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()) + && id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'); + if valid { + Ok(()) + } else { + Err(format!("Invalid session id: {:?}", id)) + } +} + +/// Run `triple-c-scheduler ` as a bare argv vector — no shell is +/// involved, so caller-supplied ids cannot be interpreted as shell syntax. +/// Returns the combined output, erroring with it on a non-zero exit. +async fn run_scheduler(container_id: &str, args: Vec) -> Result { + let mut cmd = vec!["triple-c-scheduler".to_string()]; + cmd.extend(args); + + let (output, exit_code) = exec_oneshot_env_status(container_id, cmd, home_env()).await?; + if exit_code != 0 { + let detail = output.trim(); + return Err(if detail.is_empty() { + format!("triple-c-scheduler failed (exit {})", exit_code) + } else { + detail.to_string() + }); + } + Ok(output) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Sessions +// ───────────────────────────────────────────────────────────────────────────── + +/// Emits a JSON array describing the newest transcripts on the config volume. +/// +/// Layout (verified empirically against Claude Code 2.1.226): transcripts are +/// JSON Lines at `~/.claude/projects//.jsonl`. +/// +/// Metadata is pulled with a single `grep -o` pass per file that yields whole +/// JSON key/value fragments; each fragment is already a valid JSON object body, +/// so wrapping it in braces and letting `jq` merge them decodes escapes +/// correctly without ever parsing a full transcript line-by-line. Later records +/// win (so the newest title/prompt is used) except `cwd`, where the first +/// record wins (the directory the session actually started in). Malformed lines +/// simply fail to match and are skipped. +const SESSIONS_SCRIPT: &str = r#"exec 2>/dev/null +set -u +ROOT="$HOME/.claude/projects" +[ -d "$ROOT" ] || { echo '[]'; exit 0; } +TAB=$(printf '\t') +find "$ROOT" -mindepth 2 -maxdepth 2 -name '*.jsonl' -type f -printf '%T@\t%s\t%p\n' \ + | sort -rn | head -__MAX__ \ + | while IFS="$TAB" read -r mtime size path; do + [ -n "${path:-}" ] || continue + [ "${size:-0}" -gt 0 ] || continue + id=$(basename "$path" .jsonl) + meta=$(grep -aoE '"(cwd|aiTitle|customTitle|agentName|lastPrompt|summary)":"([^"\\]|\\.)*"' "$path" \ + | sed 's/^/{/; s/$/}/' \ + | jq -c -s '(reduce .[] as $o ({}; . + $o)) + (([.[] | select(has("cwd"))] | first) // {})') || meta='' + [ -n "$meta" ] || meta='{}' + count=$(grep -acE '"type":"(user|assistant)"' "$path") || count=0 + jq -c -n --arg id "$id" --arg mt "${mtime%%.*}" --arg sz "$size" --arg mc "$count" --argjson meta "$meta" \ + '{id: $id, + modified_epoch: ($mt | tonumber), + size_bytes: ($sz | tonumber), + message_count: ($mc | tonumber), + name: ($meta.customTitle // $meta.agentName // null), + summary: ($meta.aiTitle // $meta.summary // $meta.lastPrompt // null), + cwd: ($meta.cwd // null)}' + done | jq -s '.' +"#; + +#[derive(Debug, Deserialize)] +struct RawSession { + id: String, + modified_epoch: i64, + size_bytes: u64, + message_count: u64, + name: Option, + summary: Option, + cwd: Option, +} + +/// List the Claude Code sessions stored inside a project's container, newest +/// first, capped at [`MAX_SESSIONS`]. +/// +/// Returns an empty vec (no error) when the container is stopped or has never +/// been started. +#[tauri::command] +pub async fn list_claude_sessions( + project_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(Vec::new()), + }; + + // `head -N` is the only piece of the script that varies, and it comes from + // a const usize — never from the caller. + let script = SESSIONS_SCRIPT.replace("__MAX__", &MAX_SESSIONS.to_string()); + + let raw = run_script(&container_id, script).await?; + let sessions: Vec = parse_or_default(&raw, "session list"); + + Ok(sessions + .into_iter() + .map(|s| ClaudeSession { + id: s.id, + name: s.name.filter(|v| !v.is_empty()), + summary: s.summary.filter(|v| !v.is_empty()), + last_modified: epoch_to_iso(s.modified_epoch), + size_bytes: s.size_bytes, + message_count: s.message_count, + cwd: s.cwd.filter(|v| !v.is_empty()), + }) + .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 ` (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 { + 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 +// ───────────────────────────────────────────────────────────────────────────── + +/// Emits a single JSON object with one group per capability category. +/// +/// User scope is `~/.claude`. Project scope is `/workspace/.claude` *and* +/// `/workspace//.claude` — Triple-C mounts each project path at +/// `/workspace/`, so a repo's own `.claude` dir lives one level +/// down, not at the workspace root. +/// +/// Frontmatter `name`/`description` are pulled with a small `awk` reader +/// (first `---` block only, first matching key, surrounding quotes stripped); +/// no YAML crate is involved. Files without frontmatter fall back to their +/// path-derived name. +const CAPABILITIES_SCRIPT: &str = r#"exec 2>/dev/null +set -u +USER_BASE="$HOME/.claude" + +# Project-scoped config roots: the workspace root plus each mounted project dir. +proj_bases() { + [ -d /workspace/.claude ] && echo /workspace/.claude + for d in /workspace/*/; do + [ -d "$d/.claude" ] && echo "${d}.claude" + done +} + +# fm — value of a YAML frontmatter key, or nothing. +fm() { + [ -f "$1" ] || return 0 + head -1 "$1" | grep -q '^---[[:space:]]*$' || return 0 + awk -v key="$2" ' + NR == 1 { next } + /^---[[:space:]]*$/ { exit } + { + pfx = key ":" + if (index($0, pfx) == 1) { + v = substr($0, length(pfx) + 1) + sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v) + if (v ~ /^".*"$/) v = substr(v, 2, length(v) - 2) + else if (v ~ /^\047.*\047$/) v = substr(v, 2, length(v) - 2) + print v + exit + } + }' "$1" +} + +emit_item() { + jq -c -n --arg n "$1" --arg d "$2" --arg s "$3" \ + '{name: $n, description: (if $d == "" then null else $d end), scope: $s}' +} + +collect_skills() { + base="$1"; scope="$2" + [ -d "$base/skills" ] || return 0 + for d in "$base"/skills/*/; do + [ -f "$d/SKILL.md" ] || continue + n=$(fm "$d/SKILL.md" name) + [ -n "$n" ] || n=$(basename "$d") + emit_item "$n" "$(fm "$d/SKILL.md" description)" "$scope" + done +} + +collect_md() { + base="$1"; scope="$2"; sub="$3" + [ -d "$base/$sub" ] || return 0 + find "$base/$sub" -name '*.md' -type f | sort | while read -r f; do + rel=${f#"$base/$sub/"}; rel=${rel%.md} + n=$(fm "$f" name) + [ -n "$n" ] || n="$rel" + emit_item "$n" "$(fm "$f" description)" "$scope" + done +} + +# One item per hook event; `count` carries the number of individual handlers so +# the caller can sum them into the group total. +collect_hooks() { + base="$1"; scope="$2" + for sf in "$base/settings.json" "$base/settings.local.json"; do + [ -f "$sf" ] || continue + jq -c --arg s "$scope" --arg f "$(basename "$sf")" ' + (.hooks // {}) | to_entries[] | + ([.value[]? | (.hooks // []) | length] | add // 0) as $n | + {name: .key, + description: ($f + ": " + ($n | tostring) + " handler(s)"), + scope: $s, + count: $n}' "$sf" + done +} + +collect_plugins() { + ip="$USER_BASE/plugins/installed_plugins.json" + [ -f "$ip" ] && jq -c '(.plugins // {}) | to_entries[] | + {name: .key, + description: ((.value[0].version // "") | if . == "" then null else "v" + . end), + scope: (.value[0].scope // "user")}' "$ip" + for cf in "$USER_BASE/settings.json" "$HOME/.claude.json"; do + [ -f "$cf" ] || continue + jq -c '(.enabledPlugins // {}) | to_entries[] | select(.value == true) | + {name: .key, description: "enabled", scope: "user"}' "$cf" + done +} + +collect_mcp() { + if [ -f "$HOME/.claude.json" ]; then + jq -c '(.mcpServers // {}) | to_entries[] | + {name: .key, description: ((.value.command // .value.url // .value.type) // null), + scope: "user"}' "$HOME/.claude.json" + jq -c '(.projects // {}) | to_entries[] | (.value.mcpServers // {}) | to_entries[] | + {name: .key, description: ((.value.command // .value.url // .value.type) // null), + scope: "project"}' "$HOME/.claude.json" + fi + for mf in /workspace/.mcp.json /workspace/*/.mcp.json; do + [ -f "$mf" ] || continue + jq -c '(.mcpServers // {}) | to_entries[] | + {name: .key, description: ((.value.command // .value.url // .value.type) // null), + scope: "project"}' "$mf" + done +} + +group() { jq -s 'unique_by([.scope, .name]) | {count: length, items: .}'; } + +all_skills() { collect_skills "$USER_BASE" user; proj_bases | while read -r b; do collect_skills "$b" project; done; } +all_agents() { collect_md "$USER_BASE" user agents; proj_bases | while read -r b; do collect_md "$b" project agents; done; } +all_commands() { collect_md "$USER_BASE" user commands; proj_bases | while read -r b; do collect_md "$b" project commands; done; } +all_hooks() { collect_hooks "$USER_BASE" user; proj_bases | while read -r b; do collect_hooks "$b" project; done; } + +jq -c -n \ + --argjson skills "$(all_skills | group)" \ + --argjson agents "$(all_agents | group)" \ + --argjson commands "$(all_commands | group)" \ + --argjson hooks "$(all_hooks | jq -s '{count: ([.[].count] | add // 0), items: map(del(.count))}')" \ + --argjson plugins "$(collect_plugins | group)" \ + --argjson mcp "$(collect_mcp | group)" \ + '{skills: $skills, agents: $agents, commands: $commands, + hooks: $hooks, plugins: $plugins, mcp_servers: $mcp}' +"#; + +/// Inventory the Claude Code capabilities installed inside a project's +/// container. A stopped container yields all-zero groups, not an error. +#[tauri::command] +pub async fn list_container_capabilities( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(ContainerCapabilities::default()), + }; + + let raw = run_script(&container_id, CAPABILITIES_SCRIPT).await?; + Ok(parse_or_default(&raw, "container capabilities")) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Scheduler +// ───────────────────────────────────────────────────────────────────────────── + +/// Emits the scheduler's tasks as JSON, mirroring its on-disk schema: +/// `{id, name, prompt, schedule, type, at, created_at, enabled, working_dir}`. +/// +/// `last_run` is not in that schema, so it is derived from the mtime of the +/// newest file in `~/.claude/scheduler/logs//`. +const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null +set -u +TASKS="$HOME/.claude/scheduler/tasks" +LOGS="$HOME/.claude/scheduler/logs" +[ -d "$TASKS" ] || { echo '[]'; exit 0; } +for f in "$TASKS"/*.json; do + [ -f "$f" ] || continue + id=$(jq -r '.id // ""' "$f") || continue + [ -n "$id" ] || id=$(basename "$f" .json) + last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1) + jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{ + id: (if (.id // "") == "" then $fallback_id else .id end), + name: (.name // ""), + prompt: (.prompt // ""), + schedule: (.schedule // ""), + task_type: (.type // "recurring"), + at: (if (.at // "") == "" then null else .at end), + enabled: (.enabled == true), + working_dir: (.working_dir // "/workspace"), + created_at: (.created_at // null), + last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end) + }' "$f" +done | jq -s 'sort_by(.name, .id)' +"#; + +/// Emits the newest notification files as structured JSON. The runner writes +/// them as a fixed plain-text block (`Task:`/`Status:`/`Time:`/`Type:` then a +/// `Summary:` body), which is parsed here; the verbatim text is kept too. +const SCHEDULER_NOTIFICATIONS_SCRIPT: &str = r#"exec 2>/dev/null +set -u +NDIR="$HOME/.claude/scheduler/notifications" +[ -d "$NDIR" ] || { echo '[]'; exit 0; } +TAB=$(printf '\t') +find "$NDIR" -maxdepth 1 -name '*.notify' -type f -printf '%T@\t%p\n' \ + | sort -rn | head -__MAX__ \ + | while IFS="$TAB" read -r mtime path; do + [ -f "$path" ] || continue + base=$(basename "$path" .notify) + jq -c -n --arg tid "${base%%_*}" --arg mt "${mtime%%.*}" --rawfile body "$path" '{ + task_id: $tid, + created_epoch: ($mt | tonumber), + task_name: (($body | capture("Task:[ \t]+(?.*)") | .v | sub("[ \t]+$"; "")) // null), + status: (($body | capture("Status:[ \t]+(?.*)") | .v | sub("[ \t]+$"; "")) // null), + time: (($body | capture("Time:[ \t]+(?.*)") | .v | sub("[ \t]+$"; "")) // null), + task_type: (($body | capture("Type:[ \t]+(?.*)") | .v | sub("[ \t]+$"; "")) // null), + summary: (($body | capture("Summary:\n(?[\\s\\S]*)") | .v) // null), + body: $body + }' + done | jq -s '.' +"#; + +#[derive(Debug, Deserialize)] +struct RawScheduledTask { + id: String, + name: String, + prompt: String, + schedule: String, + task_type: String, + at: Option, + enabled: bool, + working_dir: String, + created_at: Option, + last_run_epoch: Option, +} + +#[derive(Debug, Deserialize)] +struct RawNotification { + task_id: String, + created_epoch: i64, + task_name: Option, + status: Option, + time: Option, + task_type: Option, + summary: Option, + body: String, +} + +/// List the container's scheduled tasks. Stopped container → empty vec. +#[tauri::command] +pub async fn list_scheduled_tasks( + project_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(Vec::new()), + }; + + let raw = run_script(&container_id, SCHEDULER_LIST_SCRIPT).await?; + let tasks: Vec = parse_or_default(&raw, "scheduled tasks"); + + Ok(tasks + .into_iter() + .map(|t| { + // A one-shot task's `at` time is its next (and only) run. Recurring + // cron expressions are left uncomputed rather than guessed at. + let next_run = if t.task_type == "once" && t.enabled { + t.at.clone() + } else { + None + }; + ScheduledTask { + id: t.id, + name: t.name, + prompt: t.prompt, + schedule: t.schedule, + task_type: t.task_type, + at: t.at, + enabled: t.enabled, + working_dir: t.working_dir, + created_at: t.created_at, + last_run: t.last_run_epoch.map(epoch_to_iso), + next_run, + } + }) + .collect()) +} + +/// Tail the most recent log for one task, via the scheduler's own `logs` +/// subcommand. Stopped container → empty string. +#[tauri::command] +pub async fn get_scheduled_task_log( + project_id: String, + task_id: String, + tail_lines: Option, + state: State<'_, AppState>, +) -> Result { + validate_task_id(&task_id)?; + // Clamped, and an integer by type — cannot carry shell syntax. + let tail = tail_lines.unwrap_or(200).clamp(1, 5000); + + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(String::new()), + }; + + run_scheduler( + &container_id, + vec![ + "logs".to_string(), + "--id".to_string(), + task_id, + "--tail".to_string(), + tail.to_string(), + ], + ) + .await +} + +/// Read the scheduler's pending completion notifications, newest first. +/// Stopped container → empty vec. +#[tauri::command] +pub async fn get_scheduler_notifications( + project_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(Vec::new()), + }; + + let script = + SCHEDULER_NOTIFICATIONS_SCRIPT.replace("__MAX__", &MAX_NOTIFICATIONS.to_string()); + + let raw = run_script(&container_id, script).await?; + let notifications: Vec = parse_or_default(&raw, "scheduler notifications"); + + Ok(notifications + .into_iter() + .map(|n| SchedulerNotification { + task_id: n.task_id, + task_name: n.task_name, + status: n.status, + time: n.time, + task_type: n.task_type, + summary: n.summary.map(|s| s.trim_end().to_string()).filter(|s| !s.is_empty()), + body: n.body, + created_at: epoch_to_iso(n.created_epoch), + }) + .collect()) +} + +// ── Mutating scheduler commands ────────────────────────────────────────────── +// +// These delegate to `triple-c-scheduler`'s own subcommands (which also rebuild +// the crontab) instead of editing its JSON, and each runs as a bare argv vector +// with a validated id. + +/// Enable or disable a task via the scheduler's `enable` / `disable`. +#[tauri::command] +pub async fn set_scheduled_task_enabled( + project_id: String, + task_id: String, + enabled: bool, + state: State<'_, AppState>, +) -> Result { + validate_task_id(&task_id)?; + let container_id = require_running_container(&project_id, &state).await?; + + let subcommand = if enabled { "enable" } else { "disable" }; + let output = run_scheduler( + &container_id, + vec![subcommand.to_string(), "--id".to_string(), task_id], + ) + .await?; + Ok(output.trim().to_string()) +} + +/// Trigger a task immediately via the scheduler's `run`. +/// +/// The run itself invokes Claude Code and can take minutes, so the exec is +/// started **detached**: Docker keeps it alive after this call returns and the +/// UI is not blocked. Progress shows up through `get_scheduled_task_log` / +/// `get_scheduler_notifications`, exactly as for a cron-triggered run. +#[tauri::command] +pub async fn run_scheduled_task_now( + project_id: String, + task_id: String, + state: State<'_, AppState>, +) -> Result { + validate_task_id(&task_id)?; + let container_id = require_running_container(&project_id, &state).await?; + + let docker = get_docker()?; + let exec = docker + .create_exec( + &container_id, + CreateExecOptions { + attach_stdout: Some(false), + attach_stderr: Some(false), + // Argv vector — no shell, so `task_id` is inert as data. + cmd: Some(vec![ + "triple-c-scheduler".to_string(), + "run".to_string(), + "--id".to_string(), + task_id.clone(), + ]), + env: Some(home_env()), + user: Some("claude".to_string()), + working_dir: Some("/workspace".to_string()), + ..Default::default() + }, + ) + .await + .map_err(|e| format!("Failed to create exec: {}", e))?; + + docker + .start_exec( + &exec.id, + Some(StartExecOptions { + detach: true, + ..Default::default() + }), + ) + .await + .map_err(|e| format!("Failed to start task: {}", e))?; + + log::info!( + "Triggered scheduler task {} in project {} (detached exec {})", + task_id, + project_id, + exec.id + ); + Ok(format!("Task {} started.", task_id)) +} + +/// Remove a task via the scheduler's `remove` (which also rebuilds the crontab). +#[tauri::command] +pub async fn remove_scheduled_task( + project_id: String, + task_id: String, + state: State<'_, AppState>, +) -> Result { + validate_task_id(&task_id)?; + let container_id = require_running_container(&project_id, &state).await?; + + let output = run_scheduler( + &container_id, + vec!["remove".to_string(), "--id".to_string(), task_id], + ) + .await?; + Ok(output.trim().to_string()) +} + +/// Clear all pending notifications via the scheduler's `notifications --clear`. +#[tauri::command] +pub async fn clear_scheduler_notifications( + project_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let container_id = require_running_container(&project_id, &state).await?; + run_scheduler( + &container_id, + vec!["notifications".to_string(), "--clear".to_string()], + ) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_id_allowlist_accepts_scheduler_generated_ids() { + assert!(validate_task_id("a1b2c3d4").is_ok()); + assert!(validate_task_id("00000000").is_ok()); + assert!(validate_task_id("task_1-a").is_ok()); + } + + #[test] + fn task_id_allowlist_rejects_injection_and_traversal() { + for bad in [ + "", + "a b", + "a;rm -rf /", + "a$(id)", + "a`id`", + "a|b", + "a&b", + "a>b", + "a'b", + "a\"b", + "a\nb", + "../../etc/passwd", + "a/b", + "a.json", + "-id", + "--id", + &"a".repeat(65), + ] { + assert!( + validate_task_id(bad).is_err(), + "should have rejected {:?}", + bad + ); + } + } + + #[test] + fn session_id_allowlist_accepts_uuids_only() { + assert!(validate_session_id("e13d312d-2f38-4cf6-b0e0-1db60208a74c").is_ok()); + assert!(validate_session_id("zzzz").is_err()); + assert!(validate_session_id("abc; rm -rf /").is_err()); + assert!(validate_session_id("-abc").is_err()); + assert!(validate_session_id("").is_err()); + } + + #[test] + fn parse_or_default_degrades_on_garbage() { + let v: Vec = parse_or_default("not json", "test"); + assert!(v.is_empty()); + let v: Vec = parse_or_default(" ", "test"); + assert!(v.is_empty()); + let caps: ContainerCapabilities = parse_or_default("{}", "test"); + assert_eq!(caps.skills.count, 0); + } + + #[test] + fn epoch_to_iso_is_rfc3339() { + assert!(epoch_to_iso(0).starts_with("1970-01-01T00:00:00")); + } +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 27edfa8..1f5ac05 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod aws_commands; pub mod docker_commands; pub mod file_commands; pub mod help_commands; +pub mod inspect_commands; pub mod install_helper_commands; pub mod project_commands; pub mod settings_commands; diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index 2df434f..2e05bd8 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -17,11 +17,11 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option< .map(|b| b.auth_method == BedrockAuthMethod::Profile) .unwrap_or(false); + let permission_args = project.effective_permission_mode().cli_args(); + if !is_bedrock_profile { let mut cmd = vec!["claude".to_string()]; - if project.full_permissions { - cmd.push("--dangerously-skip-permissions".to_string()); - } + cmd.extend(permission_args); if let Some(name) = session_name { if !name.is_empty() { cmd.push("-n".to_string()); @@ -42,11 +42,13 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option< .filter(|n| !n.is_empty()) .map(|n| format!(" -n '{}'", n.replace('\'', "'\\''"))) .unwrap_or_default(); - let claude_cmd = if project.full_permissions { - format!("exec claude --dangerously-skip-permissions{}", name_flag) - } else { - format!("exec claude{}", name_flag) - }; + // The args are interpolated into a shell script string, so single-quote + // each one (same escaping style as name_flag above). + let permission_flags: String = permission_args + .iter() + .map(|a| format!(" '{}'", a.replace('\'', "'\\''"))) + .collect(); + let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag); let script = format!( r#" diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index e18f0a5..9f33c01 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -177,7 +177,7 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was // removed, but the name stays blocked so users cannot hand-set it. - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; + let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"]; let mut parts: Vec = Vec::new(); for env_var in custom_env_vars { let key = env_var.key.trim(); @@ -720,7 +720,7 @@ pub async fn create_container( let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was // removed, but the name stays blocked so users cannot hand-set it. - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; + let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"]; for env_var in &merged_env { let key = env_var.key.trim(); if key.is_empty() { @@ -750,6 +750,13 @@ pub async fn create_container( env_vars.push("MISSION_CONTROL_ENABLED=1".to_string()); } + // Permission mode — read by triple-c-task-runner for scheduled (headless) + // Claude Code runs. Interactive terminals get the flags directly instead. + env_vars.push(format!( + "TRIPLE_C_PERMISSION_MODE={}", + project.effective_permission_mode().as_env_value() + )); + // Claude instructions (global + per-project, plus port mapping info + scheduler docs) let combined_instructions = build_claude_instructions( global_claude_instructions, @@ -930,6 +937,8 @@ pub async fn create_container( labels.insert("triple-c.image".to_string(), image_name.to_string()); labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string()); labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string()); + labels.insert("triple-c.permission-mode".to_string(), + project.effective_permission_mode().as_env_value().to_string()); labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone()); labels.insert("triple-c.claude-code-settings-fingerprint".to_string(), compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled)); @@ -1398,6 +1407,22 @@ pub async fn container_needs_recreation( return Ok(true); } + // ── Permission mode ──────────────────────────────────────────────────── + // The mode is injected as the TRIPLE_C_PERMISSION_MODE env var, and + // container env can only change by recreating the container. A missing + // label means the container predates this feature and therefore has no + // such env var, so it must be recreated too (empty != any valid mode). + let expected_permission_mode = project.effective_permission_mode().as_env_value(); + let container_permission_mode = get_label("triple-c.permission-mode").unwrap_or_default(); + if container_permission_mode != expected_permission_mode { + log::info!( + "Permission mode mismatch (container={:?}, expected={:?})", + container_permission_mode, + expected_permission_mode + ); + return Ok(true); + } + // ── Claude instructions (label-based fingerprint) ───────────────────── let expected_instructions = build_claude_instructions( global_claude_instructions, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index c4ef25f..e82f0af 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -200,6 +200,17 @@ pub fn run() { commands::stt_commands::build_stt_image, commands::stt_commands::pull_stt_image, commands::stt_commands::transcribe_audio, + // 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::get_scheduled_task_log, + commands::inspect_commands::set_scheduled_task_enabled, + commands::inspect_commands::run_scheduled_task_now, + commands::inspect_commands::remove_scheduled_task, + commands::inspect_commands::get_scheduler_notifications, + commands::inspect_commands::clear_scheduler_notifications, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index 209cb14..b47f1a1 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -30,6 +30,50 @@ fn default_full_permissions() -> bool { true } +/// How much autonomy Claude Code is granted inside the container. +/// +/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is +/// the single definition of that mapping and must be used by every call site. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum PermissionMode { + /// Read-only planning mode. + Plan, + /// Claude Code's own default behavior (prompts for permission). + #[default] + Default, + /// Auto-accept file edits, prompt for everything else. + AcceptEdits, + /// Skip all permission prompts. + Bypass, +} + +impl PermissionMode { + /// The CLI flags this mode adds to a `claude` invocation. + /// Defined once here so every call site stays in sync. + pub fn cli_args(&self) -> Vec { + match self { + PermissionMode::Plan => vec!["--permission-mode".to_string(), "plan".to_string()], + PermissionMode::Default => Vec::new(), + PermissionMode::AcceptEdits => { + vec!["--permission-mode".to_string(), "acceptEdits".to_string()] + } + PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()], + } + } + + /// The wire value used for the `TRIPLE_C_PERMISSION_MODE` container env var. + /// Matches the serde `camelCase` representation. + pub fn as_env_value(&self) -> &'static str { + match self { + PermissionMode::Plan => "plan", + PermissionMode::Default => "default", + PermissionMode::AcceptEdits => "acceptEdits", + PermissionMode::Bypass => "bypass", + } + } +} + /// Settings for Claude Code CLI behavior inside the container. /// These map to Claude Code env vars and ~/.claude/settings.json entries. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] @@ -78,8 +122,16 @@ pub struct Project { pub sandbox_mode_enabled: bool, #[serde(default)] pub mission_control_enabled: bool, + /// Legacy binary permission flag. Superseded by `permission_mode`, but kept + /// because it is the value already stored in users' `projects.json`; it is + /// the fallback in `effective_permission_mode()` so old projects keep + /// behaving identically without a data migration. #[serde(default = "default_full_permissions")] pub full_permissions: bool, + /// Per-project permission mode. `None` means "not set yet" → fall back to + /// the legacy `full_permissions` flag. + #[serde(default)] + pub permission_mode: Option, pub ssh_key_path: Option, #[serde(skip_serializing, default)] pub git_token: Option, @@ -211,6 +263,7 @@ impl Project { sandbox_mode_enabled: false, mission_control_enabled: false, full_permissions: false, + permission_mode: None, ssh_key_path: None, git_token: None, git_user_name: None, @@ -225,6 +278,17 @@ impl Project { } } + /// The permission mode to actually use for this project. + /// Falls back to the legacy `full_permissions` boolean when the newer + /// `permission_mode` field has never been set. + pub fn effective_permission_mode(&self) -> PermissionMode { + self.permission_mode.unwrap_or(if self.full_permissions { + PermissionMode::Bypass + } else { + PermissionMode::Default + }) + } + pub fn container_name(&self) -> String { format!("triple-c-{}", self.id) } diff --git a/app/src-tauri/src/web_terminal/ws_handler.rs b/app/src-tauri/src/web_terminal/ws_handler.rs index 3a49b18..bcafb11 100644 --- a/app/src-tauri/src/web_terminal/ws_handler.rs +++ b/app/src-tauri/src/web_terminal/ws_handler.rs @@ -205,11 +205,11 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin .map(|b| b.auth_method == BedrockAuthMethod::Profile) .unwrap_or(false); + let permission_args = project.effective_permission_mode().cli_args(); + if !is_bedrock_profile { let mut cmd = vec!["claude".to_string()]; - if project.full_permissions { - cmd.push("--dangerously-skip-permissions".to_string()); - } + cmd.extend(permission_args); return cmd; } @@ -218,11 +218,13 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin settings_store.get().global_aws.aws_profile.as_deref(), ); - let claude_cmd = if project.full_permissions { - "exec claude --dangerously-skip-permissions" - } else { - "exec claude" - }; + // The args are interpolated into a shell script string below, so + // single-quote each one. + let permission_flags: String = permission_args + .iter() + .map(|a| format!(" '{}'", a.replace('\'', "'\\''"))) + .collect(); + let claude_cmd = format!("exec claude{}", permission_flags); let script = format!( r#" diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index abb01ce..c44218d 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, SchedulerNotification } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -107,3 +107,29 @@ export const transcribeAudio = (audioData: number[]) => export const detectInstallOptions = () => invoke("detect_install_options"); export const runDockerInstall = () => invoke("run_docker_install"); + +// Container introspection — sessions +export const listClaudeSessions = (projectId: string) => + invoke("list_claude_sessions", { projectId }); +export const resumeSessionCommand = (projectId: string, sessionId: string) => + invoke("resume_session_command", { projectId, sessionId }); + +// Container introspection — capabilities +export const listContainerCapabilities = (projectId: string) => + invoke("list_container_capabilities", { projectId }); + +// Container introspection — scheduler +export const listScheduledTasks = (projectId: string) => + invoke("list_scheduled_tasks", { projectId }); +export const getScheduledTaskLog = (projectId: string, taskId: string, tailLines?: number) => + invoke("get_scheduled_task_log", { projectId, taskId, tailLines }); +export const setScheduledTaskEnabled = (projectId: string, taskId: string, enabled: boolean) => + invoke("set_scheduled_task_enabled", { projectId, taskId, enabled }); +export const runScheduledTaskNow = (projectId: string, taskId: string) => + invoke("run_scheduled_task_now", { projectId, taskId }); +export const removeScheduledTask = (projectId: string, taskId: string) => + invoke("remove_scheduled_task", { projectId, taskId }); +export const getSchedulerNotifications = (projectId: string) => + invoke("get_scheduler_notifications", { projectId }); +export const clearSchedulerNotifications = (projectId: string) => + invoke("clear_scheduler_notifications", { projectId }); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index c4773cb..0a0b4eb 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -27,7 +27,11 @@ export interface Project { allow_docker_access: boolean; sandbox_mode_enabled: boolean; mission_control_enabled: boolean; + /** Legacy binary permission flag; superseded by `permission_mode`, kept for + * existing projects.json data. */ full_permissions: boolean; + /** null = not set → falls back to `full_permissions` (true → "bypass"). */ + permission_mode: PermissionMode | null; ssh_key_path: string | null; git_token: string | null; git_user_name: string | null; @@ -50,6 +54,9 @@ export type ProjectStatus = export type Backend = "anthropic" | "bedrock" | "ollama" | "open_ai_compatible"; +/** Mirrors Rust `PermissionMode` (serde camelCase). */ +export type PermissionMode = "plan" | "default" | "acceptEdits" | "bypass"; + export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token"; export interface BedrockConfig { @@ -220,3 +227,69 @@ export interface InstallOptions { manual_steps: string[]; post_install_notes: string[]; } + +// Container introspection (read-only) — see src-tauri/src/commands/inspect_commands.rs + +/** A Claude Code session transcript stored on the container's config volume. */ +export interface ClaudeSession { + id: string; + /** User-set display name (`claude -n `), if any. */ + name: string | null; + /** Claude's auto-generated title, else the session's last prompt. */ + summary: string | null; + last_modified: string; + size_bytes: number; + message_count: number; + cwd: string | null; +} + +export type CapabilityScope = "user" | "project"; + +export interface CapabilityItem { + name: string; + description: string | null; + scope: CapabilityScope; +} + +export interface CapabilityGroup { + count: number; + items: CapabilityItem[]; +} + +export interface ContainerCapabilities { + skills: CapabilityGroup; + agents: CapabilityGroup; + commands: CapabilityGroup; + /** One item per hook event; `count` totals the individual handlers. */ + hooks: CapabilityGroup; + plugins: CapabilityGroup; + mcp_servers: CapabilityGroup; +} + +export interface ScheduledTask { + id: string; + name: string; + prompt: string; + /** Cron expression (one-shot tasks are stored as cron too — see `at`). */ + schedule: string; + task_type: "recurring" | "once"; + /** Original `--at` value (`"YYYY-MM-DD HH:MM"`) for one-shot tasks. */ + at: string | null; + enabled: boolean; + working_dir: string; + created_at: string | null; + last_run: string | null; + /** Known only for enabled one-shot tasks; cron is not evaluated. */ + next_run: string | null; +} + +export interface SchedulerNotification { + task_id: string; + task_name: string | null; + status: string | null; + time: string | null; + task_type: string | null; + summary: string | null; + body: string; + created_at: string; +} diff --git a/container/entrypoint.sh b/container/entrypoint.sh index a961beb..4ad5e4f 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -255,7 +255,7 @@ ENV_FILE="$SCHEDULER_DIR/.env" : > "$ENV_FILE" env | while IFS='=' read -r key value; do case "$key" in - ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|PATH|HOME|LANG|TZ|COLORTERM) + ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM) # Escape single quotes in value and write as KEY='VALUE' escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g") printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE" diff --git a/container/triple-c-task-runner b/container/triple-c-task-runner index 5b59d29..e7bf701 100644 --- a/container/triple-c-task-runner +++ b/container/triple-c-task-runner @@ -47,6 +47,21 @@ WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE") TASK_NAME=$(jq -r '.name' "$TASK_FILE") TASK_TYPE=$(jq -r '.type' "$TASK_FILE") +# ── Resolve permission mode ───────────────────────────────────────────────── +# TRIPLE_C_PERMISSION_MODE is injected into the container by Triple-C from the +# project's permission setting. Keep this mapping in sync with +# PermissionMode::cli_args() in app/src-tauri/src/models/project.rs. +# NOTE: headless `claude -p` runs cannot answer a permission prompt, so any +# mode other than "bypass" means the task may stop early when Claude Code asks +# for permission. Unset or unrecognized values pass no flag (Claude's default). +PERMISSION_ARGS=() +case "${TRIPLE_C_PERMISSION_MODE:-}" in + plan) PERMISSION_ARGS=(--permission-mode plan) ;; + acceptEdits) PERMISSION_ARGS=(--permission-mode acceptEdits) ;; + bypass) PERMISSION_ARGS=(--dangerously-skip-permissions) ;; + *) PERMISSION_ARGS=() ;; +esac + # ── Prepare log directory ─────────────────────────────────────────────────── TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}" mkdir -p "$TASK_LOG_DIR" @@ -60,13 +75,15 @@ LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log" echo "=== Started: $(date) ===" echo "=== Working dir: $WORKING_DIR ===" echo "=== Prompt: $PROMPT ===" + echo "=== Permission mode: ${TRIPLE_C_PERMISSION_MODE:-default} ===" echo "" } > "$LOG_FILE" EXIT_CODE=0 if [ -d "$WORKING_DIR" ]; then cd "$WORKING_DIR" - claude -p "$PROMPT" --dangerously-skip-permissions >> "$LOG_FILE" 2>&1 || EXIT_CODE=$? + # ${arr[@]+"${arr[@]}"} keeps an empty array safe under `set -u` + claude -p "$PROMPT" ${PERMISSION_ARGS[@]+"${PERMISSION_ARGS[@]}"} >> "$LOG_FILE" 2>&1 || EXIT_CODE=$? else echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE" EXIT_CODE=1