diff --git a/CLAUDE.md b/CLAUDE.md index 9abf9837..8b650235 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,6 +209,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) +**Instance shutdown**: the desktop/tablet header X opens a confirmation dialog and calls `POST /api/system/shutdown`. The route requires `CODEMAN_PASSWORD` in single-user mode and an administrator in multi-user mode. Manual/dev servers use the existing graceful `WebServer.stop()` path and preserve tmux sessions. Auto-restarting services stop their owning supervisor instead of merely exiting: `instance-shutdown.ts` accepts only a Codeman-named systemd cgroup whose `MainPID` is the current process, or a launchd job whose reported PID is the current process. System-level units fail closed without administrator access; browser/test-mode servers acknowledge without exiting. + **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/src/web/instance-shutdown.ts b/src/web/instance-shutdown.ts new file mode 100644 index 00000000..dc1c7155 --- /dev/null +++ b/src/web/instance-shutdown.ts @@ -0,0 +1,190 @@ +/** + * @fileoverview Detects and stops the supervisor that owns the running Codeman process. + * + * A supervised Codeman service uses an automatic restart policy. Exiting the Node + * process directly would therefore restart it, so shutdown must target the exact + * systemd/launchd unit that owns this PID. Unsupervised dev/manual processes use + * WebServer.stop() instead. + */ + +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +const LAUNCHD_LABEL = 'com.codeman.web'; +const CODEMAN_SYSTEMD_UNIT_RE = /^codeman(?:[-.@][A-Za-z0-9_.@-]+)?\.service$/; + +export type ResolvedShutdownStrategy = + | { kind: 'manual'; apiName: 'manual' } + | { + kind: 'systemd'; + apiName: 'systemd-user' | 'systemd-system'; + scope: 'user' | 'system'; + unit: string; + } + | { + kind: 'launchd'; + apiName: 'launchd-user' | 'launchd-system'; + target: string; + } + | { kind: 'unsupported'; reason: string }; + +interface ShutdownProbe { + platform: NodeJS.Platform; + pid: number; + uid: number | undefined; + cgroupText?: string | null; + systemdMainPid?: (scope: 'user' | 'system', unit: string) => number | null; + launchdPrint?: (target: string) => string | null; +} + +export function parseCodemanSystemdMembership(cgroupText: string): { unit: string; scope: 'user' | 'system' } | null { + for (const line of cgroupText.split(/\r?\n/)) { + const path = line.slice(line.lastIndexOf(':') + 1); + const segments = path.split('/').filter(Boolean); + let unit: string | undefined; + for (let index = segments.length - 1; index >= 0; index--) { + if (CODEMAN_SYSTEMD_UNIT_RE.test(segments[index])) { + unit = segments[index]; + break; + } + } + if (unit === undefined) continue; + const scope = path.includes('/user.slice/') || path.includes('/user@') ? 'user' : 'system'; + return { unit, scope }; + } + return null; +} + +export function parseLaunchdPid(output: string | null): number | null { + const match = output?.match(/^\s*pid\s*=\s*(\d+)\s*$/m); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; +} + +export function resolveInstanceShutdownStrategy(probe: ShutdownProbe): ResolvedShutdownStrategy { + if (probe.platform === 'linux') { + const membership = probe.cgroupText ? parseCodemanSystemdMembership(probe.cgroupText) : null; + if (!membership) return { kind: 'manual', apiName: 'manual' }; + // A preview launched from a service-owned shell can share its cgroup. Stop + // the unit only when this process is the unit's actual MainPID. + if (probe.systemdMainPid?.(membership.scope, membership.unit) !== probe.pid) { + return { kind: 'manual', apiName: 'manual' }; + } + if (membership.scope === 'system' && probe.uid !== 0) { + return { + kind: 'unsupported', + reason: `Codeman is managed by system service ${membership.unit}; stopping it requires administrator access`, + }; + } + return { + kind: 'systemd', + apiName: membership.scope === 'user' ? 'systemd-user' : 'systemd-system', + scope: membership.scope, + unit: membership.unit, + }; + } + + if (probe.platform === 'darwin') { + const printTarget = probe.launchdPrint; + if (!printTarget) return { kind: 'manual', apiName: 'manual' }; + + if (probe.uid !== undefined) { + const userTarget = `gui/${probe.uid}/${LAUNCHD_LABEL}`; + if (parseLaunchdPid(printTarget(userTarget)) === probe.pid) { + return { kind: 'launchd', apiName: 'launchd-user', target: userTarget }; + } + } + + const systemTarget = `system/${LAUNCHD_LABEL}`; + if (parseLaunchdPid(printTarget(systemTarget)) === probe.pid) { + if (probe.uid !== 0) { + return { + kind: 'unsupported', + reason: 'Codeman is managed by a system LaunchDaemon; stopping it requires administrator access', + }; + } + return { kind: 'launchd', apiName: 'launchd-system', target: systemTarget }; + } + } + + return { kind: 'manual', apiName: 'manual' }; +} + +function tryLaunchdPrint(target: string): string | null { + try { + return execFileSync('launchctl', ['print', target], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + }); + } catch { + return null; + } +} + +function trySystemdMainPid(scope: 'user' | 'system', unit: string): number | null { + try { + const args = + scope === 'user' + ? ['--user', 'show', unit, '--property', 'MainPID', '--value'] + : ['show', unit, '--property', 'MainPID', '--value']; + const output = execFileSync('systemctl', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + }).trim(); + const pid = Number(output); + return /^\d+$/.test(output) && Number.isSafeInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +export function detectInstanceShutdownStrategy(): ResolvedShutdownStrategy { + let cgroupText: string | null = null; + if (process.platform === 'linux') { + try { + cgroupText = readFileSync('/proc/self/cgroup', 'utf8'); + } catch { + // Non-systemd Linux and restricted containers are manual processes. + } + } + + return resolveInstanceShutdownStrategy({ + platform: process.platform, + pid: process.pid, + uid: process.getuid?.(), + cgroupText, + systemdMainPid: process.platform === 'linux' ? trySystemdMainPid : undefined, + launchdPrint: process.platform === 'darwin' ? tryLaunchdPrint : undefined, + }); +} + +export function startSupervisorShutdown( + strategy: Exclude +): ChildProcess { + const { command, args } = buildSupervisorShutdownCommand(strategy); + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.unref(); + return child; +} + +export function buildSupervisorShutdownCommand( + strategy: Exclude +): { command: 'systemctl' | 'launchctl'; args: string[] } { + let command: 'systemctl' | 'launchctl'; + let args: string[]; + + if (strategy.kind === 'systemd') { + command = 'systemctl'; + args = + strategy.scope === 'user' + ? ['--user', '--no-block', 'stop', strategy.unit] + : ['--no-block', 'stop', strategy.unit]; + } else { + command = 'launchctl'; + args = ['bootout', strategy.target]; + } + + return { command, args }; +} diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index 18cbe603..779a9102 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -14,3 +14,4 @@ export type { InfraPort, ScheduledRun } from './infra-port.js'; export type { AuthPort } from './auth-port.js'; export type { OrchestratorPort } from './orchestrator-port.js'; export type { CronPort } from './cron-port.js'; +export type { InstanceControlPort, InstanceShutdownResult, InstanceShutdownStrategy } from './instance-control-port.js'; diff --git a/src/web/ports/instance-control-port.ts b/src/web/ports/instance-control-port.ts new file mode 100644 index 00000000..8d2fb226 --- /dev/null +++ b/src/web/ports/instance-control-port.ts @@ -0,0 +1,20 @@ +/** + * @fileoverview Process-level Codeman instance controls exposed to system routes. + */ + +export type InstanceShutdownStrategy = 'manual' | 'systemd-user' | 'systemd-system' | 'launchd-user' | 'launchd-system'; + +export type InstanceShutdownResult = + | { + accepted: true; + strategy: InstanceShutdownStrategy; + alreadyScheduled: boolean; + } + | { + accepted: false; + reason: string; + }; + +export interface InstanceControlPort { + requestInstanceShutdown(): Promise; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 6f953be8..a38be46d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -505,6 +505,9 @@ class CodemanApp { this.terminalBuffers = new Map(); // Store terminal content per session this.editingSessionId = null; // Session being edited in options modal this.pendingCloseSessionId = null; // Session pending close confirmation + this._instanceShutdownState = 'idle'; // idle | confirming | submitting | accepted + this._instanceShutdownFocusTrap = null; + this._instanceShutdownRequested = false; this.muxSessions = []; // Screen sessions for process monitor // Ralph loop/todo state per session @@ -994,6 +997,7 @@ class CodemanApp { // Escape - close panels and modals (different logic: no preventDefault, no return) if (e.key === 'Escape') { + this.cancelInstanceShutdown(); this.closeAllPanels(); this.closeHelp(); if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory(); @@ -1345,6 +1349,8 @@ class CodemanApp { } connectSSE() { + if (this._instanceShutdownRequested) return; + // Check if browser is offline if (!navigator.onLine) { this.setConnectionStatus('offline'); @@ -1403,6 +1409,11 @@ class CodemanApp { this.setConnectionStatus('connected'); }; this.eventSource.onerror = () => { + if (this._instanceShutdownRequested) { + this.eventSource?.close(); + this.eventSource = null; + return; + } this.reconnectAttempts++; if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.setConnectionStatus('disconnected'); @@ -4630,6 +4641,102 @@ class CodemanApp { } } + requestInstanceShutdown() { + if (this._instanceShutdownState === 'submitting' || this._instanceShutdownState === 'accepted') return; + + const activeElement = document.activeElement; + if (activeElement?.classList?.contains('xterm-helper-textarea')) { + activeElement.blur(); + } + const modal = document.getElementById('instanceShutdownModal'); + const status = document.getElementById('instanceShutdownStatus'); + const closeButton = document.getElementById('instanceShutdownCloseBtn'); + const cancelButton = document.getElementById('instanceShutdownCancelBtn'); + const confirmButton = document.getElementById('instanceShutdownConfirmBtn'); + if (!modal || !status || !closeButton || !cancelButton || !confirmButton) return; + + this._instanceShutdownState = 'confirming'; + status.hidden = true; + status.classList.remove('error'); + status.textContent = ''; + closeButton.disabled = false; + cancelButton.disabled = false; + confirmButton.disabled = false; + confirmButton.textContent = 'Shut down'; + modal.classList.add('active'); + modal.setAttribute('aria-hidden', 'false'); + document.body.classList.add('terminal-action-pending'); + + this._instanceShutdownFocusTrap?.deactivate(); + this._instanceShutdownFocusTrap = new FocusTrap(modal); + this._instanceShutdownFocusTrap.activate(); + requestAnimationFrame(() => cancelButton.focus()); + } + + cancelInstanceShutdown() { + if (this._instanceShutdownState === 'submitting' || this._instanceShutdownState === 'accepted') return; + + const modal = document.getElementById('instanceShutdownModal'); + modal?.classList.remove('active'); + modal?.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('terminal-action-pending'); + this._instanceShutdownFocusTrap?.deactivate(); + this._instanceShutdownFocusTrap = null; + this._instanceShutdownState = 'idle'; + } + + async confirmInstanceShutdown() { + if (this._instanceShutdownState !== 'confirming') return; + + const modal = document.getElementById('instanceShutdownModal'); + const status = document.getElementById('instanceShutdownStatus'); + const closeButton = document.getElementById('instanceShutdownCloseBtn'); + const cancelButton = document.getElementById('instanceShutdownCancelBtn'); + const confirmButton = document.getElementById('instanceShutdownConfirmBtn'); + if (!modal || !status || !closeButton || !cancelButton || !confirmButton) return; + + this._instanceShutdownState = 'submitting'; + closeButton.disabled = true; + cancelButton.disabled = true; + confirmButton.disabled = true; + confirmButton.textContent = 'Stopping...'; + status.hidden = false; + status.classList.remove('error'); + status.textContent = 'Requesting a graceful shutdown...'; + + try { + const response = await fetch('/api/system/shutdown', { method: 'POST' }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error || 'Codeman could not be stopped'); + } + + const result = payload?.success === true ? payload.data : payload; + this._instanceShutdownState = 'accepted'; + this._instanceShutdownRequested = true; + status.textContent = result?.alreadyScheduled + ? 'Codeman shutdown is already in progress.' + : 'Codeman is stopping. You can close this page.'; + confirmButton.textContent = 'Stopping...'; + this._clearTimer('sseReconnectTimeout'); + this.eventSource?.close(); + this.eventSource = null; + this._disconnectWs(); + modal.focus(); + } catch (error) { + this._instanceShutdownState = 'confirming'; + closeButton.disabled = false; + cancelButton.disabled = false; + confirmButton.disabled = false; + confirmButton.textContent = 'Try again'; + status.hidden = false; + status.classList.add('error'); + status.textContent = error?.message || 'Codeman could not be stopped'; + this.showToast?.(status.textContent, 'error'); + cancelButton.focus(); + } + } + nextSession() { if (this.sessionOrder.length <= 1) return; diff --git a/src/web/public/index.html b/src/web/public/index.html index 224280db..d13959cb 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -145,6 +145,9 @@ + @@ -1222,6 +1225,26 @@

Close Session

+ + +