SPB Git

Repository Public

sessions.ts 12.5 KB 325 lines typescript

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
Files main
Menu

Theme