From 657c61939f41d0b17825982b88d3944fd9f0dc0f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 10:04:25 -0700 Subject: [PATCH 01/14] Remove MCP tab and per-project MCP UI from frontend Claude Code manages MCP natively now (`claude mcp add/list/remove`, `.mcp.json`, `/mcp`), so Triple-C's own MCP server library is redundant. Deletes components/mcp/, hooks/useMcpServers.ts, the MCP sidebar tab and rail icon, the per-project enable checkboxes on ProjectCard, the mcpServers slice of the Zustand store, the four IPC wrappers, and the McpServer/McpTransportType types. Rust backend is untouched in this commit; the commands simply become unreachable. Backend removal and the legacy container/network cleanup follow separately. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/App.tsx | 3 - app/src/components/layout/Sidebar.test.tsx | 3 - app/src/components/layout/Sidebar.tsx | 26 +- app/src/components/mcp/McpPanel.tsx | 79 ----- app/src/components/mcp/McpServerCard.tsx | 331 ------------------ .../components/projects/ProjectCard.test.tsx | 11 - app/src/components/projects/ProjectCard.tsx | 49 +-- app/src/hooks/useMcpServers.ts | 55 --- app/src/lib/tauri-commands.ts | 11 +- app/src/lib/types.ts | 18 - app/src/store/appState.ts | 26 +- 11 files changed, 8 insertions(+), 604 deletions(-) delete mode 100644 app/src/components/mcp/McpPanel.tsx delete mode 100644 app/src/components/mcp/McpServerCard.tsx delete mode 100644 app/src/hooks/useMcpServers.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 79d00ca..64935bf 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -8,7 +8,6 @@ import DockerInstallDialog from "./components/DockerInstallDialog"; 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"; @@ -19,7 +18,6 @@ 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 })) @@ -56,7 +54,6 @@ export default function App() { } }); refresh(); - refreshMcp(); // Update detection loadVersion(); diff --git a/app/src/components/layout/Sidebar.test.tsx b/app/src/components/layout/Sidebar.test.tsx index be5c22c..d1072dd 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(() => { diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index 2c3bbfc..3e2b719 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", @@ -108,9 +95,6 @@ export default function Sidebar() { - @@ -128,13 +112,7 @@ export default function Sidebar() { {/* Content */}
- {sidebarView === "projects" ? ( - - ) : sidebarView === "mcp" ? ( - - ) : ( - - )} + {sidebarView === "projects" ? : }
); 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/ProjectCard.test.tsx b/app/src/components/projects/ProjectCard.test.tsx index 3d2e468..c8e52b0 100644 --- a/app/src/components/projects/ProjectCard.test.tsx +++ b/app/src/components/projects/ProjectCard.test.tsx @@ -31,16 +31,6 @@ vi.mock("../../hooks/useTerminal", () => ({ }), })); -vi.mock("../../hooks/useMcpServers", () => ({ - useMcpServers: () => ({ - mcpServers: [], - refresh: vi.fn(), - add: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - }), -})); - let mockSelectedProjectId: string | null = null; vi.mock("../../store/appState", () => ({ useAppState: vi.fn((selector) => @@ -67,7 +57,6 @@ const mockProject: Project = { custom_env_vars: [], port_mappings: [], claude_instructions: null, - enabled_mcp_servers: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index af83630..ff3221e 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -4,7 +4,6 @@ import * as commands from "../../lib/tauri-commands"; import { listen } from "@tauri-apps/api/event"; import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types"; import { useProjects } from "../../hooks/useProjects"; -import { useMcpServers } from "../../hooks/useMcpServers"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; import EnvVarsModal from "./EnvVarsModal"; @@ -24,7 +23,6 @@ export default function ProjectCard({ project }: Props) { const selectedProjectId = useAppState(s => s.selectedProjectId); const setSelectedProject = useAppState(s => s.setSelectedProject); const { start, stop, rebuild, remove, update } = useProjects(); - const { mcpServers } = useMcpServers(); const { open: openTerminal } = useTerminal(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -196,7 +194,7 @@ export default function ProjectCard({ project }: Props) { setError(null); const bytes = await commands.downloadContainerBackup(project.id, hostPath); const mb = (bytes / (1024 * 1024)).toFixed(1); - const msg = `Backup saved (${mb} MB). Note: includes MCP/config — may contain MCP API keys. Keep it private.`; + const msg = `Backup saved (${mb} MB). Note: includes Claude config — may contain API keys. Keep it private.`; setProgressMsg(msg); // Auto-clear so the transient confirmation doesn't linger in the card // status; guard against clobbering a newer message (e.g. a later op). @@ -539,7 +537,7 @@ export default function ProjectCard({ project }: Props) { onClick={handleBackup} disabled={loading || backingUp} label={backingUp ? "Backing up…" : "Backup"} - title="Downloads /workspace plus a sanitized home config (MCP servers, settings, skills). OAuth tokens are excluded, but MCP server configs may embed their own API keys/tokens — keep the archive private." + title="Downloads /workspace plus a sanitized home config (Claude settings, skills, MCP config). OAuth tokens are excluded, but other config may embed its own API keys/tokens — keep the archive private." /> ) : ( @@ -864,49 +862,6 @@ export default function ProjectCard({ project }: Props) { - {/* MCP Servers */} - {mcpServers.length > 0 && ( -
- -
- {mcpServers.map((server) => { - const enabled = project.enabled_mcp_servers.includes(server.id); - const isDocker = !!server.docker_image; - return ( - - ); - })} -
- {mcpServers.some((s) => s.docker_image && s.transport_type === "stdio" && project.enabled_mcp_servers.includes(s.id)) && ( -

- Docker access will be auto-enabled for stdio+Docker MCP servers. -

- )} -
- )} - {/* Bedrock config */} {project.backend === "bedrock" && (() => { const bc = project.bedrock_config ?? defaultBedrockConfig; diff --git a/app/src/hooks/useMcpServers.ts b/app/src/hooks/useMcpServers.ts deleted file mode 100644 index 8ce32f8..0000000 --- a/app/src/hooks/useMcpServers.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useCallback } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { useAppState } from "../store/appState"; -import * as commands from "../lib/tauri-commands"; -import type { McpServer } from "../lib/types"; - -export function useMcpServers() { - const { - mcpServers, - setMcpServers, - updateMcpServerInList, - removeMcpServerFromList, - } = useAppState( - useShallow(s => ({ - mcpServers: s.mcpServers, - setMcpServers: s.setMcpServers, - updateMcpServerInList: s.updateMcpServerInList, - removeMcpServerFromList: s.removeMcpServerFromList, - })) - ); - - const refresh = useCallback(async () => { - const list = await commands.listMcpServers(); - setMcpServers(list); - }, [setMcpServers]); - - const add = useCallback( - async (name: string) => { - const server = await commands.addMcpServer(name); - const list = await commands.listMcpServers(); - setMcpServers(list); - return server; - }, - [setMcpServers], - ); - - const update = useCallback( - async (server: McpServer) => { - const updated = await commands.updateMcpServer(server); - updateMcpServerInList(updated); - return updated; - }, - [updateMcpServerInList], - ); - - const remove = useCallback( - async (id: string) => { - await commands.removeMcpServer(id); - removeMcpServerFromList(id); - }, - [removeMcpServerFromList], - ); - - return { mcpServers, refresh, add, update, remove }; -} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index c894db2..abb01ce 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo, SttStatus, InstallOptions } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -64,15 +64,6 @@ export const sendAudioData = (sessionId: string, data: number[]) => export const stopAudioBridge = (sessionId: string) => invoke("stop_audio_bridge", { sessionId }); -// MCP Servers -export const listMcpServers = () => invoke("list_mcp_servers"); -export const addMcpServer = (name: string) => - invoke("add_mcp_server", { name }); -export const updateMcpServer = (server: McpServer) => - invoke("update_mcp_server", { server }); -export const removeMcpServer = (serverId: string) => - invoke("remove_mcp_server", { serverId }); - // Files export const listContainerFiles = (projectId: string, path: string) => invoke("list_container_files", { projectId, path }); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index b6e4d79..c4773cb 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -35,7 +35,6 @@ export interface Project { custom_env_vars: EnvVar[]; port_mappings: PortMapping[]; claude_instructions: string | null; - enabled_mcp_servers: string[]; claude_code_settings: ClaudeCodeSettings | null; renamed_session_names: Record; created_at: string; @@ -202,23 +201,6 @@ export interface ImageUpdateInfo { remote_updated_at: string | null; } -export type McpTransportType = "stdio" | "http"; - -export interface McpServer { - id: string; - name: string; - transport_type: McpTransportType; - command: string | null; - args: string[]; - env: Record; - url: string | null; - headers: Record; - docker_image: string | null; - container_port: number | null; - created_at: string; - updated_at: string; -} - export interface FileEntry { name: string; path: string; diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts index 37a409e..b4681e7 100644 --- a/app/src/store/appState.ts +++ b/app/src/store/appState.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types"; +import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo } from "../lib/types"; const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed"; @@ -35,12 +35,6 @@ interface AppState { removeSession: (id: string) => void; setActiveSession: (id: string | null) => void; - // MCP servers - mcpServers: McpServer[]; - setMcpServers: (servers: McpServer[]) => void; - updateMcpServerInList: (server: McpServer) => void; - removeMcpServerFromList: (id: string) => void; - // UI state terminalHasSelection: boolean; setTerminalHasSelection: (has: boolean) => void; @@ -54,8 +48,8 @@ interface AppState { setTerminalAtBottom: (v: boolean) => void; scrollActiveToBottom: () => void; setScrollActiveToBottom: (fn: () => void) => void; - sidebarView: "projects" | "mcp" | "settings"; - setSidebarView: (view: "projects" | "mcp" | "settings") => void; + sidebarView: "projects" | "settings"; + setSidebarView: (view: "projects" | "settings") => void; sidebarCollapsed: boolean; setSidebarCollapsed: (collapsed: boolean) => void; toggleSidebarCollapsed: () => void; @@ -118,20 +112,6 @@ export const useAppState = create((set) => ({ }), setActiveSession: (id) => set({ activeSessionId: id }), - // MCP servers - mcpServers: [], - setMcpServers: (servers) => set({ mcpServers: servers }), - updateMcpServerInList: (server) => - set((state) => ({ - mcpServers: state.mcpServers.map((s) => - s.id === server.id ? server : s, - ), - })), - removeMcpServerFromList: (id) => - set((state) => ({ - mcpServers: state.mcpServers.filter((s) => s.id !== id), - })), - // UI state terminalHasSelection: false, setTerminalHasSelection: (has) => set({ terminalHasSelection: has }), -- 2.52.0 From d0bb631d4d30d27876761dd7c2f46dbda9f57405 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 10:31:18 -0700 Subject: [PATCH 02/14] Remove MCP backend, entrypoint injection, and docs; add migration shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the removal begun in the previous commit. Backend: deletes models/mcp_server.rs, storage/mcp_store.rs and commands/mcp_commands.rs, the McpStore on AppState, the four IPC handlers, Project::enabled_mcp_servers, build_mcp_servers_json(), compute_mcp_fingerprint(), the MCP_SERVERS_JSON env injection, the mcp-fingerprint label, and the whole MCP container lifecycle. create_container() and container_needs_recreation() lose their mcp_servers/network_name parameters. Container: entrypoint.sh no longer merges MCP_SERVERS_JSON into ~/.claude.json. MCP_SERVERS_JSON stays in the reserved env blocklist. Security: the Docker socket is no longer auto-mounted for stdio+Docker MCP servers — it now mounts only when allow_docker_access is set. Migration: old containers were created with network_mode=triple-c-net- and refuse to start once that network is gone. docker/network.rs becomes docker/legacy_cleanup.rs with label-driven, best-effort removal of leftover MCP containers and the per-project network, called on both delete and recreate. container_needs_recreation() now forces a rebuild for any container carrying a non-empty triple-c.mcp-fingerprint label or attached to a triple-c-net-* network, moving it onto the default bridge. Both can be dropped a release later. Docs: drops the MCP sections from README/HOW-TO-USE/TECHNICAL and adds a short note pointing at Claude Code's native `claude mcp` / `/mcp` / .mcp.json instead. Co-Authored-By: Claude Opus 5 (1M context) --- HOW-TO-USE.md | 140 +------- README.md | 40 +-- TECHNICAL.md | 12 +- app/src-tauri/src/commands/file_commands.rs | 7 +- app/src-tauri/src/commands/mcp_commands.rs | 38 --- app/src-tauri/src/commands/mod.rs | 1 - .../src/commands/project_commands.rs | 93 +----- app/src-tauri/src/docker/container.rs | 301 ++---------------- app/src-tauri/src/docker/legacy_cleanup.rs | 137 ++++++++ app/src-tauri/src/docker/mod.rs | 4 +- app/src-tauri/src/docker/network.rs | 129 -------- app/src-tauri/src/lib.rs | 15 - app/src-tauri/src/models/mcp_server.rs | 70 ---- app/src-tauri/src/models/mod.rs | 2 - app/src-tauri/src/models/project.rs | 3 - app/src-tauri/src/storage/mcp_store.rs | 106 ------ app/src-tauri/src/storage/mod.rs | 3 - container/entrypoint.sh | 21 -- 18 files changed, 204 insertions(+), 918 deletions(-) delete mode 100644 app/src-tauri/src/commands/mcp_commands.rs create mode 100644 app/src-tauri/src/docker/legacy_cleanup.rs delete mode 100644 app/src-tauri/src/docker/network.rs delete mode 100644 app/src-tauri/src/models/mcp_server.rs delete mode 100644 app/src-tauri/src/storage/mcp_store.rs diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 6c92943..ddb99a7 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -11,7 +11,6 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code - [The Interface](#the-interface) - [Project Management](#project-management) - [Project Configuration](#project-configuration) -- [MCP Servers (Beta)](#mcp-servers-beta) - [AWS Bedrock Configuration](#aws-bedrock-configuration) - [Ollama Configuration](#ollama-configuration) - [OpenAI Compatible Configuration](#openai-compatible-configuration) @@ -136,7 +135,6 @@ Claude Code launches automatically. By default, it runs in standard permission m │ Sidebar │ │ │ │ Terminal View │ │ Projects │ (xterm.js) │ -│ MCP │ │ │ Settings │ │ ├────────────┴────────────────────────────────────────┤ │ StatusBar X projects · X running · X terminals │ @@ -144,7 +142,7 @@ Claude Code launches automatically. By default, it runs in standard permission m ``` - **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. +- **Sidebar** — Toggle between the **Projects** list and the **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. @@ -192,7 +190,7 @@ Only **Remove** deletes everything, including the config volume and any stored c ### 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, a progress modal shows real-time status messages (e.g., "Creating container...", "Starting 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. --- @@ -253,7 +251,7 @@ When **disabled** (default), Claude prompts you for approval before executing ea 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. -> 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_`) and specific internal variables (`CLAUDE_INSTRUCTIONS`, `CLAUDE_CODE_SETTINGS_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`). ### Port Mappings @@ -287,127 +285,17 @@ 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 -## MCP Servers (Beta) +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: -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**. +- `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 -### How It Works - -There are two dimensions to MCP server configuration: - -| | **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 | - -**Docker images are pulled automatically** if not already present when the project starts. - -### Accessing MCP Configuration - -Click the **MCP** tab in the sidebar to open the MCP server library. This is where you define all available MCP servers. - -### Adding an MCP Server - -1. Type a name in the input field and click **Add**. -2. Expand the server card and configure it. - -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. - -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 - -#### Example 1: Filesystem Server (Stdio, Manual) - -A simple npx-based server that runs inside the project container. No Docker image needed since Node.js is already installed. - -| Field | Value | -|-------|-------| -| **Docker Image** | *(empty)* | -| **Transport** | Stdio | -| **Command** | `npx` | -| **Arguments** | `-y @modelcontextprotocol/server-filesystem /workspace` | - -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. - -#### Example 2: GitHub Server (Stdio, Manual) - -Another npx-based server, with an environment variable for authentication. - -| 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. +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. --- @@ -753,12 +641,6 @@ These features are built into Claude Code and work inside Triple-C containers wi - 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..2e1c9d5 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi ### 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 +2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, 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 +4. **Stop**: Container halted (filesystem persists in named volume) 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) @@ -41,7 +41,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi | `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation | | `/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 | ### Authentication Modes @@ -60,27 +60,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. @@ -132,8 +111,6 @@ Users can override this in Settings via the global `docker_socket_path` option. | `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/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management | | `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) | @@ -142,30 +119,25 @@ Users can override this in Settings via the global `docker_socket_path` option. | `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) | | `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) | | `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) | -| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations | | `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/container.rs` | Container creation, mounts, env vars, fingerprinting | | `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, 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/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers | | `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, Docker access, Claude Code settings, Mission Control) | | `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 | diff --git a/TECHNICAL.md b/TECHNICAL.md index cf02576..5a89d49 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -236,7 +236,7 @@ triple-c/ │ ├── container/ │ ├── 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 │ ├── 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 @@ -259,11 +259,10 @@ triple-c/ │ ├── App.tsx # Top-level layout │ ├── index.css # CSS variables, dark theme, scrollbars │ ├── store/ - │ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI) + │ │ └── appState.ts # Zustand store (projects, sessions, UI) │ ├── hooks/ │ │ ├── useDocker.ts # Docker status, image build/pull │ │ ├── useFileManager.ts # File manager operations - │ │ ├── useMcpServers.ts # MCP server CRUD │ │ ├── useProjects.ts # Project CRUD operations │ │ ├── useSettings.ts # App settings │ │ ├── useTerminal.ts # Terminal I/O, resize, session events @@ -275,7 +274,6 @@ triple-c/ │ │ └── constants.ts # App-wide constants │ └── components/ │ ├── layout/ # Sidebar, TopBar, StatusBar - │ ├── mcp/ # McpPanel, McpServerCard │ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog, │ │ # FileManagerModal, ContainerProgressModal, modals │ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings, @@ -294,7 +292,6 @@ triple-c/ ├── 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 @@ -309,17 +306,14 @@ triple-c/ │ ├── client.rs # bollard singleton connection │ ├── container.rs # Create, start, stop, remove, fingerprinting │ ├── exec.rs # PTY exec sessions with bidirectional streaming - │ ├── image.rs # Build from Dockerfile, pull from registry - │ └── network.rs # Per-project bridge networks for MCP + │ └── image.rs # Build from Dockerfile, pull from registry ├── models/ # Data structures │ ├── project.rs # Project, Backend, BedrockConfig - │ ├── mcp_server.rs # MCP server configuration │ ├── app_settings.rs # Global settings (image source, AWS, etc.) │ ├── 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 ``` 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/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..27edfa8 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -3,7 +3,6 @@ pub mod docker_commands; pub mod file_commands; pub mod help_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..dee3c99 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()) @@ -121,16 +108,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 { @@ -177,9 +158,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 +278,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 +289,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 +306,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 +331,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 +368,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(), @@ -482,15 +428,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(()) } @@ -514,14 +451,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); diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 0c9f997..e18f0a5 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 @@ -175,6 +175,8 @@ fn build_claude_instructions( /// 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_"]; + // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was + // removed, but the name stays blocked so users cannot hand-set it. let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; let mut parts: Vec = Vec::new(); for env_var in custom_env_vars { @@ -476,83 +478,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 +519,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>, @@ -795,6 +718,8 @@ 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_"]; + // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was + // removed, but the name stays blocked so users cannot hand-set it. let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; for env_var in &merged_env { let key = env_var.key.trim(); @@ -838,12 +763,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 +883,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,7 +929,6 @@ 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.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone()); labels.insert("triple-c.claude-code-settings-fingerprint".to_string(), @@ -1030,8 +944,6 @@ pub async fn create_container( 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() }; @@ -1307,7 +1219,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>, @@ -1514,11 +1425,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 +1519,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/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..c4ef25f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -11,14 +11,12 @@ use std::sync::Arc; 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 web_terminal_server: Arc>>, } @@ -40,13 +38,6 @@ 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()); // Clone Arcs for the setup closure (web terminal auto-start) @@ -61,7 +52,6 @@ pub fn run() { .manage(AppState { projects_store, settings_store, - mcp_store, exec_manager, web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), }) @@ -187,11 +177,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 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..209cb14 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -92,8 +92,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)] @@ -220,7 +218,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(), 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/container/entrypoint.sh b/container/entrypoint.sh index 11a4056..a961beb 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -167,27 +167,6 @@ if [ "$MISSION_CONTROL_ENABLED" = "1" ]; then unset MISSION_CONTROL_ENABLED fi -# ── MCP server configuration ──────────────────────────────────────────────── -# Merge MCP server config into ~/.claude.json (preserves existing keys like -# OAuth tokens). Creates the file if it doesn't exist. -if [ -n "$MCP_SERVERS_JSON" ]; then - CLAUDE_JSON="/home/claude/.claude.json" - if [ -f "$CLAUDE_JSON" ]; then - # Merge: existing config + MCP config (MCP keys override on conflict) - MERGED=$(jq -s '.[0] * .[1]' "$CLAUDE_JSON" <(printf '%s' "$MCP_SERVERS_JSON") 2>/dev/null) - if [ -n "$MERGED" ]; then - printf '%s\n' "$MERGED" > "$CLAUDE_JSON" - else - echo "entrypoint: warning — failed to merge MCP config into $CLAUDE_JSON" - fi - else - printf '%s\n' "$MCP_SERVERS_JSON" > "$CLAUDE_JSON" - fi - chown claude:claude "$CLAUDE_JSON" - chmod 600 "$CLAUDE_JSON" - unset MCP_SERVERS_JSON -fi - # ── Claude Code settings ──────────────────────────────────────────────────── # Merge Claude Code settings into ~/.claude/settings.json (preserves existing # keys). Creates the file if it doesn't exist. These control TUI mode, effort -- 2.52.0 From 0ac4e5030c86e94692088fd2d190979294d9625b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 10:51:34 -0700 Subject: [PATCH 03/14] Add permission modes and container introspection backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission modes: replaces the binary full_permissions flag with a PermissionMode enum (Plan/Default/AcceptEdits/Bypass). Flag mapping is defined once in PermissionMode::cli_args() and used by the terminal, the web terminal, and the scheduler: Plan -> --permission-mode plan Default -> (no flag) AcceptEdits -> --permission-mode acceptEdits Bypass -> --dangerously-skip-permissions Choices verified against `claude --permission-mode` on 2.1.226. full_permissions is retained and effective_permission_mode() falls back to it, so existing projects.json needs no migration. Bug fix: triple-c-task-runner ran `claude -p ... --dangerously-skip- permissions` unconditionally, ignoring the project's setting entirely. It now reads TRIPLE_C_PERMISSION_MODE, which is injected into the container, added to the reserved env blocklist, propagated through the entrypoint's cron env filter, and tracked by a new triple-c.permission-mode label so a change forces recreation. Introspection: new commands/inspect_commands.rs exposes read-only views into the container over docker exec — Claude sessions (parsed from ~/.claude/projects//.jsonl), installed capabilities (skills, agents, commands, hooks, plugins, natively-configured MCP servers), and the triple-c-scheduler task list, logs and notifications. Task/session ids are validated against a strict allowlist and every parameterized call runs as a bare argv vector via bollard, so no shell is involved. Stopped containers return empty results rather than errors. No UI yet; that lands with the Project Home view. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/commands/inspect_commands.rs | 951 ++++++++++++++++++ app/src-tauri/src/commands/mod.rs | 1 + .../src/commands/terminal_commands.rs | 18 +- app/src-tauri/src/docker/container.rs | 29 +- app/src-tauri/src/lib.rs | 11 + app/src-tauri/src/models/project.rs | 64 ++ app/src-tauri/src/web_terminal/ws_handler.rs | 18 +- app/src/lib/tauri-commands.ts | 28 +- app/src/lib/types.ts | 73 ++ container/entrypoint.sh | 2 +- container/triple-c-task-runner | 19 +- 11 files changed, 1193 insertions(+), 21 deletions(-) create mode 100644 app/src-tauri/src/commands/inspect_commands.rs diff --git a/app/src-tauri/src/commands/inspect_commands.rs b/app/src-tauri/src/commands/inspect_commands.rs new file mode 100644 index 0000000..84d9be1 --- /dev/null +++ b/app/src-tauri/src/commands/inspect_commands.rs @@ -0,0 +1,951 @@ +//! Read-only introspection of what lives inside a project's container. +//! +//! Three inventories are exposed to the GUI: +//! 1. Claude Code sessions (transcripts on the persistent config volume) +//! 2. Container capabilities (skills / agents / commands / hooks / plugins / MCP) +//! 3. Scheduled tasks managed by the in-container `triple-c-scheduler` +//! +//! Everything here is read-only except the explicitly-mutating scheduler +//! commands at the bottom of the file (enable/disable, run, remove, clear +//! notifications), which shell out to the scheduler's own subcommands rather +//! than editing its state files. +//! +//! ## Container access +//! +//! All work happens inside the container via the existing `docker exec` +//! plumbing in [`crate::docker::exec`] — no second mechanism is introduced. +//! The heavy lifting (walking dirs, grepping transcripts, parsing JSON with +//! `jq`) runs *in* the container and only a small JSON summary crosses the +//! wire, so multi-megabyte transcripts are never streamed back. +//! +//! `HOME` is passed explicitly on every exec: `docker exec` inherits the +//! container image's environment rather than the target user's, so `$HOME` is +//! not reliably `/home/claude` otherwise (see `download_container_backup`, +//! which does the same). +//! +//! ## Injection safety +//! +//! Two rules, applied together (defense in depth): +//! +//! * The `sh -c` scripts below are compile-time constants. No caller-supplied +//! value is ever interpolated into them. +//! * Every command that takes a caller-supplied id runs as a plain **argv +//! vector** with no shell in the process tree at all, so shell metacharacters +//! are inert by construction. On top of that, ids are validated against a +//! strict allowlist ([`validate_task_id`], [`validate_session_id`]) that +//! admits no shell metacharacters, no `/`, no `.` (so no path traversal into +//! the scheduler's task dir), and no leading `-` (so no option injection). +//! +//! ## Degradation +//! +//! A stopped or missing container is a normal state, not an error: the +//! read-only commands return empty/zero results. Only the mutating scheduler +//! commands fail loudly, since they cannot do anything useful without a +//! running container. + +use bollard::exec::{CreateExecOptions, StartExecOptions}; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::docker::client::get_docker; +use crate::docker::container::is_container_running; +use crate::docker::exec::{exec_oneshot_env, exec_oneshot_env_status}; +use crate::AppState; + +/// Newest N session transcripts to inspect. Caps the work done inside the +/// container regardless of how much history has accumulated on the volume. +const MAX_SESSIONS: usize = 50; + +/// Newest N scheduler notifications to return. +const MAX_NOTIFICATIONS: usize = 50; + +const CONTAINER_HOME: &str = "/home/claude"; + +// ───────────────────────────────────────────────────────────────────────────── +// Response models +// +// These live here (rather than in `models/`) so this feature is confined to a +// single file; they are IPC response shapes, not persisted state. +// ───────────────────────────────────────────────────────────────────────────── + +/// One Claude Code session transcript found inside the container. +#[derive(Debug, Clone, Serialize)] +pub struct ClaudeSession { + /// Session UUID (the transcript's filename stem, and what `--resume` takes). + pub id: String, + /// User-set display name (`claude -n `), if the session has one. + pub name: Option, + /// Best available one-line description: Claude's auto-generated title if it + /// produced one, otherwise the last prompt sent in the session. + pub summary: Option, + /// Transcript mtime as an ISO 8601 / RFC 3339 timestamp (UTC). + pub last_modified: String, + pub size_bytes: u64, + /// Approximate user + assistant turn count (counted by line, cheap). + pub message_count: u64, + /// The directory the session was started in. + pub cwd: Option, +} + +/// A single installed capability (skill, agent, command, hook event, …). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapabilityItem { + pub name: String, + pub description: Option, + /// `"user"` (from `~/.claude`) or `"project"` (from a mounted workspace). + pub scope: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CapabilityGroup { + pub count: u64, + pub items: Vec, +} + +/// Inventory of everything Claude Code has available inside the container. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ContainerCapabilities { + pub skills: CapabilityGroup, + pub agents: CapabilityGroup, + pub commands: CapabilityGroup, + /// One item per configured hook event; `count` is the total number of + /// individual hook handlers across all events. + pub hooks: CapabilityGroup, + pub plugins: CapabilityGroup, + pub mcp_servers: CapabilityGroup, +} + +/// A task managed by the in-container `triple-c-scheduler`. +/// +/// Mirrors the scheduler's own on-disk JSON schema +/// (`~/.claude/scheduler/tasks/.json`). +#[derive(Debug, Clone, Serialize)] +pub struct ScheduledTask { + pub id: String, + pub name: String, + pub prompt: String, + /// Cron expression. One-shot tasks are also stored as a cron expression; + /// see `at` for the original wall-clock time. + pub schedule: String, + /// `"recurring"` or `"once"` (the scheduler's `type` field). + pub task_type: String, + /// Original `--at` value (`"YYYY-MM-DD HH:MM"`) for one-shot tasks. + pub at: Option, + pub enabled: bool, + pub working_dir: String, + pub created_at: Option, + /// Derived from the newest file in `~/.claude/scheduler/logs//`; the + /// scheduler does not record this in the task JSON itself. + pub last_run: Option, + /// Only known for enabled one-shot tasks (their `at` time). Recurring cron + /// expressions are not evaluated here. + pub next_run: Option, +} + +/// A completion notice written by `triple-c-task-runner` after a task ran. +#[derive(Debug, Clone, Serialize)] +pub struct SchedulerNotification { + pub task_id: String, + pub task_name: Option, + /// `"SUCCESS"` or `"FAILED (exit code N)"`. + pub status: Option, + /// The runner's own human-readable timestamp line. + pub time: Option, + pub task_type: Option, + /// Tail of the run's log that the runner captured. + pub summary: Option, + /// Full notification text, verbatim. + pub body: String, + /// Notification file mtime, ISO 8601 (UTC). + pub created_at: String, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Resolve a project to a *running* container id. +/// +/// `Ok(None)` means "there is nothing to inspect" — no container recorded, or +/// the container exists but is stopped. Callers that are read-only turn that +/// into an empty result; mutating callers turn it into an error. +/// `Err` is reserved for a genuinely unknown project id. +async fn running_container_for( + project_id: &str, + state: &State<'_, AppState>, +) -> Result, String> { + let project = state + .projects_store + .get(project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let container_id = match project.container_id { + Some(id) => id, + None => return Ok(None), + }; + + if is_container_running(&container_id).await.unwrap_or(false) { + Ok(Some(container_id)) + } else { + Ok(None) + } +} + +/// Same as [`running_container_for`], but a stopped container is an error. +/// Used by the mutating scheduler commands. +async fn require_running_container( + project_id: &str, + state: &State<'_, AppState>, +) -> Result { + running_container_for(project_id, state).await?.ok_or_else(|| { + "Container is not running — start the project first.".to_string() + }) +} + +fn home_env() -> Vec { + vec![format!("HOME={}", CONTAINER_HOME)] +} + +/// Run one of this module's constant scripts under `sh -c` and return stdout. +/// +/// The scripts redirect their own stderr to `/dev/null` (`exec 2>/dev/null` on +/// the first line) so the combined stream `exec_oneshot_env` returns is pure +/// stdout and stays parseable as JSON. A non-zero exit therefore surfaces as +/// empty output, which the callers treat as "nothing to report". +async fn run_script(container_id: &str, script: impl Into) -> Result { + exec_oneshot_env( + container_id, + vec!["sh".to_string(), "-c".to_string(), script.into()], + home_env(), + ) + .await +} + +/// Parse script output as JSON, degrading to a default value (and a log line) +/// rather than failing the whole command if the container returned something +/// unexpected. +fn parse_or_default(raw: &str, what: &str) -> T { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return T::default(); + } + match serde_json::from_str::(trimmed) { + Ok(v) => v, + Err(e) => { + log::warn!( + "Failed to parse {} JSON from container ({}): {}", + what, + e, + trimmed.chars().take(300).collect::() + ); + T::default() + } + } +} + +fn epoch_to_iso(epoch: i64) -> String { + chrono::DateTime::from_timestamp(epoch, 0) + .unwrap_or_default() + .to_rfc3339() +} + +/// Strict allowlist for scheduler task ids. +/// +/// The scheduler generates ids as 8 lowercase hex chars (`head -c 4 +/// /dev/urandom | od -An -tx1`). This accepts that plus a small tolerant +/// superset, while admitting **no** shell metacharacters, no `/` or `.` (so a +/// crafted id cannot escape `~/.claude/scheduler/tasks/`), and no leading `-` +/// (so it cannot be mistaken for an option). Combined with argv-only execution +/// this makes shell injection structurally impossible. +fn validate_task_id(id: &str) -> Result<(), String> { + let valid = !id.is_empty() + && id.len() <= 64 + && id.starts_with(|c: char| c.is_ascii_alphanumeric()) + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if valid { + Ok(()) + } else { + Err(format!("Invalid scheduler task id: {:?}", id)) + } +} + +/// Strict allowlist for session ids (Claude Code uses UUIDs). +fn validate_session_id(id: &str) -> Result<(), String> { + let valid = !id.is_empty() + && id.len() <= 64 + && id.starts_with(|c: char| c.is_ascii_alphanumeric()) + && id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'); + if valid { + Ok(()) + } else { + Err(format!("Invalid session id: {:?}", id)) + } +} + +/// Run `triple-c-scheduler ` as a bare argv vector — no shell is +/// involved, so caller-supplied ids cannot be interpreted as shell syntax. +/// Returns the combined output, erroring with it on a non-zero exit. +async fn run_scheduler(container_id: &str, args: Vec) -> Result { + let mut cmd = vec!["triple-c-scheduler".to_string()]; + cmd.extend(args); + + let (output, exit_code) = exec_oneshot_env_status(container_id, cmd, home_env()).await?; + if exit_code != 0 { + let detail = output.trim(); + return Err(if detail.is_empty() { + format!("triple-c-scheduler failed (exit {})", exit_code) + } else { + detail.to_string() + }); + } + Ok(output) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Sessions +// ───────────────────────────────────────────────────────────────────────────── + +/// Emits a JSON array describing the newest transcripts on the config volume. +/// +/// Layout (verified empirically against Claude Code 2.1.226): transcripts are +/// JSON Lines at `~/.claude/projects//.jsonl`. +/// +/// Metadata is pulled with a single `grep -o` pass per file that yields whole +/// JSON key/value fragments; each fragment is already a valid JSON object body, +/// so wrapping it in braces and letting `jq` merge them decodes escapes +/// correctly without ever parsing a full transcript line-by-line. Later records +/// win (so the newest title/prompt is used) except `cwd`, where the first +/// record wins (the directory the session actually started in). Malformed lines +/// simply fail to match and are skipped. +const SESSIONS_SCRIPT: &str = r#"exec 2>/dev/null +set -u +ROOT="$HOME/.claude/projects" +[ -d "$ROOT" ] || { echo '[]'; exit 0; } +TAB=$(printf '\t') +find "$ROOT" -mindepth 2 -maxdepth 2 -name '*.jsonl' -type f -printf '%T@\t%s\t%p\n' \ + | sort -rn | head -__MAX__ \ + | while IFS="$TAB" read -r mtime size path; do + [ -n "${path:-}" ] || continue + [ "${size:-0}" -gt 0 ] || continue + id=$(basename "$path" .jsonl) + meta=$(grep -aoE '"(cwd|aiTitle|customTitle|agentName|lastPrompt|summary)":"([^"\\]|\\.)*"' "$path" \ + | sed 's/^/{/; s/$/}/' \ + | jq -c -s '(reduce .[] as $o ({}; . + $o)) + (([.[] | select(has("cwd"))] | first) // {})') || meta='' + [ -n "$meta" ] || meta='{}' + count=$(grep -acE '"type":"(user|assistant)"' "$path") || count=0 + jq -c -n --arg id "$id" --arg mt "${mtime%%.*}" --arg sz "$size" --arg mc "$count" --argjson meta "$meta" \ + '{id: $id, + modified_epoch: ($mt | tonumber), + size_bytes: ($sz | tonumber), + message_count: ($mc | tonumber), + name: ($meta.customTitle // $meta.agentName // null), + summary: ($meta.aiTitle // $meta.summary // $meta.lastPrompt // null), + cwd: ($meta.cwd // null)}' + done | jq -s '.' +"#; + +#[derive(Debug, Deserialize)] +struct RawSession { + id: String, + modified_epoch: i64, + size_bytes: u64, + message_count: u64, + name: Option, + summary: Option, + cwd: Option, +} + +/// List the Claude Code sessions stored inside a project's container, newest +/// first, capped at [`MAX_SESSIONS`]. +/// +/// Returns an empty vec (no error) when the container is stopped or has never +/// been started. +#[tauri::command] +pub async fn list_claude_sessions( + project_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(Vec::new()), + }; + + // `head -N` is the only piece of the script that varies, and it comes from + // a const usize — never from the caller. + let script = SESSIONS_SCRIPT.replace("__MAX__", &MAX_SESSIONS.to_string()); + + let raw = run_script(&container_id, script).await?; + let sessions: Vec = parse_or_default(&raw, "session list"); + + Ok(sessions + .into_iter() + .map(|s| ClaudeSession { + id: s.id, + name: s.name.filter(|v| !v.is_empty()), + summary: s.summary.filter(|v| !v.is_empty()), + last_modified: epoch_to_iso(s.modified_epoch), + size_bytes: s.size_bytes, + message_count: s.message_count, + cwd: s.cwd.filter(|v| !v.is_empty()), + }) + .collect()) +} + +/// Build the shell command line that resumes a session, for the frontend to +/// drop into a terminal. +/// +/// The flag spelling was checked against the CLI in the container image: +/// `claude --resume ` (short form `-r`). +/// +/// The project's permission mode is folded in so the resumed session behaves +/// like a freshly opened one. The session id is validated first, and the +/// returned string contains only allowlisted characters. +#[tauri::command] +pub async fn resume_session_command( + project_id: String, + session_id: String, + state: State<'_, AppState>, +) -> Result { + validate_session_id(&session_id)?; + + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let mut parts = vec!["claude".to_string()]; + parts.extend(project.effective_permission_mode().cli_args()); + parts.push("--resume".to_string()); + parts.push(session_id); + + Ok(parts.join(" ")) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Capabilities +// ───────────────────────────────────────────────────────────────────────────── + +/// Emits a single JSON object with one group per capability category. +/// +/// User scope is `~/.claude`. Project scope is `/workspace/.claude` *and* +/// `/workspace//.claude` — Triple-C mounts each project path at +/// `/workspace/`, so a repo's own `.claude` dir lives one level +/// down, not at the workspace root. +/// +/// Frontmatter `name`/`description` are pulled with a small `awk` reader +/// (first `---` block only, first matching key, surrounding quotes stripped); +/// no YAML crate is involved. Files without frontmatter fall back to their +/// path-derived name. +const CAPABILITIES_SCRIPT: &str = r#"exec 2>/dev/null +set -u +USER_BASE="$HOME/.claude" + +# Project-scoped config roots: the workspace root plus each mounted project dir. +proj_bases() { + [ -d /workspace/.claude ] && echo /workspace/.claude + for d in /workspace/*/; do + [ -d "$d/.claude" ] && echo "${d}.claude" + done +} + +# fm — value of a YAML frontmatter key, or nothing. +fm() { + [ -f "$1" ] || return 0 + head -1 "$1" | grep -q '^---[[:space:]]*$' || return 0 + awk -v key="$2" ' + NR == 1 { next } + /^---[[:space:]]*$/ { exit } + { + pfx = key ":" + if (index($0, pfx) == 1) { + v = substr($0, length(pfx) + 1) + sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v) + if (v ~ /^".*"$/) v = substr(v, 2, length(v) - 2) + else if (v ~ /^\047.*\047$/) v = substr(v, 2, length(v) - 2) + print v + exit + } + }' "$1" +} + +emit_item() { + jq -c -n --arg n "$1" --arg d "$2" --arg s "$3" \ + '{name: $n, description: (if $d == "" then null else $d end), scope: $s}' +} + +collect_skills() { + base="$1"; scope="$2" + [ -d "$base/skills" ] || return 0 + for d in "$base"/skills/*/; do + [ -f "$d/SKILL.md" ] || continue + n=$(fm "$d/SKILL.md" name) + [ -n "$n" ] || n=$(basename "$d") + emit_item "$n" "$(fm "$d/SKILL.md" description)" "$scope" + done +} + +collect_md() { + base="$1"; scope="$2"; sub="$3" + [ -d "$base/$sub" ] || return 0 + find "$base/$sub" -name '*.md' -type f | sort | while read -r f; do + rel=${f#"$base/$sub/"}; rel=${rel%.md} + n=$(fm "$f" name) + [ -n "$n" ] || n="$rel" + emit_item "$n" "$(fm "$f" description)" "$scope" + done +} + +# One item per hook event; `count` carries the number of individual handlers so +# the caller can sum them into the group total. +collect_hooks() { + base="$1"; scope="$2" + for sf in "$base/settings.json" "$base/settings.local.json"; do + [ -f "$sf" ] || continue + jq -c --arg s "$scope" --arg f "$(basename "$sf")" ' + (.hooks // {}) | to_entries[] | + ([.value[]? | (.hooks // []) | length] | add // 0) as $n | + {name: .key, + description: ($f + ": " + ($n | tostring) + " handler(s)"), + scope: $s, + count: $n}' "$sf" + done +} + +collect_plugins() { + ip="$USER_BASE/plugins/installed_plugins.json" + [ -f "$ip" ] && jq -c '(.plugins // {}) | to_entries[] | + {name: .key, + description: ((.value[0].version // "") | if . == "" then null else "v" + . end), + scope: (.value[0].scope // "user")}' "$ip" + for cf in "$USER_BASE/settings.json" "$HOME/.claude.json"; do + [ -f "$cf" ] || continue + jq -c '(.enabledPlugins // {}) | to_entries[] | select(.value == true) | + {name: .key, description: "enabled", scope: "user"}' "$cf" + done +} + +collect_mcp() { + if [ -f "$HOME/.claude.json" ]; then + jq -c '(.mcpServers // {}) | to_entries[] | + {name: .key, description: ((.value.command // .value.url // .value.type) // null), + scope: "user"}' "$HOME/.claude.json" + jq -c '(.projects // {}) | to_entries[] | (.value.mcpServers // {}) | to_entries[] | + {name: .key, description: ((.value.command // .value.url // .value.type) // null), + scope: "project"}' "$HOME/.claude.json" + fi + for mf in /workspace/.mcp.json /workspace/*/.mcp.json; do + [ -f "$mf" ] || continue + jq -c '(.mcpServers // {}) | to_entries[] | + {name: .key, description: ((.value.command // .value.url // .value.type) // null), + scope: "project"}' "$mf" + done +} + +group() { jq -s 'unique_by([.scope, .name]) | {count: length, items: .}'; } + +all_skills() { collect_skills "$USER_BASE" user; proj_bases | while read -r b; do collect_skills "$b" project; done; } +all_agents() { collect_md "$USER_BASE" user agents; proj_bases | while read -r b; do collect_md "$b" project agents; done; } +all_commands() { collect_md "$USER_BASE" user commands; proj_bases | while read -r b; do collect_md "$b" project commands; done; } +all_hooks() { collect_hooks "$USER_BASE" user; proj_bases | while read -r b; do collect_hooks "$b" project; done; } + +jq -c -n \ + --argjson skills "$(all_skills | group)" \ + --argjson agents "$(all_agents | group)" \ + --argjson commands "$(all_commands | group)" \ + --argjson hooks "$(all_hooks | jq -s '{count: ([.[].count] | add // 0), items: map(del(.count))}')" \ + --argjson plugins "$(collect_plugins | group)" \ + --argjson mcp "$(collect_mcp | group)" \ + '{skills: $skills, agents: $agents, commands: $commands, + hooks: $hooks, plugins: $plugins, mcp_servers: $mcp}' +"#; + +/// Inventory the Claude Code capabilities installed inside a project's +/// container. A stopped container yields all-zero groups, not an error. +#[tauri::command] +pub async fn list_container_capabilities( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(ContainerCapabilities::default()), + }; + + let raw = run_script(&container_id, CAPABILITIES_SCRIPT).await?; + Ok(parse_or_default(&raw, "container capabilities")) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Scheduler +// ───────────────────────────────────────────────────────────────────────────── + +/// Emits the scheduler's tasks as JSON, mirroring its on-disk schema: +/// `{id, name, prompt, schedule, type, at, created_at, enabled, working_dir}`. +/// +/// `last_run` is not in that schema, so it is derived from the mtime of the +/// newest file in `~/.claude/scheduler/logs//`. +const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null +set -u +TASKS="$HOME/.claude/scheduler/tasks" +LOGS="$HOME/.claude/scheduler/logs" +[ -d "$TASKS" ] || { echo '[]'; exit 0; } +for f in "$TASKS"/*.json; do + [ -f "$f" ] || continue + id=$(jq -r '.id // ""' "$f") || continue + [ -n "$id" ] || id=$(basename "$f" .json) + last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1) + jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{ + id: (if (.id // "") == "" then $fallback_id else .id end), + name: (.name // ""), + prompt: (.prompt // ""), + schedule: (.schedule // ""), + task_type: (.type // "recurring"), + at: (if (.at // "") == "" then null else .at end), + enabled: (.enabled == true), + working_dir: (.working_dir // "/workspace"), + created_at: (.created_at // null), + last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end) + }' "$f" +done | jq -s 'sort_by(.name, .id)' +"#; + +/// Emits the newest notification files as structured JSON. The runner writes +/// them as a fixed plain-text block (`Task:`/`Status:`/`Time:`/`Type:` then a +/// `Summary:` body), which is parsed here; the verbatim text is kept too. +const SCHEDULER_NOTIFICATIONS_SCRIPT: &str = r#"exec 2>/dev/null +set -u +NDIR="$HOME/.claude/scheduler/notifications" +[ -d "$NDIR" ] || { echo '[]'; exit 0; } +TAB=$(printf '\t') +find "$NDIR" -maxdepth 1 -name '*.notify' -type f -printf '%T@\t%p\n' \ + | sort -rn | head -__MAX__ \ + | while IFS="$TAB" read -r mtime path; do + [ -f "$path" ] || continue + base=$(basename "$path" .notify) + jq -c -n --arg tid "${base%%_*}" --arg mt "${mtime%%.*}" --rawfile body "$path" '{ + task_id: $tid, + created_epoch: ($mt | tonumber), + task_name: (($body | capture("Task:[ \t]+(?.*)") | .v | sub("[ \t]+$"; "")) // null), + status: (($body | capture("Status:[ \t]+(?.*)") | .v | sub("[ \t]+$"; "")) // null), + time: (($body | capture("Time:[ \t]+(?.*)") | .v | sub("[ \t]+$"; "")) // null), + task_type: (($body | capture("Type:[ \t]+(?.*)") | .v | sub("[ \t]+$"; "")) // null), + summary: (($body | capture("Summary:\n(?[\\s\\S]*)") | .v) // null), + body: $body + }' + done | jq -s '.' +"#; + +#[derive(Debug, Deserialize)] +struct RawScheduledTask { + id: String, + name: String, + prompt: String, + schedule: String, + task_type: String, + at: Option, + enabled: bool, + working_dir: String, + created_at: Option, + last_run_epoch: Option, +} + +#[derive(Debug, Deserialize)] +struct RawNotification { + task_id: String, + created_epoch: i64, + task_name: Option, + status: Option, + time: Option, + task_type: Option, + summary: Option, + body: String, +} + +/// List the container's scheduled tasks. Stopped container → empty vec. +#[tauri::command] +pub async fn list_scheduled_tasks( + project_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(Vec::new()), + }; + + let raw = run_script(&container_id, SCHEDULER_LIST_SCRIPT).await?; + let tasks: Vec = parse_or_default(&raw, "scheduled tasks"); + + Ok(tasks + .into_iter() + .map(|t| { + // A one-shot task's `at` time is its next (and only) run. Recurring + // cron expressions are left uncomputed rather than guessed at. + let next_run = if t.task_type == "once" && t.enabled { + t.at.clone() + } else { + None + }; + ScheduledTask { + id: t.id, + name: t.name, + prompt: t.prompt, + schedule: t.schedule, + task_type: t.task_type, + at: t.at, + enabled: t.enabled, + working_dir: t.working_dir, + created_at: t.created_at, + last_run: t.last_run_epoch.map(epoch_to_iso), + next_run, + } + }) + .collect()) +} + +/// Tail the most recent log for one task, via the scheduler's own `logs` +/// subcommand. Stopped container → empty string. +#[tauri::command] +pub async fn get_scheduled_task_log( + project_id: String, + task_id: String, + tail_lines: Option, + state: State<'_, AppState>, +) -> Result { + validate_task_id(&task_id)?; + // Clamped, and an integer by type — cannot carry shell syntax. + let tail = tail_lines.unwrap_or(200).clamp(1, 5000); + + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(String::new()), + }; + + run_scheduler( + &container_id, + vec![ + "logs".to_string(), + "--id".to_string(), + task_id, + "--tail".to_string(), + tail.to_string(), + ], + ) + .await +} + +/// Read the scheduler's pending completion notifications, newest first. +/// Stopped container → empty vec. +#[tauri::command] +pub async fn get_scheduler_notifications( + project_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let container_id = match running_container_for(&project_id, &state).await? { + Some(id) => id, + None => return Ok(Vec::new()), + }; + + let script = + SCHEDULER_NOTIFICATIONS_SCRIPT.replace("__MAX__", &MAX_NOTIFICATIONS.to_string()); + + let raw = run_script(&container_id, script).await?; + let notifications: Vec = parse_or_default(&raw, "scheduler notifications"); + + Ok(notifications + .into_iter() + .map(|n| SchedulerNotification { + task_id: n.task_id, + task_name: n.task_name, + status: n.status, + time: n.time, + task_type: n.task_type, + summary: n.summary.map(|s| s.trim_end().to_string()).filter(|s| !s.is_empty()), + body: n.body, + created_at: epoch_to_iso(n.created_epoch), + }) + .collect()) +} + +// ── Mutating scheduler commands ────────────────────────────────────────────── +// +// These delegate to `triple-c-scheduler`'s own subcommands (which also rebuild +// the crontab) instead of editing its JSON, and each runs as a bare argv vector +// with a validated id. + +/// Enable or disable a task via the scheduler's `enable` / `disable`. +#[tauri::command] +pub async fn set_scheduled_task_enabled( + project_id: String, + task_id: String, + enabled: bool, + state: State<'_, AppState>, +) -> Result { + validate_task_id(&task_id)?; + let container_id = require_running_container(&project_id, &state).await?; + + let subcommand = if enabled { "enable" } else { "disable" }; + let output = run_scheduler( + &container_id, + vec![subcommand.to_string(), "--id".to_string(), task_id], + ) + .await?; + Ok(output.trim().to_string()) +} + +/// Trigger a task immediately via the scheduler's `run`. +/// +/// The run itself invokes Claude Code and can take minutes, so the exec is +/// started **detached**: Docker keeps it alive after this call returns and the +/// UI is not blocked. Progress shows up through `get_scheduled_task_log` / +/// `get_scheduler_notifications`, exactly as for a cron-triggered run. +#[tauri::command] +pub async fn run_scheduled_task_now( + project_id: String, + task_id: String, + state: State<'_, AppState>, +) -> Result { + validate_task_id(&task_id)?; + let container_id = require_running_container(&project_id, &state).await?; + + let docker = get_docker()?; + let exec = docker + .create_exec( + &container_id, + CreateExecOptions { + attach_stdout: Some(false), + attach_stderr: Some(false), + // Argv vector — no shell, so `task_id` is inert as data. + cmd: Some(vec![ + "triple-c-scheduler".to_string(), + "run".to_string(), + "--id".to_string(), + task_id.clone(), + ]), + env: Some(home_env()), + user: Some("claude".to_string()), + working_dir: Some("/workspace".to_string()), + ..Default::default() + }, + ) + .await + .map_err(|e| format!("Failed to create exec: {}", e))?; + + docker + .start_exec( + &exec.id, + Some(StartExecOptions { + detach: true, + ..Default::default() + }), + ) + .await + .map_err(|e| format!("Failed to start task: {}", e))?; + + log::info!( + "Triggered scheduler task {} in project {} (detached exec {})", + task_id, + project_id, + exec.id + ); + Ok(format!("Task {} started.", task_id)) +} + +/// Remove a task via the scheduler's `remove` (which also rebuilds the crontab). +#[tauri::command] +pub async fn remove_scheduled_task( + project_id: String, + task_id: String, + state: State<'_, AppState>, +) -> Result { + validate_task_id(&task_id)?; + let container_id = require_running_container(&project_id, &state).await?; + + let output = run_scheduler( + &container_id, + vec!["remove".to_string(), "--id".to_string(), task_id], + ) + .await?; + Ok(output.trim().to_string()) +} + +/// Clear all pending notifications via the scheduler's `notifications --clear`. +#[tauri::command] +pub async fn clear_scheduler_notifications( + project_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let container_id = require_running_container(&project_id, &state).await?; + run_scheduler( + &container_id, + vec!["notifications".to_string(), "--clear".to_string()], + ) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_id_allowlist_accepts_scheduler_generated_ids() { + assert!(validate_task_id("a1b2c3d4").is_ok()); + assert!(validate_task_id("00000000").is_ok()); + assert!(validate_task_id("task_1-a").is_ok()); + } + + #[test] + fn task_id_allowlist_rejects_injection_and_traversal() { + for bad in [ + "", + "a b", + "a;rm -rf /", + "a$(id)", + "a`id`", + "a|b", + "a&b", + "a>b", + "a'b", + "a\"b", + "a\nb", + "../../etc/passwd", + "a/b", + "a.json", + "-id", + "--id", + &"a".repeat(65), + ] { + assert!( + validate_task_id(bad).is_err(), + "should have rejected {:?}", + bad + ); + } + } + + #[test] + fn session_id_allowlist_accepts_uuids_only() { + assert!(validate_session_id("e13d312d-2f38-4cf6-b0e0-1db60208a74c").is_ok()); + assert!(validate_session_id("zzzz").is_err()); + assert!(validate_session_id("abc; rm -rf /").is_err()); + assert!(validate_session_id("-abc").is_err()); + assert!(validate_session_id("").is_err()); + } + + #[test] + fn parse_or_default_degrades_on_garbage() { + let v: Vec = parse_or_default("not json", "test"); + assert!(v.is_empty()); + let v: Vec = parse_or_default(" ", "test"); + assert!(v.is_empty()); + let caps: ContainerCapabilities = parse_or_default("{}", "test"); + assert_eq!(caps.skills.count, 0); + } + + #[test] + fn epoch_to_iso_is_rfc3339() { + assert!(epoch_to_iso(0).starts_with("1970-01-01T00:00:00")); + } +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 27edfa8..1f5ac05 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod aws_commands; pub mod docker_commands; pub mod file_commands; pub mod help_commands; +pub mod inspect_commands; pub mod install_helper_commands; pub mod project_commands; pub mod settings_commands; diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index 2df434f..2e05bd8 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -17,11 +17,11 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option< .map(|b| b.auth_method == BedrockAuthMethod::Profile) .unwrap_or(false); + let permission_args = project.effective_permission_mode().cli_args(); + if !is_bedrock_profile { let mut cmd = vec!["claude".to_string()]; - if project.full_permissions { - cmd.push("--dangerously-skip-permissions".to_string()); - } + cmd.extend(permission_args); if let Some(name) = session_name { if !name.is_empty() { cmd.push("-n".to_string()); @@ -42,11 +42,13 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option< .filter(|n| !n.is_empty()) .map(|n| format!(" -n '{}'", n.replace('\'', "'\\''"))) .unwrap_or_default(); - let claude_cmd = if project.full_permissions { - format!("exec claude --dangerously-skip-permissions{}", name_flag) - } else { - format!("exec claude{}", name_flag) - }; + // The args are interpolated into a shell script string, so single-quote + // each one (same escaping style as name_flag above). + let permission_flags: String = permission_args + .iter() + .map(|a| format!(" '{}'", a.replace('\'', "'\\''"))) + .collect(); + let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag); let script = format!( r#" diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index e18f0a5..9f33c01 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -177,7 +177,7 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was // removed, but the name stays blocked so users cannot hand-set it. - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; + let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"]; let mut parts: Vec = Vec::new(); for env_var in custom_env_vars { let key = env_var.key.trim(); @@ -720,7 +720,7 @@ pub async fn create_container( let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was // removed, but the name stays blocked so users cannot hand-set it. - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"]; + let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"]; for env_var in &merged_env { let key = env_var.key.trim(); if key.is_empty() { @@ -750,6 +750,13 @@ pub async fn create_container( env_vars.push("MISSION_CONTROL_ENABLED=1".to_string()); } + // Permission mode — read by triple-c-task-runner for scheduled (headless) + // Claude Code runs. Interactive terminals get the flags directly instead. + env_vars.push(format!( + "TRIPLE_C_PERMISSION_MODE={}", + project.effective_permission_mode().as_env_value() + )); + // Claude instructions (global + per-project, plus port mapping info + scheduler docs) let combined_instructions = build_claude_instructions( global_claude_instructions, @@ -930,6 +937,8 @@ pub async fn create_container( labels.insert("triple-c.image".to_string(), image_name.to_string()); labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string()); labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string()); + labels.insert("triple-c.permission-mode".to_string(), + project.effective_permission_mode().as_env_value().to_string()); labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone()); labels.insert("triple-c.claude-code-settings-fingerprint".to_string(), compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled)); @@ -1398,6 +1407,22 @@ pub async fn container_needs_recreation( return Ok(true); } + // ── Permission mode ──────────────────────────────────────────────────── + // The mode is injected as the TRIPLE_C_PERMISSION_MODE env var, and + // container env can only change by recreating the container. A missing + // label means the container predates this feature and therefore has no + // such env var, so it must be recreated too (empty != any valid mode). + let expected_permission_mode = project.effective_permission_mode().as_env_value(); + let container_permission_mode = get_label("triple-c.permission-mode").unwrap_or_default(); + if container_permission_mode != expected_permission_mode { + log::info!( + "Permission mode mismatch (container={:?}, expected={:?})", + container_permission_mode, + expected_permission_mode + ); + return Ok(true); + } + // ── Claude instructions (label-based fingerprint) ───────────────────── let expected_instructions = build_claude_instructions( global_claude_instructions, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index c4ef25f..e82f0af 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -200,6 +200,17 @@ pub fn run() { commands::stt_commands::build_stt_image, commands::stt_commands::pull_stt_image, commands::stt_commands::transcribe_audio, + // Container introspection (sessions / capabilities / scheduler) + commands::inspect_commands::list_claude_sessions, + commands::inspect_commands::resume_session_command, + commands::inspect_commands::list_container_capabilities, + commands::inspect_commands::list_scheduled_tasks, + commands::inspect_commands::get_scheduled_task_log, + commands::inspect_commands::set_scheduled_task_enabled, + commands::inspect_commands::run_scheduled_task_now, + commands::inspect_commands::remove_scheduled_task, + commands::inspect_commands::get_scheduler_notifications, + commands::inspect_commands::clear_scheduler_notifications, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index 209cb14..b47f1a1 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -30,6 +30,50 @@ fn default_full_permissions() -> bool { true } +/// How much autonomy Claude Code is granted inside the container. +/// +/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is +/// the single definition of that mapping and must be used by every call site. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum PermissionMode { + /// Read-only planning mode. + Plan, + /// Claude Code's own default behavior (prompts for permission). + #[default] + Default, + /// Auto-accept file edits, prompt for everything else. + AcceptEdits, + /// Skip all permission prompts. + Bypass, +} + +impl PermissionMode { + /// The CLI flags this mode adds to a `claude` invocation. + /// Defined once here so every call site stays in sync. + pub fn cli_args(&self) -> Vec { + match self { + PermissionMode::Plan => vec!["--permission-mode".to_string(), "plan".to_string()], + PermissionMode::Default => Vec::new(), + PermissionMode::AcceptEdits => { + vec!["--permission-mode".to_string(), "acceptEdits".to_string()] + } + PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()], + } + } + + /// The wire value used for the `TRIPLE_C_PERMISSION_MODE` container env var. + /// Matches the serde `camelCase` representation. + pub fn as_env_value(&self) -> &'static str { + match self { + PermissionMode::Plan => "plan", + PermissionMode::Default => "default", + PermissionMode::AcceptEdits => "acceptEdits", + PermissionMode::Bypass => "bypass", + } + } +} + /// Settings for Claude Code CLI behavior inside the container. /// These map to Claude Code env vars and ~/.claude/settings.json entries. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] @@ -78,8 +122,16 @@ pub struct Project { pub sandbox_mode_enabled: bool, #[serde(default)] pub mission_control_enabled: bool, + /// Legacy binary permission flag. Superseded by `permission_mode`, but kept + /// because it is the value already stored in users' `projects.json`; it is + /// the fallback in `effective_permission_mode()` so old projects keep + /// behaving identically without a data migration. #[serde(default = "default_full_permissions")] pub full_permissions: bool, + /// Per-project permission mode. `None` means "not set yet" → fall back to + /// the legacy `full_permissions` flag. + #[serde(default)] + pub permission_mode: Option, pub ssh_key_path: Option, #[serde(skip_serializing, default)] pub git_token: Option, @@ -211,6 +263,7 @@ impl Project { sandbox_mode_enabled: false, mission_control_enabled: false, full_permissions: false, + permission_mode: None, ssh_key_path: None, git_token: None, git_user_name: None, @@ -225,6 +278,17 @@ impl Project { } } + /// The permission mode to actually use for this project. + /// Falls back to the legacy `full_permissions` boolean when the newer + /// `permission_mode` field has never been set. + pub fn effective_permission_mode(&self) -> PermissionMode { + self.permission_mode.unwrap_or(if self.full_permissions { + PermissionMode::Bypass + } else { + PermissionMode::Default + }) + } + pub fn container_name(&self) -> String { format!("triple-c-{}", self.id) } diff --git a/app/src-tauri/src/web_terminal/ws_handler.rs b/app/src-tauri/src/web_terminal/ws_handler.rs index 3a49b18..bcafb11 100644 --- a/app/src-tauri/src/web_terminal/ws_handler.rs +++ b/app/src-tauri/src/web_terminal/ws_handler.rs @@ -205,11 +205,11 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin .map(|b| b.auth_method == BedrockAuthMethod::Profile) .unwrap_or(false); + let permission_args = project.effective_permission_mode().cli_args(); + if !is_bedrock_profile { let mut cmd = vec!["claude".to_string()]; - if project.full_permissions { - cmd.push("--dangerously-skip-permissions".to_string()); - } + cmd.extend(permission_args); return cmd; } @@ -218,11 +218,13 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin settings_store.get().global_aws.aws_profile.as_deref(), ); - let claude_cmd = if project.full_permissions { - "exec claude --dangerously-skip-permissions" - } else { - "exec claude" - }; + // The args are interpolated into a shell script string below, so + // single-quote each one. + let permission_flags: String = permission_args + .iter() + .map(|a| format!(" '{}'", a.replace('\'', "'\\''"))) + .collect(); + let claude_cmd = format!("exec claude{}", permission_flags); let script = format!( r#" diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index abb01ce..c44218d 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, SchedulerNotification } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -107,3 +107,29 @@ export const transcribeAudio = (audioData: number[]) => export const detectInstallOptions = () => invoke("detect_install_options"); export const runDockerInstall = () => invoke("run_docker_install"); + +// Container introspection — sessions +export const listClaudeSessions = (projectId: string) => + invoke("list_claude_sessions", { projectId }); +export const resumeSessionCommand = (projectId: string, sessionId: string) => + invoke("resume_session_command", { projectId, sessionId }); + +// Container introspection — capabilities +export const listContainerCapabilities = (projectId: string) => + invoke("list_container_capabilities", { projectId }); + +// Container introspection — scheduler +export const listScheduledTasks = (projectId: string) => + invoke("list_scheduled_tasks", { projectId }); +export const getScheduledTaskLog = (projectId: string, taskId: string, tailLines?: number) => + invoke("get_scheduled_task_log", { projectId, taskId, tailLines }); +export const setScheduledTaskEnabled = (projectId: string, taskId: string, enabled: boolean) => + invoke("set_scheduled_task_enabled", { projectId, taskId, enabled }); +export const runScheduledTaskNow = (projectId: string, taskId: string) => + invoke("run_scheduled_task_now", { projectId, taskId }); +export const removeScheduledTask = (projectId: string, taskId: string) => + invoke("remove_scheduled_task", { projectId, taskId }); +export const getSchedulerNotifications = (projectId: string) => + invoke("get_scheduler_notifications", { projectId }); +export const clearSchedulerNotifications = (projectId: string) => + invoke("clear_scheduler_notifications", { projectId }); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index c4773cb..0a0b4eb 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -27,7 +27,11 @@ export interface Project { allow_docker_access: boolean; sandbox_mode_enabled: boolean; mission_control_enabled: boolean; + /** Legacy binary permission flag; superseded by `permission_mode`, kept for + * existing projects.json data. */ full_permissions: boolean; + /** null = not set → falls back to `full_permissions` (true → "bypass"). */ + permission_mode: PermissionMode | null; ssh_key_path: string | null; git_token: string | null; git_user_name: string | null; @@ -50,6 +54,9 @@ export type ProjectStatus = export type Backend = "anthropic" | "bedrock" | "ollama" | "open_ai_compatible"; +/** Mirrors Rust `PermissionMode` (serde camelCase). */ +export type PermissionMode = "plan" | "default" | "acceptEdits" | "bypass"; + export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token"; export interface BedrockConfig { @@ -220,3 +227,69 @@ export interface InstallOptions { manual_steps: string[]; post_install_notes: string[]; } + +// Container introspection (read-only) — see src-tauri/src/commands/inspect_commands.rs + +/** A Claude Code session transcript stored on the container's config volume. */ +export interface ClaudeSession { + id: string; + /** User-set display name (`claude -n `), if any. */ + name: string | null; + /** Claude's auto-generated title, else the session's last prompt. */ + summary: string | null; + last_modified: string; + size_bytes: number; + message_count: number; + cwd: string | null; +} + +export type CapabilityScope = "user" | "project"; + +export interface CapabilityItem { + name: string; + description: string | null; + scope: CapabilityScope; +} + +export interface CapabilityGroup { + count: number; + items: CapabilityItem[]; +} + +export interface ContainerCapabilities { + skills: CapabilityGroup; + agents: CapabilityGroup; + commands: CapabilityGroup; + /** One item per hook event; `count` totals the individual handlers. */ + hooks: CapabilityGroup; + plugins: CapabilityGroup; + mcp_servers: CapabilityGroup; +} + +export interface ScheduledTask { + id: string; + name: string; + prompt: string; + /** Cron expression (one-shot tasks are stored as cron too — see `at`). */ + schedule: string; + task_type: "recurring" | "once"; + /** Original `--at` value (`"YYYY-MM-DD HH:MM"`) for one-shot tasks. */ + at: string | null; + enabled: boolean; + working_dir: string; + created_at: string | null; + last_run: string | null; + /** Known only for enabled one-shot tasks; cron is not evaluated. */ + next_run: string | null; +} + +export interface SchedulerNotification { + task_id: string; + task_name: string | null; + status: string | null; + time: string | null; + task_type: string | null; + summary: string | null; + body: string; + created_at: string; +} diff --git a/container/entrypoint.sh b/container/entrypoint.sh index a961beb..4ad5e4f 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -255,7 +255,7 @@ ENV_FILE="$SCHEDULER_DIR/.env" : > "$ENV_FILE" env | while IFS='=' read -r key value; do case "$key" in - ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|PATH|HOME|LANG|TZ|COLORTERM) + ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM) # Escape single quotes in value and write as KEY='VALUE' escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g") printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE" diff --git a/container/triple-c-task-runner b/container/triple-c-task-runner index 5b59d29..e7bf701 100644 --- a/container/triple-c-task-runner +++ b/container/triple-c-task-runner @@ -47,6 +47,21 @@ WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE") TASK_NAME=$(jq -r '.name' "$TASK_FILE") TASK_TYPE=$(jq -r '.type' "$TASK_FILE") +# ── Resolve permission mode ───────────────────────────────────────────────── +# TRIPLE_C_PERMISSION_MODE is injected into the container by Triple-C from the +# project's permission setting. Keep this mapping in sync with +# PermissionMode::cli_args() in app/src-tauri/src/models/project.rs. +# NOTE: headless `claude -p` runs cannot answer a permission prompt, so any +# mode other than "bypass" means the task may stop early when Claude Code asks +# for permission. Unset or unrecognized values pass no flag (Claude's default). +PERMISSION_ARGS=() +case "${TRIPLE_C_PERMISSION_MODE:-}" in + plan) PERMISSION_ARGS=(--permission-mode plan) ;; + acceptEdits) PERMISSION_ARGS=(--permission-mode acceptEdits) ;; + bypass) PERMISSION_ARGS=(--dangerously-skip-permissions) ;; + *) PERMISSION_ARGS=() ;; +esac + # ── Prepare log directory ─────────────────────────────────────────────────── TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}" mkdir -p "$TASK_LOG_DIR" @@ -60,13 +75,15 @@ LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log" echo "=== Started: $(date) ===" echo "=== Working dir: $WORKING_DIR ===" echo "=== Prompt: $PROMPT ===" + echo "=== Permission mode: ${TRIPLE_C_PERMISSION_MODE:-default} ===" echo "" } > "$LOG_FILE" EXIT_CODE=0 if [ -d "$WORKING_DIR" ]; then cd "$WORKING_DIR" - claude -p "$PROMPT" --dangerously-skip-permissions >> "$LOG_FILE" 2>&1 || EXIT_CODE=$? + # ${arr[@]+"${arr[@]}"} keeps an empty array safe under `set -u` + claude -p "$PROMPT" ${PERMISSION_ARGS[@]+"${PERMISSION_ARGS[@]}"} >> "$LOG_FILE" 2>&1 || EXIT_CODE=$? else echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE" EXIT_CODE=1 -- 2.52.0 From f68d10d5c20fc13736fb822c6c8b6fcb7352e77e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 10:56:26 -0700 Subject: [PATCH 04/14] Add DESIGN-REVIEW.md and ROADMAP.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN-REVIEW.md is Fable's review of the v0.3.0 UI: token gaps and three WCAG AA contrast failures, the modal/accessibility audit, and an IA proposal that promotes the project from a sidebar card to a tabbed main-area view. ROADMAP.md covers Claude Code feature coverage — the five settings.json keys currently surfaced, the gaps worth closing, the ones deliberately skipped, the authentication handoff design, and phase sequencing. Also published as an artifact for easier reading. Co-Authored-By: Claude Opus 5 (1M context) --- DESIGN-REVIEW.md | 337 +++++++++++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 190 ++++++++++++++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 DESIGN-REVIEW.md create mode 100644 ROADMAP.md 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/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..70a1f47 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,190 @@ +# 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 +once on the host, stores the token in the OS keychain via the existing `secure.rs`, and +injects `CLAUDE_CODE_OAUTH_TOKEN` into every container that uses the Anthropic backend. + +- 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. + +### 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. **Stale model placeholders** — see "Not yet scheduled" above. + +4. **Silent save failures.** Project config saves on blur; failures go only to + `console.error`. No user-visible indication. Addressed in Phase 3. -- 2.52.0 From 01a2f6aec85c5286b7207720b80b0218b299b53a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 11:35:42 -0700 Subject: [PATCH 05/14] Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px sidebar card to a first-class main-area view. ProjectCard.tsx (1,257 lines) is replaced by a select-only ProjectRow plus tabs for Overview, Sessions, Automation, Config and Files. The PortMappings, FileManager and ContainerProgress modals are absorbed rather than reimplemented. Config gains a Saved/Saving/Failed indicator — save-on-blur failures previously reached only console.error. Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled and focus-ring tokens; a global :focus-visible ring with every focus:outline-none removed; filled buttons moved to --accent-emphasis and white-on-success toggles retired, fixing three WCAG AA failures (2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog", focus trap and restore, adopted by all remaining modals; status indicators that carry a glyph and word rather than colour alone. Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's kill-word, used constantly in the terminal this app is built around. Auth Bridge: a general loopback-callback bridge so browser logins run inside a container (aws sso login, Concourse fly login, claude login) can complete against the host browser. Listeners are discovered from /proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on host 127.0.0.1 only, and tunnelled in over the Docker API via socat, which keeps working on Docker Desktop where container IPs are not routable. Falls back to [::1] because Node resolves localhost to IPv6 first, so claude login often binds ::1 alone. Opt-in per project. This extracts create_attached_exec() and moves the existing terminal session path onto it, so there is one attached-exec implementation rather than two. Shared auth token: `claude setup-token` is run in a container, the token is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN into Anthropic-backend projects. Contrary to the initial design note, setup-token uses an Anthropic-hosted redirect and blocks on a stdin paste prompt rather than a loopback callback, so a stdin command is required for the flow to complete. The token is never logged, never returned to the frontend, and is redacted from the streamed output with a stateful matcher that withholds any tail that could still grow into a secret. Change detection uses a random rotation id rather than a hash, since a hash in a docker-inspect readable label would be an offline verification oracle. Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean. Co-Authored-By: Claude Opus 5 (1M context) --- ROADMAP.md | 45 +- app/src-tauri/src/auth_bridge/mod.rs | 525 +++++++ app/src-tauri/src/auth_bridge/proc_net.rs | 302 ++++ app/src-tauri/src/auth_bridge/tunnel.rs | 245 ++++ .../src/commands/auth_bridge_commands.rs | 67 + .../src/commands/auth_token_commands.rs | 899 ++++++++++++ app/src-tauri/src/commands/mod.rs | 2 + .../src/commands/project_commands.rs | 69 +- app/src-tauri/src/docker/container.rs | 131 +- app/src-tauri/src/docker/exec.rs | 172 ++- app/src-tauri/src/lib.rs | 15 + app/src-tauri/src/models/project.rs | 27 + app/src-tauri/src/storage/projects_store.rs | 14 + app/src-tauri/src/storage/secure.rs | 113 ++ app/src/App.tsx | 140 +- app/src/components/DockerInstallDialog.tsx | 250 ++-- app/src/components/layout/HelpDialog.tsx | 69 +- app/src/components/layout/MainTabs.tsx | 328 +++++ app/src/components/layout/Sidebar.test.tsx | 6 + app/src/components/layout/Sidebar.tsx | 4 +- app/src/components/layout/StatusBar.tsx | 2 +- app/src/components/layout/TopBar.tsx | 71 +- .../components/projects/AddProjectDialog.tsx | 168 +-- .../projects/ClaudeCodeSettingsEditor.tsx | 151 ++ .../projects/ClaudeCodeSettingsModal.tsx | 206 +-- .../projects/ClaudeInstructionsEditor.tsx | 46 + .../projects/ClaudeInstructionsModal.tsx | 99 +- .../projects/ConfirmRemoveModal.tsx | 63 +- .../projects/ContainerProgressModal.tsx | 109 -- app/src/components/projects/EnvVarsEditor.tsx | 93 ++ app/src/components/projects/EnvVarsModal.tsx | 134 +- .../components/projects/FileManagerModal.tsx | 197 --- .../projects/PermissionModeControl.test.tsx | 140 ++ .../projects/PermissionModeControl.tsx | 116 ++ .../projects/PortMappingsEditor.tsx | 128 ++ .../components/projects/PortMappingsModal.tsx | 157 --- .../components/projects/ProjectCard.test.tsx | 129 -- app/src/components/projects/ProjectCard.tsx | 1212 ----------------- app/src/components/projects/ProjectList.tsx | 27 +- .../components/projects/ProjectRow.test.tsx | 146 ++ app/src/components/projects/ProjectRow.tsx | 140 ++ .../projects/home/AutomationTab.tsx | 267 ++++ .../projects/home/CapabilityTiles.tsx | 166 +++ .../components/projects/home/ConfigTab.tsx | 57 + app/src/components/projects/home/FilesTab.tsx | 162 +++ .../components/projects/home/OverviewTab.tsx | 218 +++ .../components/projects/home/ProjectHome.tsx | 228 ++++ .../components/projects/home/SessionsTab.tsx | 102 ++ .../projects/home/config/AccessSection.tsx | 150 ++ .../projects/home/config/ModelSection.tsx | 387 ++++++ .../projects/home/config/RuntimeSection.tsx | 103 ++ .../projects/home/config/WorkspaceSection.tsx | 145 ++ app/src/components/projects/home/format.ts | 39 + app/src/components/settings/AwsSettings.tsx | 8 +- .../components/settings/DockerSettings.tsx | 8 +- .../components/settings/ImageUpdateDialog.tsx | 97 +- .../settings/MicrophoneSettings.tsx | 4 +- .../components/settings/OllamaSettings.tsx | 4 +- .../settings/OpenAiCompatibleSettings.tsx | 4 +- app/src/components/settings/SettingsPanel.tsx | 30 +- app/src/components/settings/SttSettings.tsx | 26 +- app/src/components/settings/UpdateDialog.tsx | 141 +- .../settings/WebTerminalSettings.tsx | 18 +- app/src/components/terminal/TerminalTabs.tsx | 201 --- app/src/components/ui/Button.tsx | 50 + app/src/components/ui/Field.tsx | 89 ++ app/src/components/ui/Modal.test.tsx | 118 ++ app/src/components/ui/Modal.tsx | 183 +++ app/src/components/ui/OverflowMenu.tsx | 82 ++ app/src/components/ui/SaveIndicator.tsx | 28 + app/src/components/ui/SegmentedControl.tsx | 92 ++ app/src/components/ui/StatusIndicator.tsx | 105 ++ app/src/components/ui/ToastHost.tsx | 105 ++ app/src/components/ui/Toggle.tsx | 45 + app/src/components/ui/Tooltip.tsx | 5 +- app/src/hooks/useContainerProgress.ts | 25 + app/src/hooks/useKeyboardShortcuts.ts | 92 ++ app/src/hooks/useProjectActions.ts | 150 ++ app/src/hooks/useSaveState.ts | 50 + app/src/index.css | 46 +- app/src/lib/tauri-commands.ts | 29 +- app/src/lib/types.ts | 56 + app/src/store/appState.ts | 234 +++- 83 files changed, 8042 insertions(+), 3064 deletions(-) create mode 100644 app/src-tauri/src/auth_bridge/mod.rs create mode 100644 app/src-tauri/src/auth_bridge/proc_net.rs create mode 100644 app/src-tauri/src/auth_bridge/tunnel.rs create mode 100644 app/src-tauri/src/commands/auth_bridge_commands.rs create mode 100644 app/src-tauri/src/commands/auth_token_commands.rs create mode 100644 app/src/components/layout/MainTabs.tsx create mode 100644 app/src/components/projects/ClaudeCodeSettingsEditor.tsx create mode 100644 app/src/components/projects/ClaudeInstructionsEditor.tsx delete mode 100644 app/src/components/projects/ContainerProgressModal.tsx create mode 100644 app/src/components/projects/EnvVarsEditor.tsx delete mode 100644 app/src/components/projects/FileManagerModal.tsx create mode 100644 app/src/components/projects/PermissionModeControl.test.tsx create mode 100644 app/src/components/projects/PermissionModeControl.tsx create mode 100644 app/src/components/projects/PortMappingsEditor.tsx delete mode 100644 app/src/components/projects/PortMappingsModal.tsx delete mode 100644 app/src/components/projects/ProjectCard.test.tsx delete mode 100644 app/src/components/projects/ProjectCard.tsx create mode 100644 app/src/components/projects/ProjectRow.test.tsx create mode 100644 app/src/components/projects/ProjectRow.tsx create mode 100644 app/src/components/projects/home/AutomationTab.tsx create mode 100644 app/src/components/projects/home/CapabilityTiles.tsx create mode 100644 app/src/components/projects/home/ConfigTab.tsx create mode 100644 app/src/components/projects/home/FilesTab.tsx create mode 100644 app/src/components/projects/home/OverviewTab.tsx create mode 100644 app/src/components/projects/home/ProjectHome.tsx create mode 100644 app/src/components/projects/home/SessionsTab.tsx create mode 100644 app/src/components/projects/home/config/AccessSection.tsx create mode 100644 app/src/components/projects/home/config/ModelSection.tsx create mode 100644 app/src/components/projects/home/config/RuntimeSection.tsx create mode 100644 app/src/components/projects/home/config/WorkspaceSection.tsx create mode 100644 app/src/components/projects/home/format.ts delete mode 100644 app/src/components/terminal/TerminalTabs.tsx create mode 100644 app/src/components/ui/Button.tsx create mode 100644 app/src/components/ui/Field.tsx create mode 100644 app/src/components/ui/Modal.test.tsx create mode 100644 app/src/components/ui/Modal.tsx create mode 100644 app/src/components/ui/OverflowMenu.tsx create mode 100644 app/src/components/ui/SaveIndicator.tsx create mode 100644 app/src/components/ui/SegmentedControl.tsx create mode 100644 app/src/components/ui/StatusIndicator.tsx create mode 100644 app/src/components/ui/ToastHost.tsx create mode 100644 app/src/components/ui/Toggle.tsx create mode 100644 app/src/hooks/useContainerProgress.ts create mode 100644 app/src/hooks/useKeyboardShortcuts.ts create mode 100644 app/src/hooks/useProjectActions.ts create mode 100644 app/src/hooks/useSaveState.ts diff --git a/ROADMAP.md b/ROADMAP.md index 70a1f47..46d012a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,9 +101,16 @@ 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 -once on the host, stores the token in the OS keychain via the existing `secure.rs`, and -injects `CLAUDE_CODE_OAUTH_TOKEN` into every container that uses the Anthropic backend. +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. @@ -111,6 +118,12 @@ injects `CLAUDE_CODE_OAUTH_TOKEN` into every container that uses the Anthropic b - 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 @@ -187,4 +200,28 @@ the config volume by the entrypoint. Generalizes the pattern the MCP tab was rea 3. **Stale model placeholders** — see "Not yet scheduled" above. 4. **Silent save failures.** Project config saves on blur; failures go only to - `console.error`. No user-visible indication. Addressed in Phase 3. + `console.error`. No user-visible indication. Fixed in Phase 3 — `useProjectSave` + now renders a Saved / Saving / Save failed indicator and raises a toast. + +--- + +## Known gaps left by Phase 2–3 + +- **`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/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..d2a699b --- /dev/null +++ b/app/src-tauri/src/commands/auth_token_commands.rs @@ -0,0 +1,899 @@ +//! 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, 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)) +} + +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>, +) -> 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! { + 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 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); + } + + 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).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; + 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()) +} + +/// 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/mod.rs b/app/src-tauri/src/commands/mod.rs index 1f5ac05..c046d5e 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -1,3 +1,5 @@ +pub mod auth_bridge_commands; +pub mod auth_token_commands; pub mod aws_commands; pub mod docker_commands; pub mod file_commands; diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index dee3c99..a53dcf8 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -100,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 { @@ -133,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] @@ -400,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) @@ -418,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..."); @@ -443,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; @@ -469,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(); @@ -490,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/docker/container.rs b/app/src-tauri/src/docker/container.rs index 9f33c01..c98bf12 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -171,23 +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_"]; - // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was - // removed, but the name stays blocked so users cannot hand-set it. - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"]; 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)); @@ -196,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 { @@ -680,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. @@ -704,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() @@ -717,19 +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_"]; - // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was - // removed, but the name stays blocked so users cannot hand-set it. - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"]; 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; } @@ -948,6 +1020,10 @@ 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), @@ -1144,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. @@ -1390,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); 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/lib.rs b/app/src-tauri/src/lib.rs index e82f0af..4ad063f 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,6 +9,7 @@ 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; @@ -18,6 +20,7 @@ pub struct AppState { pub projects_store: Arc, pub settings_store: Arc, pub exec_manager: Arc, + pub auth_bridge: Arc, pub web_terminal_server: Arc>>, } @@ -39,6 +42,7 @@ pub fn run() { } }); 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(); @@ -53,6 +57,7 @@ pub fn run() { projects_store, settings_store, exec_manager, + auth_bridge, web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), }) .setup(move |app| { @@ -136,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; }); } }) @@ -155,6 +162,14 @@ 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::has_claude_token, + commands::auth_token_commands::clear_claude_token, // Settings commands::settings_commands::get_settings, commands::settings_commands::update_settings, diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index b47f1a1..bf2b0c0 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -30,6 +30,14 @@ 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 @@ -122,6 +130,23 @@ 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 @@ -262,6 +287,8 @@ 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, 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/App.tsx b/app/src/App.tsx index 64935bf..f408a6f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,13 +5,20 @@ 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 { 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() { @@ -19,9 +26,17 @@ export default function App() { const { loadSettings } = useSettings(); const { refresh } = useProjects(); 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 @@ -33,6 +48,9 @@ export default function App() { setSttToggle(stt.toggle); }, [stt.toggle, setSttToggle]); + useContainerProgress(); + useKeyboardShortcuts(); + // Initialize on mount useEffect(() => { loadSettings(); @@ -69,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)} /> )} @@ -98,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 d1072dd..03ea549 100644 --- a/app/src/components/layout/Sidebar.test.tsx +++ b/app/src/components/layout/Sidebar.test.tsx @@ -34,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 3e2b719..af4824e 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -63,7 +63,7 @@ export default function Sidebar() { }; return ( -
+
)} {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/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

- -
-