SPB Git

Repository Public

sessions.test.ts 14.5 KB 269 lines typescript

1import fs from 'node:fs';2import path from 'node:path';3import { execFileSync } from 'node:child_process';4import { fileURLToPath } from 'node:url';5import { afterAll, beforeAll, describe, expect, it } from 'vitest';6import type { RtSessionInfo } from '@cr/shared';7import type { Runtime } from './main.js';8import { findForbiddenClaudeArg, findInvalidResumeArg, sessionArgv } from './sessions.js';9import { collectEvents, makeTestEnv, openWs, request, sessionSpec, startRuntime, TermClient, waitUntil, type TestEnv } from './core-testkit.js';10import { XtermClient, flush, screen } from './core-xterm-testkit.js';1112const HOOK_SCRIPT = fileURLToPath(new URL('../bin/cr-hook', import.meta.url));1314describe('claude argv safety (unit)', () => {15  it('rejects every permission-bypass form', () => {16    expect(findForbiddenClaudeArg(['--dangerously-skip-permissions'])).not.toBeNull();17    expect(findForbiddenClaudeArg(['--resume', 'x', '--allow-dangerously-skip-permissions'])).not.toBeNull();18    expect(findForbiddenClaudeArg(['--dangerously-skip-permissions=true'])).not.toBeNull();19    expect(findForbiddenClaudeArg(['--permission-mode', 'bypassPermissions'])).not.toBeNull();20    expect(findForbiddenClaudeArg(['--permission-mode=bypassPermissions'])).not.toBeNull();21    expect(findForbiddenClaudeArg(['--resume', 'abc', '--model', 'opus'])).toBeNull();22    expect(findForbiddenClaudeArg(['--permission-mode', 'plan'])).toBeNull();23  });2425  it('refuses an option-like value after --resume', () => {26    expect(findInvalidResumeArg(['--resume', '--dangerously-skip-permissions'])).not.toBeNull();27    expect(findInvalidResumeArg(['--resume', '-x'])).not.toBeNull();28    expect(findInvalidResumeArg(['-r', ' --model'])).not.toBeNull();29    expect(findInvalidResumeArg(['--resume=-p'])).not.toBeNull();30    expect(findInvalidResumeArg(['--resume', '11111111-aaaa-bbbb-cccc-222222222222'])).toBeNull();31    expect(findInvalidResumeArg(['--resume'])).toBeNull();32    expect(findInvalidResumeArg(['--continue'])).toBeNull();33  });3435  it('builds a login-shell argv with single-quote escaping and no bypass flag', () => {36    expect(sessionArgv('shell', '/bin/zsh', 'claude', ['ignored'])).toEqual(['/bin/zsh', '-l']);37    const argv = sessionArgv('claude', '/bin/zsh', 'claude', ['--resume', "it's; rm -rf $HOME"]);38    expect(argv).toEqual(['/bin/zsh', '-lc', `exec 'claude' '--resume' 'it'\\''s; rm -rf $HOME'`]);39    // The quoted word round-trips through a real shell unchanged.40    const out = execFileSync('/bin/sh', ['-c', `printf '%s' ${argv[2]!.slice('exec '.length).split(' ').slice(2).join(' ')}`], { encoding: 'utf8' });41    expect(out).toBe("it's; rm -rf $HOME");42    expect(argv.join(' ')).not.toContain('skip-permissions');43  });44});4546describe('terminal sessions (real tmux)', () => {47  let env: TestEnv;48  let rt: Runtime;49  let sock: string;50  let token: string;51  const saved = { ...process.env };5253  beforeAll(async () => {54    // Secrets present in the runtime's own environment must not reach sessions.55    process.env.CR_RUNTIME_TOKEN = 'runtime-token-that-must-not-leak';56    process.env.DATABASE_URL = 'postgres://secret@127.0.0.1/db';57    process.env.CR_SECRET_KEY = 'deadbeef';58    process.env.CR_SESSION_POLL_MS = '300';59    env = makeTestEnv();60    rt = await startRuntime(env.config);61    sock = env.config.socketPath;62    token = env.config.token;63  });64  afterAll(async () => {65    await rt.stop();66    env.cleanup();67    process.env = saved;68  });6970  const create = (extra: Record<string, unknown> = {}) => request(sock, 'POST', '/sessions', { token, body: sessionSpec(env, extra) });71  const info = async (id: string) => (await request(sock, 'GET', `/sessions/${id}`, { token })).json<RtSessionInfo>();7273  it('full lifecycle: create → attach → io → resize → detach → re-attach → exit', async () => {74    const events = await collectEvents(sock, token);75    const spec = sessionSpec(env);76    const id = spec.id as string;77    const created = await request(sock, 'POST', '/sessions', { token, body: spec });78    expect(created.status).toBe(200);79    expect(created.json<RtSessionInfo>()).toMatchObject({ id, alive: true, exitCode: null, attached: 0 });80    expect((await request(sock, 'POST', '/sessions', { token, body: spec })).status).toBe(409);81    expect((await request(sock, 'GET', '/sessions', { token })).json<RtSessionInfo[]>().map((s) => s.id)).toContain(id);8283    const a = await TermClient.open(sock, id, token, 100, 30);84    expect(await a.waitMsg('ready')).toMatchObject({ sessionId: id, cols: 100, rows: 30 });85    a.type('export CR_MARK=persist-$((6*7))\n');86    a.type('echo hello-$((40+2))\n');87    await a.waitOutput('hello-42');88    await waitUntil(async () => (await info(id)).attached === 1, 5000, 'attached=1');8990    a.control({ t: 'resize', cols: 120, rows: 40 });91    await waitUntil(async () => {92      const s = await info(id);93      return s.cols === 120 && s.rows === 40;94    }, 5000, 'resize to reach tmux');95    a.control({ t: 'ping', n: 7 });96    expect(await a.waitMsg('pong')).toEqual({ t: 'pong', n: 7 });97    a.control({ t: 'resize', cols: 1 }); // invalid control message: reported, connection kept98    expect((await a.waitMsg('error')).code).toBe('bad_message');99100    await a.close(); // detach = only the attach client dies101    await waitUntil(async () => (await info(id)).attached === 0, 5000, 'attached=0');102    expect((await info(id)).alive).toBe(true);103104    const b = await TermClient.open(sock, id, token, 90, 25);105    await b.waitMsg('ready');106    b.type('echo mark=$CR_MARK\n');107    await b.waitOutput('mark=persist-42'); // same process, state intact108109    const snap = (await request(sock, 'GET', `/sessions/${id}/snapshot`, { token })).json<{ text: string }>();110    expect(snap.text).toContain('hello-42');111112    await waitUntil(() => events.events.some((e) => e.type === 'session.activity' && e.id === id), 10_000, 'session.activity');113114    b.type('exit 7\n');115    expect(await b.waitMsg('exit')).toEqual({ t: 'exit', code: 7 });116    expect(await b.waitClose()).toBe(4410);117    await waitUntil(() => events.events.some((e) => e.type === 'session.exit' && e.id === id), 5000, 'session.exit');118    await new Promise((r) => setTimeout(r, 1000)); // several more polls: still exactly one exit event119    const exits = events.events.filter((e) => e.type === 'session.exit' && e.id === id);120    expect(exits).toEqual([{ type: 'session.exit', id, exitCode: 7 }]);121    expect(await info(id)).toMatchObject({ alive: false, exitCode: 7 });122123    // Attaching to a dead session reports the exit immediately.124    const c = await TermClient.open(sock, id, token);125    expect(await c.waitMsg('exit')).toEqual({ t: 'exit', code: 7 });126    expect(await c.waitClose()).toBe(4410);127128    expect((await request(sock, 'DELETE', `/sessions/${id}?force=1`, { token })).status).toBe(200);129    expect((await request(sock, 'GET', `/sessions/${id}`, { token })).status).toBe(404);130    expect((await request(sock, 'DELETE', `/sessions/${id}`, { token })).status).toBe(404);131    events.close();132  });133134  it('gives the session its id and hook access but none of the runtime secrets', async () => {135    const res = await create({ env: { MY_VAR: 'from-spec', CR_RUNTIME_TOKEN: 'nope' } });136    const id = res.json<RtSessionInfo>().id;137    const t = await TermClient.open(sock, id, token);138    t.type('echo "sid=$CR_SESSION_ID my=$MY_VAR tok=${CR_RUNTIME_TOKEN:-none} db=${DATABASE_URL:-none} key=${CR_SECRET_KEY:-none} term=$TERM ct=$COLORTERM"\n');139    await t.waitOutput(`sid=${id} my=from-spec tok=none db=none key=none term=xterm-256color ct=truecolor`);140    t.type('echo "hook=${CR_HOOK_TOKEN}@${CR_HOOK_SOCKET}"\n');141    await t.waitOutput(`hook=${rt.core.hookToken}@${sock}`);142    expect(rt.core.hookToken).not.toBe(token);143144    // End to end: cr-hook run inside the session reaches /hooks/claude with the hook token.145    const events = await collectEvents(sock, token);146    t.type(`printf '%s' '{"hook_event_name":"Notification","session_id":"conv-123","message":"Claude needs your permission"}' | ${HOOK_SCRIPT}; echo rc=$?\n`);147    await t.waitOutput('rc=0');148    const hook = await waitUntil(() => events.events.find((e) => e.type === 'claude.hook'), 5000, 'claude.hook');149    expect(hook).toEqual({ type: 'claude.hook', id, event: 'Notification', conversationId: 'conv-123', message: 'Claude needs your permission' });150    events.close();151    await t.close();152    await request(sock, 'DELETE', `/sessions/${id}?force=1`, { token });153  });154155  it('two concurrent sessions do not cross-talk', async () => {156    const [ia, ib] = [(await create()).json<RtSessionInfo>().id, (await create()).json<RtSessionInfo>().id];157    const [a, b] = await Promise.all([TermClient.open(sock, ia, token), TermClient.open(sock, ib, token)]);158    a.type('echo only-in-A-$((100+1))\n');159    b.type('echo only-in-B-$((200+2))\n');160    await Promise.all([a.waitOutput('only-in-A-101'), b.waitOutput('only-in-B-202')]);161    await new Promise((r) => setTimeout(r, 500));162    expect(a.output).not.toContain('only-in-B');163    expect(b.output).not.toContain('only-in-A');164    await Promise.all([a.close(), b.close()]);165    // Graceful delete: SIGHUP, then the session is gone and an exit event was emitted.166    const events = await collectEvents(sock, token);167    expect((await request(sock, 'DELETE', `/sessions/${ia}`, { token })).status).toBe(200);168    expect((await request(sock, 'GET', `/sessions/${ia}`, { token })).status).toBe(404);169    expect(events.events.filter((e) => e.type === 'session.exit' && e.id === ia)).toHaveLength(1);170    events.close();171    await request(sock, 'DELETE', `/sessions/${ib}?force=1`, { token });172  });173174  it('rejects oversized inbound frames, unknown sessions and bad specs', async () => {175    const id = (await create()).json<RtSessionInfo>().id;176    const t = await TermClient.open(sock, id, token);177    await t.waitMsg('ready');178    t.ws.send(Buffer.alloc(1024 * 1024 + 1, 0x61), { binary: true });179    // ws refuses the frame (maxPayload): 1009, or 1006 when the client is cut off mid-send.180    expect([1009, 1006]).toContain(await t.waitClose());181    expect((await info(id)).alive).toBe(true); // the session itself is unaffected182    await request(sock, 'DELETE', `/sessions/${id}?force=1`, { token });183184    const ghost = await TermClient.open(sock, '00000000-0000-4000-8000-000000000000', token);185    expect(await ghost.waitClose()).toBe(4404);186187    for (const args of [['--dangerously-skip-permissions'], ['--allow-dangerously-skip-permissions'], ['--permission-mode=bypassPermissions']]) {188      const res = await create({ kind: 'claude', args });189      expect(res.status).toBe(400);190      expect(res.json<{ error: { code: string } }>().error.code).toBe('forbidden_flag');191    }192    expect((await create({ id: 'not-a-uuid' })).status).toBe(400);193    expect((await create({ cwd: '/usr' })).status).toBe(403);194    expect((await create({ cwd: path.join(env.workspace, 'missing') })).status).toBe(400);195    fs.symlinkSync('/usr', path.join(env.workspace, 'escape'));196    expect((await create({ cwd: path.join(env.workspace, 'escape') })).status).toBe(403);197    expect((await create({ env: { 'BAD NAME': 'x' } })).status).toBe(400);198  });199200  it('claude smoke: the real CLI starts under tmux and its TUI renders in the browser terminal (no prompt sent)', async (ctx) => {201    let claudeBin: string;202    try {203      claudeBin = execFileSync('/bin/sh', ['-lc', 'command -v claude'], { encoding: 'utf8' }).trim();204    } catch {205      return ctx.skip();206    }207    if (!claudeBin) return ctx.skip();208    const env2 = makeTestEnv({ shell: process.env.SHELL ?? '/bin/zsh', claudeBin });209    const rt2 = await startRuntime(env2.config);210    try {211      const res = await request(env2.config.socketPath, 'POST', '/sessions', { token: env2.config.token, body: sessionSpec(env2, { kind: 'claude' }) });212      expect(res.status).toBe(200);213      const id = res.json<RtSessionInfo>().id;214      const t = await XtermClient.open(env2, id, 120, 40);215      await waitUntil(() => t.bytes > 0, 15_000, 'any output bytes');216      // Stronger than "any bytes" (tmux itself repaints on attach): claude drew something in the pane.217      const snapshot = async (): Promise<string[]> =>218        (await request(env2.config.socketPath, 'GET', `/sessions/${id}/snapshot`, { token: env2.config.token })).json<{ text: string }>().text.split('\n').map((l) => l.trimEnd());219      await waitUntil(async () => (await snapshot()).join('').trim().length > 20, 20_000, 'claude to render its UI');220      // ...and the browser terminal (a real xterm parser fed with the bytes we send) shows that221      // TUI, in its normal buffer (native scrollback), once the screen is stable.222      await waitUntil(async () => {223        const pane = (await snapshot()).filter((l) => l.trim() !== '');224        await flush(t.term);225        const shown = screen(t.term).map((l) => l.trimEnd()).filter((l) => l.trim() !== '');226        return pane.length > 0 && pane.every((l) => shown.includes(l));227      }, 30_000, 'browser screen = pane content');228      expect(t.term.buffer.active.type).toBe('normal');229      const s = (await request(env2.config.socketPath, 'GET', `/sessions/${id}`, { token: env2.config.token })).json<RtSessionInfo>();230      expect(s.alive).toBe(true); // still running: it did not crash at startup231      await t.close();232      expect((await request(env2.config.socketPath, 'DELETE', `/sessions/${id}?force=1`, { token: env2.config.token })).status).toBe(200);233    } finally {234      await rt2.stop();235      env2.cleanup();236    }237  });238});239240describe('maxSessions', () => {241  let env: TestEnv;242  let rt: Runtime;243  beforeAll(async () => {244    env = makeTestEnv({ maxSessions: 2 });245    rt = await startRuntime(env.config);246  });247  afterAll(async () => {248    await rt.stop();249    env.cleanup();250  });251252  it('returns 429 over the limit, even for concurrent creations; dead sessions do not count', async () => {253    const post = () => request(env.config.socketPath, 'POST', '/sessions', { token: env.config.token, body: sessionSpec(env) });254    const results = await Promise.all([post(), post(), post(), post()]);255    expect(results.map((r) => r.status).sort()).toEqual([200, 200, 429, 429]);256    expect(results.find((r) => r.status === 429)!.json<{ error: { code: string } }>().error.code).toBe('too_many_sessions');257258    const first = results.find((r) => r.status === 200)!.json<RtSessionInfo>().id;259    const t = await TermClient.open(env.config.socketPath, first, env.config.token);260    t.type('exit 0\n');261    expect(await t.waitMsg('exit')).toEqual({ t: 'exit', code: 0 });262    expect((await post()).status).toBe(200);263  });264265  it('rejects an unauthenticated attach before any PTY exists', async () => {266    await expect(openWs(env.config.socketPath, '/sessions/00000000-0000-4000-8000-000000000000/attach', null)).rejects.toThrow('HTTP 401');267  });268});269
Files main
Menu

Theme