Repository Public
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