Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
190 changes: 190 additions & 0 deletions src/web/instance-shutdown.ts
Original file line number Diff line number Diff line change
@@ -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<ResolvedShutdownStrategy, { kind: 'manual' | 'unsupported' }>
): ChildProcess {
const { command, args } = buildSupervisorShutdownCommand(strategy);
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
child.unref();
return child;
}

export function buildSupervisorShutdownCommand(
strategy: Exclude<ResolvedShutdownStrategy, { kind: 'manual' | 'unsupported' }>
): { 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 };
}
1 change: 1 addition & 0 deletions src/web/ports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
20 changes: 20 additions & 0 deletions src/web/ports/instance-control-port.ts
Original file line number Diff line number Diff line change
@@ -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<InstanceShutdownResult>;
}
107 changes: 107 additions & 0 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1345,6 +1349,8 @@ class CodemanApp {
}

connectSSE() {
if (this._instanceShutdownRequested) return;

// Check if browser is offline
if (!navigator.onLine) {
this.setConnectionStatus('offline');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;

Expand Down
Loading