Repository Public
1/**2 * Terminal sessions: one tmux session `cr-<uuid>` per session, running the real `claude`3 * (or a login shell) through the user's login shell. See docs/CONTRACT.md "Terminal design".4 */5import { z } from 'zod';6import { RtSessionSpec, type RtSessionInfo } from '@cr/shared';7import { RtError, type RuntimePlugin } from './plugins.js';8import { assertInsideWorkspace, cleanUserEnv, envInt, getCore, shQuote, sleep, type CoreContext } from './core-util.js';9import { AttachManager, MAX_INBOUND_FRAME_BYTES } from './core-attach.js';10import { paneExitCode, type TmuxSessionRow } from './tmux.js';11import { findForbiddenCodexArg, findInvalidCodexArgs } from './codex-args.js';1213const SESSION_PREFIX = 'cr-';14const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;15const ACTIVITY_MIN_INTERVAL_MS = 5000;16const SOFT_KILL_WAIT_MS = 5000;1718const IdParams = z.object({ id: z.string().regex(UUID_RE) });19const AttachQuery = z.object({20 cols: z.coerce.number().int().catch(80),21 rows: z.coerce.number().int().catch(24),22});23const DeleteQuery = z.object({ force: z.string().optional() });2425const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n));2627/**28 * Claude Remote never bypasses Claude Code's permission system. Any argument that would do29 * so is refused (exact flag, `--flag=value` form, or `--permission-mode bypassPermissions`).30 */31export function findForbiddenClaudeArg(args: readonly string[]): string | null {32 for (let i = 0; i < args.length; i++) {33 const a = (args[i] ?? '').trim();34 const flag = a.split('=')[0] ?? '';35 if (flag === '--dangerously-skip-permissions' || flag === '--allow-dangerously-skip-permissions') return flag;36 if (flag === '--permission-mode') {37 const value = a.includes('=') ? a.slice(a.indexOf('=') + 1) : (args[i + 1] ?? '');38 if (value.trim().toLowerCase() === 'bypasspermissions') return '--permission-mode bypassPermissions';39 }40 if (/bypasspermissions/i.test(a) && a.startsWith('-')) return a;41 }42 return null;43}4445/**46 * Defence in depth for `--resume <id>` / `-r <id>`: the value that follows must be a plain47 * identifier. A value starting with "-" would be parsed by the CLI as another option (which is48 * how a bypass flag could be smuggled in as a "conversation id").49 */50export function findInvalidResumeArg(args: readonly string[]): string | null {51 for (let i = 0; i < args.length; i++) {52 const a = args[i] ?? '';53 let value: string | undefined;54 if (a === '--resume' || a === '-r') value = args[i + 1];55 else if (a.startsWith('--resume=')) value = a.slice('--resume='.length);56 else continue;57 if (value === undefined) continue; // bare --resume opens Claude's own picker58 if (value.trim().startsWith('-')) return 'the value after --resume must not start with "-"';59 }60 return null;61}6263/** argv executed directly by tmux: the login shell, so PATH matches an interactive terminal. */64export function sessionArgv(kind: 'claude' | 'shell', shell: string, claudeBin: string, args: readonly string[]): string[] {65 if (kind === 'shell') return [shell, '-l'];66 return [shell, '-lc', `exec ${[claudeBin, ...args].map(shQuote).join(' ')}`];67}6869function toInfo(row: TmuxSessionRow): RtSessionInfo {70 return {71 id: row.name.slice(SESSION_PREFIX.length),72 alive: !row.paneDead,73 pid: row.panePid,74 attached: row.attached,75 lastActivityMs: row.activitySec === null ? null : row.activitySec * 1000,76 exitCode: paneExitCode(row),77 cols: row.width,78 rows: row.height,79 };80}8182interface Watched {83 activitySec: number | null;84 attached: number;85 lastEmitMs: number;86 exited: boolean;87}8889export class SessionManager {90 readonly attach: AttachManager;91 private readonly watched = new Map<string, Watched>();92 private timer: NodeJS.Timeout | null = null;93 private readonly nudges = new Set<NodeJS.Timeout>();94 private polling = false;95 private createChain: Promise<unknown> = Promise.resolve();9697 constructor(98 private readonly core: CoreContext,99 attach: AttachManager,100 ) {101 this.attach = attach;102 }103104 private async rows(): Promise<TmuxSessionRow[]> {105 return (await this.core.tmux.listSessions()).filter((r) => UUID_RE.test(r.name.slice(SESSION_PREFIX.length)) && r.name.startsWith(SESSION_PREFIX));106 }107108 async list(): Promise<RtSessionInfo[]> {109 return (await this.rows()).map(toInfo);110 }111112 async row(id: string): Promise<TmuxSessionRow> {113 const row = (await this.rows()).find((r) => r.name === SESSION_PREFIX + id);114 if (!row) throw new RtError(404, 'not_found', 'no such session');115 return row;116 }117118 /** Creations are serialized so the 409 / 429 checks cannot race each other. */119 create(spec: RtSessionSpec): Promise<RtSessionInfo> {120 const run = this.createChain.then(() => this.createNow(spec));121 this.createChain = run.catch(() => undefined);122 return run;123 }124125 private async createNow(spec: RtSessionSpec): Promise<RtSessionInfo> {126 const { config, tmux, hookToken } = this.core;127 if (spec.args.length > 64 || spec.args.some((a) => a.length > 8192 || a.includes('\0'))) {128 throw new RtError(400, 'bad_request', 'invalid args');129 }130 if (spec.kind === 'claude' && config.agent === 'codex') {131 // `claude` is the wire name of "the instance's agent": here the Codex CLI.132 const bad = findForbiddenCodexArg(spec.args);133 if (bad) throw new RtError(400, 'forbidden_flag', `approval/sandbox bypass flags and configuration overrides are not allowed (${bad})`);134 const badShape = findInvalidCodexArgs(spec.args);135 if (badShape) throw new RtError(400, 'invalid_resume', badShape);136 } else if (spec.kind === 'claude') {137 const bad = findForbiddenClaudeArg(spec.args);138 if (bad) throw new RtError(400, 'forbidden_flag', `permission-bypass flags are not allowed (${bad})`);139 const badResume = findInvalidResumeArg(spec.args);140 if (badResume) throw new RtError(400, 'invalid_resume', badResume);141 }142 const cwd = await assertInsideWorkspace(config, spec.cwd);143 const userEnv = cleanUserEnv(spec.env);144 const existing = await this.rows();145 const name = SESSION_PREFIX + spec.id;146 if (existing.some((r) => r.name === name)) throw new RtError(409, 'exists', 'session already exists');147 if (existing.filter((r) => !r.paneDead).length >= config.maxSessions) {148 throw new RtError(429, 'too_many_sessions', `session limit reached (${config.maxSessions})`);149 }150 await tmux.newSession({151 name,152 cwd,153 cols: clamp(spec.cols, 20, 500),154 rows: clamp(spec.rows, 5, 200),155 env: {156 ...userEnv,157 TERM: 'xterm-256color',158 COLORTERM: 'truecolor',159 CR_SESSION_ID: spec.id,160 // The hook endpoint is reachable with a token that is useless anywhere else.161 CR_HOOK_SOCKET: config.socketPath,162 CR_HOOK_TOKEN: hookToken,163 },164 argv: sessionArgv(spec.kind, config.shell, config.claudeBin, spec.args),165 });166 const row = await tmux.getSession(name);167 if (!row) throw new RtError(500, 'spawn_failed', 'tmux session vanished right after creation');168 this.watched.set(spec.id, { activitySec: row.activitySec, attached: row.attached, lastEmitMs: 0, exited: false });169 return toInfo(row);170 }171172 /** `force` kills immediately; otherwise SIGHUP the pane process, wait up to 5 s, then kill. */173 async remove(id: string, force: boolean): Promise<void> {174 const row = await this.row(id);175 if (!force && !row.paneDead && row.panePid !== null && row.panePid > 1) {176 try {177 process.kill(row.panePid, 'SIGHUP');178 } catch {179 /* already gone */180 }181 const deadline = Date.now() + SOFT_KILL_WAIT_MS;182 while (Date.now() < deadline) {183 await sleep(100);184 const r = await this.core.tmux.getSession(row.name);185 if (!r || r.paneDead) break;186 }187 }188 const last = await this.core.tmux.getSession(row.name);189 await this.core.tmux.killSession(row.name);190 this.noteExit(id, last ? paneExitCode(last) : null);191 this.watched.delete(id);192 }193194 async snapshot(id: string): Promise<string> {195 const row = await this.row(id);196 return this.core.tmux.capturePane(row.name, 200);197 }198199 /** Emits `session.exit` exactly once per session and releases its attach clients. */200 private noteExit(id: string, code: number | null): void {201 const w = this.watched.get(id) ?? { activitySec: null, attached: 0, lastEmitMs: 0, exited: false };202 if (w.exited) return;203 w.exited = true;204 this.watched.set(id, w);205 this.core.bus.emit({ type: 'session.exit', id, exitCode: code });206 this.attach.notifyExit(id, code);207 }208209 /** One `list-sessions` call: exit detection + rate-limited activity events. */210 async poll(): Promise<void> {211 if (this.polling) return;212 this.polling = true;213 try {214 const rows = await this.rows();215 const now = Date.now();216 const seen = new Set<string>();217 for (const row of rows) {218 const id = row.name.slice(SESSION_PREFIX.length);219 seen.add(id);220 let w = this.watched.get(id);221 if (!w) {222 w = { activitySec: row.activitySec, attached: row.attached, lastEmitMs: 0, exited: false };223 this.watched.set(id, w);224 }225 if (row.paneDead) {226 this.noteExit(id, paneExitCode(row));227 continue;228 }229 // A client attaching or detaching is reported at once (rare, and the server shows it);230 // plain output activity is rate-limited.231 const attachChanged = row.attached !== w.attached;232 const changed = row.activitySec !== w.activitySec || attachChanged;233 if (changed && (attachChanged || now - w.lastEmitMs >= ACTIVITY_MIN_INTERVAL_MS)) {234 // The remembered values only move when an event is emitted, so a change that235 // arrives inside the 5 s window is reported by a later poll instead of being lost.236 w.activitySec = row.activitySec;237 w.attached = row.attached;238 w.lastEmitMs = now;239 this.core.bus.emit({240 type: 'session.activity',241 id,242 atMs: row.activitySec === null ? now : row.activitySec * 1000,243 attached: row.attached,244 });245 }246 }247 for (const id of [...this.watched.keys()]) {248 if (seen.has(id)) continue;249 this.noteExit(id, null); // session vanished (killed outside the runtime): code unknown250 this.watched.delete(id);251 }252 } finally {253 this.polling = false;254 }255 }256257 start(intervalMs: number, onError: (err: unknown) => void): void {258 this.timer = setInterval(() => void this.poll().catch(onError), intervalMs);259 this.timer.unref();260 // An attach client started or ended: do not wait for the next periodic poll to report it.261 // (tmux needs a moment to register / drop the client, hence two looks.)262 this.attach.onClientsChanged = () => {263 for (const delay of [150, 700]) {264 const t = setTimeout(() => {265 this.nudges.delete(t);266 void this.poll().catch(onError);267 }, delay);268 t.unref();269 this.nudges.add(t);270 }271 };272 }273274 stop(): void {275 if (this.timer) clearInterval(this.timer);276 this.timer = null;277 this.attach.onClientsChanged = null;278 for (const t of this.nudges) clearTimeout(t);279 this.nudges.clear();280 this.attach.closeAll();281 }282}283284const sessionsPlugin: RuntimePlugin = async (app) => {285 const core = getCore(app);286 const mgr = new SessionManager(core, new AttachManager(core, app.log));287 mgr.start(envInt('CR_SESSION_POLL_MS', 2000), (err) => app.log.warn({ err }, 'session watcher poll failed'));288 app.addHook('onClose', async () => mgr.stop());289290 app.get('/sessions', async () => mgr.list());291292 app.post('/sessions', async (req) => mgr.create(RtSessionSpec.parse(req.body)));293294 app.get('/sessions/:id', async (req) => {295 const { id } = IdParams.parse(req.params);296 const row = await mgr.row(id);297 return { ...toInfo(row) };298 });299300 app.delete('/sessions/:id', async (req) => {301 const { id } = IdParams.parse(req.params);302 const { force } = DeleteQuery.parse(req.query);303 await mgr.remove(id, force === '1' || force === 'true');304 return { ok: true };305 });306307 app.get('/sessions/:id/snapshot', async (req) => {308 const { id } = IdParams.parse(req.params);309 return { text: await mgr.snapshot(id) };310 });311312 app.get('/sessions/:id/attach', { websocket: true }, (ws, req) => {313 const params = IdParams.safeParse(req.params);314 if (!params.success) {315 ws.close(4404, 'no such session');316 return;317 }318 const q = AttachQuery.parse(req.query);319 mgr.attach.handle(ws, params.data.id, clamp(q.cols, 20, 500), clamp(q.rows, 5, 200));320 });321};322323export { MAX_INBOUND_FRAME_BYTES };324export default sessionsPlugin;325