Drop MCP management; add permission modes, Project Home, Auth Bridge and shared auth #13
@@ -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 <name>`), if the session has one.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single installed capability (skill, agent, command, hook event, …).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CapabilityItem {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// `"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<CapabilityItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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/<id>.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<String>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub working_dir: String,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
/// Derived from the newest file in `~/.claude/scheduler/logs/<id>/`; the
|
||||||
|
/// scheduler does not record this in the task JSON itself.
|
||||||
|
pub last_run: Option<String>,
|
||||||
|
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron
|
||||||
|
/// expressions are not evaluated here.
|
||||||
|
pub next_run: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// `"SUCCESS"` or `"FAILED (exit code N)"`.
|
||||||
|
pub status: Option<String>,
|
||||||
|
/// The runner's own human-readable timestamp line.
|
||||||
|
pub time: Option<String>,
|
||||||
|
pub task_type: Option<String>,
|
||||||
|
/// Tail of the run's log that the runner captured.
|
||||||
|
pub summary: Option<String>,
|
||||||
|
/// 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<Option<String>, 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<String, String> {
|
||||||
|
running_container_for(project_id, state).await?.ok_or_else(|| {
|
||||||
|
"Container is not running — start the project first.".to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_env() -> Vec<String> {
|
||||||
|
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<String>) -> Result<String, String> {
|
||||||
|
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<T: Default + serde::de::DeserializeOwned>(raw: &str, what: &str) -> T {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return T::default();
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<T>(trimmed) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to parse {} JSON from container ({}): {}",
|
||||||
|
what,
|
||||||
|
e,
|
||||||
|
trimmed.chars().take(300).collect::<String>()
|
||||||
|
);
|
||||||
|
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 <args…>` 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<String>) -> Result<String, String> {
|
||||||
|
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/<cwd-with-slashes-turned-into-dashes>/<uuid>.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<String>,
|
||||||
|
summary: Option<String>,
|
||||||
|
cwd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<ClaudeSession>, 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<RawSession> = 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 <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
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Emits a single JSON object with one group per capability category.
|
||||||
|
///
|
||||||
|
/// User scope is `~/.claude`. Project scope is `/workspace/.claude` *and*
|
||||||
|
/// `/workspace/<mount>/.claude` — Triple-C mounts each project path at
|
||||||
|
/// `/workspace/<mount_name>`, 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 <file> <key> — 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<ContainerCapabilities, String> {
|
||||||
|
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/<id>/`.
|
||||||
|
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>.*)") | .v | sub("[ \t]+$"; "")) // null),
|
||||||
|
status: (($body | capture("Status:[ \t]+(?<v>.*)") | .v | sub("[ \t]+$"; "")) // null),
|
||||||
|
time: (($body | capture("Time:[ \t]+(?<v>.*)") | .v | sub("[ \t]+$"; "")) // null),
|
||||||
|
task_type: (($body | capture("Type:[ \t]+(?<v>.*)") | .v | sub("[ \t]+$"; "")) // null),
|
||||||
|
summary: (($body | capture("Summary:\n(?<v>[\\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<String>,
|
||||||
|
enabled: bool,
|
||||||
|
working_dir: String,
|
||||||
|
created_at: Option<String>,
|
||||||
|
last_run_epoch: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RawNotification {
|
||||||
|
task_id: String,
|
||||||
|
created_epoch: i64,
|
||||||
|
task_name: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
time: Option<String>,
|
||||||
|
task_type: Option<String>,
|
||||||
|
summary: Option<String>,
|
||||||
|
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<Vec<ScheduledTask>, 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<RawScheduledTask> = 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<u32>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
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<Vec<SchedulerNotification>, 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<RawNotification> = 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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<RawSession> = parse_or_default("not json", "test");
|
||||||
|
assert!(v.is_empty());
|
||||||
|
let v: Vec<RawSession> = 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod aws_commands;
|
|||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
pub mod file_commands;
|
pub mod file_commands;
|
||||||
pub mod help_commands;
|
pub mod help_commands;
|
||||||
|
pub mod inspect_commands;
|
||||||
pub mod install_helper_commands;
|
pub mod install_helper_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<
|
|||||||
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
if !is_bedrock_profile {
|
if !is_bedrock_profile {
|
||||||
let mut cmd = vec!["claude".to_string()];
|
let mut cmd = vec!["claude".to_string()];
|
||||||
if project.full_permissions {
|
cmd.extend(permission_args);
|
||||||
cmd.push("--dangerously-skip-permissions".to_string());
|
|
||||||
}
|
|
||||||
if let Some(name) = session_name {
|
if let Some(name) = session_name {
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
cmd.push("-n".to_string());
|
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())
|
.filter(|n| !n.is_empty())
|
||||||
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let claude_cmd = if project.full_permissions {
|
// The args are interpolated into a shell script string, so single-quote
|
||||||
format!("exec claude --dangerously-skip-permissions{}", name_flag)
|
// each one (same escaping style as name_flag above).
|
||||||
} else {
|
let permission_flags: String = permission_args
|
||||||
format!("exec claude{}", name_flag)
|
.iter()
|
||||||
};
|
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
||||||
|
.collect();
|
||||||
|
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
|||||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||||
// MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was
|
// 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.
|
// 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<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
for env_var in custom_env_vars {
|
for env_var in custom_env_vars {
|
||||||
let key = env_var.key.trim();
|
let key = env_var.key.trim();
|
||||||
@@ -720,7 +720,7 @@ pub async fn create_container(
|
|||||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||||
// MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was
|
// 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.
|
// 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 {
|
for env_var in &merged_env {
|
||||||
let key = env_var.key.trim();
|
let key = env_var.key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
@@ -750,6 +750,13 @@ pub async fn create_container(
|
|||||||
env_vars.push("MISSION_CONTROL_ENABLED=1".to_string());
|
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)
|
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
||||||
let combined_instructions = build_claude_instructions(
|
let combined_instructions = build_claude_instructions(
|
||||||
global_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.image".to_string(), image_name.to_string());
|
||||||
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").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.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.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
|
||||||
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
|
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
|
||||||
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled));
|
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);
|
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) ─────────────────────
|
// ── Claude instructions (label-based fingerprint) ─────────────────────
|
||||||
let expected_instructions = build_claude_instructions(
|
let expected_instructions = build_claude_instructions(
|
||||||
global_claude_instructions,
|
global_claude_instructions,
|
||||||
|
|||||||
@@ -200,6 +200,17 @@ pub fn run() {
|
|||||||
commands::stt_commands::build_stt_image,
|
commands::stt_commands::build_stt_image,
|
||||||
commands::stt_commands::pull_stt_image,
|
commands::stt_commands::pull_stt_image,
|
||||||
commands::stt_commands::transcribe_audio,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -30,6 +30,50 @@ fn default_full_permissions() -> bool {
|
|||||||
true
|
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<String> {
|
||||||
|
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.
|
/// Settings for Claude Code CLI behavior inside the container.
|
||||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
@@ -78,8 +122,16 @@ pub struct Project {
|
|||||||
pub sandbox_mode_enabled: bool,
|
pub sandbox_mode_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mission_control_enabled: bool,
|
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")]
|
#[serde(default = "default_full_permissions")]
|
||||||
pub full_permissions: bool,
|
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<PermissionMode>,
|
||||||
pub ssh_key_path: Option<String>,
|
pub ssh_key_path: Option<String>,
|
||||||
#[serde(skip_serializing, default)]
|
#[serde(skip_serializing, default)]
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
@@ -211,6 +263,7 @@ impl Project {
|
|||||||
sandbox_mode_enabled: false,
|
sandbox_mode_enabled: false,
|
||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
|
permission_mode: None,
|
||||||
ssh_key_path: None,
|
ssh_key_path: None,
|
||||||
git_token: None,
|
git_token: None,
|
||||||
git_user_name: 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 {
|
pub fn container_name(&self) -> String {
|
||||||
format!("triple-c-{}", self.id)
|
format!("triple-c-{}", self.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,11 +205,11 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
|
|||||||
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let permission_args = project.effective_permission_mode().cli_args();
|
||||||
|
|
||||||
if !is_bedrock_profile {
|
if !is_bedrock_profile {
|
||||||
let mut cmd = vec!["claude".to_string()];
|
let mut cmd = vec!["claude".to_string()];
|
||||||
if project.full_permissions {
|
cmd.extend(permission_args);
|
||||||
cmd.push("--dangerously-skip-permissions".to_string());
|
|
||||||
}
|
|
||||||
return cmd;
|
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(),
|
settings_store.get().global_aws.aws_profile.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let claude_cmd = if project.full_permissions {
|
// The args are interpolated into a shell script string below, so
|
||||||
"exec claude --dangerously-skip-permissions"
|
// single-quote each one.
|
||||||
} else {
|
let permission_flags: String = permission_args
|
||||||
"exec claude"
|
.iter()
|
||||||
};
|
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
|
||||||
|
.collect();
|
||||||
|
let claude_cmd = format!("exec claude{}", permission_flags);
|
||||||
|
|
||||||
let script = format!(
|
let script = format!(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -107,3 +107,29 @@ export const transcribeAudio = (audioData: number[]) =>
|
|||||||
export const detectInstallOptions = () =>
|
export const detectInstallOptions = () =>
|
||||||
invoke<InstallOptions>("detect_install_options");
|
invoke<InstallOptions>("detect_install_options");
|
||||||
export const runDockerInstall = () => invoke<void>("run_docker_install");
|
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) =>
|
||||||
|
invoke<ContainerCapabilities>("list_container_capabilities", { projectId });
|
||||||
|
|
||||||
|
// Container introspection — scheduler
|
||||||
|
export const listScheduledTasks = (projectId: string) =>
|
||||||
|
invoke<ScheduledTask[]>("list_scheduled_tasks", { projectId });
|
||||||
|
export const getScheduledTaskLog = (projectId: string, taskId: string, tailLines?: number) =>
|
||||||
|
invoke<string>("get_scheduled_task_log", { projectId, taskId, tailLines });
|
||||||
|
export const setScheduledTaskEnabled = (projectId: string, taskId: string, enabled: boolean) =>
|
||||||
|
invoke<string>("set_scheduled_task_enabled", { projectId, taskId, enabled });
|
||||||
|
export const runScheduledTaskNow = (projectId: string, taskId: string) =>
|
||||||
|
invoke<string>("run_scheduled_task_now", { projectId, taskId });
|
||||||
|
export const removeScheduledTask = (projectId: string, taskId: string) =>
|
||||||
|
invoke<string>("remove_scheduled_task", { projectId, taskId });
|
||||||
|
export const getSchedulerNotifications = (projectId: string) =>
|
||||||
|
invoke<SchedulerNotification[]>("get_scheduler_notifications", { projectId });
|
||||||
|
export const clearSchedulerNotifications = (projectId: string) =>
|
||||||
|
invoke<void>("clear_scheduler_notifications", { projectId });
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ export interface Project {
|
|||||||
allow_docker_access: boolean;
|
allow_docker_access: boolean;
|
||||||
sandbox_mode_enabled: boolean;
|
sandbox_mode_enabled: boolean;
|
||||||
mission_control_enabled: boolean;
|
mission_control_enabled: boolean;
|
||||||
|
/** Legacy binary permission flag; superseded by `permission_mode`, kept for
|
||||||
|
* existing projects.json data. */
|
||||||
full_permissions: boolean;
|
full_permissions: boolean;
|
||||||
|
/** null = not set → falls back to `full_permissions` (true → "bypass"). */
|
||||||
|
permission_mode: PermissionMode | null;
|
||||||
ssh_key_path: string | null;
|
ssh_key_path: string | null;
|
||||||
git_token: string | null;
|
git_token: string | null;
|
||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
@@ -50,6 +54,9 @@ export type ProjectStatus =
|
|||||||
|
|
||||||
export type Backend = "anthropic" | "bedrock" | "ollama" | "open_ai_compatible";
|
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 type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||||
|
|
||||||
export interface BedrockConfig {
|
export interface BedrockConfig {
|
||||||
@@ -220,3 +227,69 @@ export interface InstallOptions {
|
|||||||
manual_steps: string[];
|
manual_steps: string[];
|
||||||
post_install_notes: 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 <name>`), 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ ENV_FILE="$SCHEDULER_DIR/.env"
|
|||||||
: > "$ENV_FILE"
|
: > "$ENV_FILE"
|
||||||
env | while IFS='=' read -r key value; do
|
env | while IFS='=' read -r key value; do
|
||||||
case "$key" in
|
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'
|
# Escape single quotes in value and write as KEY='VALUE'
|
||||||
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
|
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
|
||||||
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
|
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE")
|
|||||||
TASK_NAME=$(jq -r '.name' "$TASK_FILE")
|
TASK_NAME=$(jq -r '.name' "$TASK_FILE")
|
||||||
TASK_TYPE=$(jq -r '.type' "$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 ───────────────────────────────────────────────────
|
# ── Prepare log directory ───────────────────────────────────────────────────
|
||||||
TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}"
|
TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}"
|
||||||
mkdir -p "$TASK_LOG_DIR"
|
mkdir -p "$TASK_LOG_DIR"
|
||||||
@@ -60,13 +75,15 @@ LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
|
|||||||
echo "=== Started: $(date) ==="
|
echo "=== Started: $(date) ==="
|
||||||
echo "=== Working dir: $WORKING_DIR ==="
|
echo "=== Working dir: $WORKING_DIR ==="
|
||||||
echo "=== Prompt: $PROMPT ==="
|
echo "=== Prompt: $PROMPT ==="
|
||||||
|
echo "=== Permission mode: ${TRIPLE_C_PERMISSION_MODE:-default} ==="
|
||||||
echo ""
|
echo ""
|
||||||
} > "$LOG_FILE"
|
} > "$LOG_FILE"
|
||||||
|
|
||||||
EXIT_CODE=0
|
EXIT_CODE=0
|
||||||
if [ -d "$WORKING_DIR" ]; then
|
if [ -d "$WORKING_DIR" ]; then
|
||||||
cd "$WORKING_DIR"
|
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
|
else
|
||||||
echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE"
|
echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE"
|
||||||
EXIT_CODE=1
|
EXIT_CODE=1
|
||||||
|
|||||||
Reference in New Issue
Block a user