Merge remote-tracking branch 'origin/master' into relprep/pr-510

Brings the 1.33.2 based adoption feature up to master. Conflicts were
resolved by keeping master's intent and re-applying the feature on top:
both sides kept for imports, the script tag and the stylesheet blocks; the
trust-dialog guard keeps master's stopped check plus the adopted refusal;
the Ralph and image watchers keep master's remote branch plus the adopted
skip; the close confirmation keeps master's worktree option plus the
adopted wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:51:00 +01:00
commit 57b2dcc466
858 files changed
+173626 -7078

No files matched your search

+7 -2
View File
@@ -6,8 +6,9 @@
*/
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { assertSandboxedHome, createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { MAX_ROUTE_PARAM_LENGTH } from '../../src/web/route-helpers.js';
export interface RouteTestHarness {
app: FastifyInstance;
@@ -30,7 +31,11 @@ export async function createRouteTestHarness(
registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } }
): Promise<RouteTestHarness> {
const app = Fastify({ logger: false });
// Route tests write the linked-cases, docker and settings files. Refuse to build a harness against
// a real home (a bare vitest has no setupFiles), instead of finding out from a wiped registry.
assertSandboxedHome('A route test');
const app = Fastify({ logger: false, routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH } });
// Register cookie plugin — some routes access req.cookies
await app.register(fastifyCookie);
@@ -155,6 +155,37 @@ describe('agent-created case marker', () => {
expect(await readMarker(name)).toBeNull();
});
it('never labels the working directory of a POST /api/sessions, header or not', async () => {
// `codeman agent` sends the origin only on quick-start, but the server must not
// trust that: a session created on an EXISTING workingDir (somebody's real repo)
// is never marked as agent scratch, whatever the request carries.
const name = 'existingrepo1';
created.push(name);
const dir = join(getCasesDir(), name);
await mkdir(dir, { recursive: true });
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
headers: { 'x-codeman-agent-origin': 'codeman-agent-cli', 'x-codeman-parent-session': PARENT_ID },
payload: { workingDir: dir, mode: 'claude' },
});
expect(res.statusCode).toBe(200);
expect(await readMarker(name)).toBeNull();
});
it('labels a case quick-start creates for `codeman agent spawn`, and not one that existed', async () => {
await quickStart('agentclicase1', { headers: { 'x-codeman-agent-origin': 'codeman-agent-cli' } });
expect(await readMarker('agentclicase1')).toMatchObject({ createdBy: 'codeman-agent-cli' });
const name = 'preexisting2';
created.push(name);
await mkdir(join(getCasesDir(), name), { recursive: true });
await quickStart(name, { headers: { 'x-codeman-agent-origin': 'codeman-agent-cli' } });
expect(await readMarker(name)).toBeNull();
});
it('drops an unrecognised origin token rather than storing it', async () => {
await quickStart('agentcase4', { payload: { agentOrigin: '<script>alert(1)</script>' } });
+355
View File
@@ -0,0 +1,355 @@
/** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
AGENT_COORDINATION_DISABLED_MESSAGE,
registerAgentSessionRoutes,
} from '../../src/web/routes/agent-session-routes.js';
import { InspectionReader } from '../../src/agent-inspection.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
let harness: RouteTestHarness;
/** The routes are opt-in (`agentCoordinationEnabled`, default OFF); undefined = key absent. */
function setEnabled(on: boolean | undefined): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { agentCoordinationEnabled: on }));
}
beforeEach(() => setEnabled(true));
afterEach(async () => {
await harness?.app.close();
rmSync(SETTINGS_PATH, { force: true });
});
describe('the agentCoordinationEnabled gate', () => {
const requests = (id: string) => [
{ method: 'GET' as const, url: `/api/sessions/${id}/inspect` },
{ method: 'GET' as const, url: `/api/sessions/${id}/inspect?maxBytes=0` },
{ method: 'POST' as const, url: `/api/sessions/${id}/stage`, payload: { message: 'hello' } },
{
method: 'POST' as const,
url: `/api/sessions/${id}/submit`,
payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) },
},
];
it.each([
['absent', undefined],
['false', false],
] as const)('answers 403 naming the setting on every route while it is %s', async (_label, value) => {
setEnabled(value);
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
// A session id that does not exist is refused the same way: the gate runs before lookup.
for (const request of [...requests(session.id), ...requests('no-such-session')]) {
const result = await harness.app.inject(request);
expect(result.statusCode).toBe(403);
expect(result.json()).toEqual({
success: false,
error: AGENT_COORDINATION_DISABLED_MESSAGE,
errorCode: 'FORBIDDEN',
});
}
expect(AGENT_COORDINATION_DISABLED_MESSAGE).toContain('agentCoordinationEnabled');
expect(session.writeBuffer).toEqual([]);
});
it('answers on every route once it is on, with no restart', async () => {
setEnabled(false);
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
expect((await harness.app.inject(requests(session.id)[0])).statusCode).toBe(403);
setEnabled(true);
const [inspect, invalid, stage, submit] = await Promise.all(
requests(session.id).map((request) => harness.app.inject(request))
);
expect(inspect.statusCode).toBe(200);
expect(inspect.json().data.source).toBe('pane-snapshot');
expect(invalid.statusCode).toBe(400);
// The mock session has no characterized composer, so the guards refuse: the route answered.
expect(stage.statusCode).toBe(409);
expect(submit.statusCode).toBe(409);
});
});
it('reports a non-cursor inspection failure as OPERATION_FAILED and logs only the session and error code', async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { Session } = await import('../../src/session.js');
const root = await mkdtemp(join(tmpdir(), 'agent-inspect-fail-'));
const home = join(root, 'codex');
await mkdir(join(home, 'sessions'), { recursive: true });
const session = new Session({
id: 'inspect-fail',
workingDir: root,
mode: 'codex',
envOverrides: { CODEX_HOME: home },
});
const id = '11111111-1111-4111-8111-111111111111';
const path = join(home, 'sessions', `rollout-${id}.jsonl`);
await writeFile(
path,
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'
);
harness = await createRouteTestHarness(registerAgentSessionRoutes);
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
const read = vi
.spyOn(InspectionReader.prototype, 'read')
.mockRejectedValue(Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { code: 'EACCES' }));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` });
expect(read).toHaveBeenCalled();
expect(result.statusCode).toBe(422);
expect(result.json().errorCode).toBe('OPERATION_FAILED');
expect(result.body).not.toContain(path);
const logged = warn.mock.calls.map((call) => call.join(' ')).join('\n');
expect(logged).toContain(session.id);
expect(logged).toContain('EACCES');
expect(logged).not.toContain(home);
} finally {
read.mockRestore();
warn.mockRestore();
await session.stop(false);
await rm(root, { recursive: true, force: true });
}
});
describe('agent session routes', () => {
it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => {
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=20` });
expect(result.statusCode).toBe(200);
const body = result.json();
expect(body.data.source).toBe('pane-snapshot');
expect(body.data.cursor).toBeNull();
expect(Buffer.byteLength(body.data.evidence)).toBeLessThanOrEqual(20);
});
it('refuses guarded staging for an uncharacterized composer', async () => {
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
const result = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: 'hello' },
});
expect(result.statusCode).toBe(409);
});
});
it('checks ownership before inspection, stage and submit', async () => {
process.env.CODEMAN_MULTIUSER = '1';
try {
harness = await createRouteTestHarness(registerAgentSessionRoutes, {
authUser: { username: 'alice', role: 'user' },
});
const session = [...harness.ctx.sessions.values()][0];
session.owner = 'bob';
for (const request of [
{ method: 'GET' as const, url: `/api/sessions/${session.id}/inspect` },
{ method: 'POST' as const, url: `/api/sessions/${session.id}/stage`, payload: { message: 'hello' } },
{
method: 'POST' as const,
url: `/api/sessions/${session.id}/submit`,
payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) },
},
])
expect((await harness.app.inject(request)).statusCode).toBe(404);
expect(session.writeBuffer).toEqual([]);
} finally {
delete process.env.CODEMAN_MULTIUSER;
}
});
it('validates output budgets and refuses to resume a pane fallback', async () => {
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
session.terminalBuffer = '😀é'.repeat(20);
for (const value of ['0', '-1', '65537', '1.5', 'NaN'])
expect(
(await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=${value}` })).statusCode
).toBe(400);
const result = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=7` })).json();
expect(result.data.evidence).not.toContain('�');
expect(Buffer.byteLength(result.data.evidence)).toBeLessThanOrEqual(7);
expect(result.data.truncated).toBe(true);
expect(
(
await harness.app.inject({
url: `/api/sessions/${session.id}/inspect?since=11111111-1111-4111-8111-111111111111`,
})
).statusCode
).toBe(409);
});
it('uses real session writes to stage without Enter and refuses intervening API input', async () => {
const { Session } = await import('../../src/session.js');
const { formatPaneSnapshot } = await import('../../src/tmux-manager.js');
harness = await createRouteTestHarness(registerAgentSessionRoutes);
let composer = '';
const writes: string[] = [];
const session = new Session({
id: 'guarded-session',
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 2 + composer.length,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
composer += text;
return true;
},
},
muxSession: { muxName: 'codeman-test', sessionId: 'guarded-session', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
try {
const stage = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: 'hello' },
});
expect(stage.statusCode).toBe(200);
expect(writes).toEqual(['hello']);
const { stageId, sha256 } = stage.json().data;
const submit = () =>
harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/submit`,
payload: { stageId, confirmSha: sha256 },
});
expect((await submit()).statusCode).toBe(200);
expect(writes).toEqual(['hello', '\r']);
expect((await submit()).statusCode).toBe(409);
composer = '';
const next = (
await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: 'again' },
})
).json().data;
await session.writeViaMux('x');
composer = 'again'; // Even restoring the visible text cannot erase the input revision.
const stale = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/submit`,
payload: { stageId: next.stageId, confirmSha: next.sha256 },
});
expect(stale.statusCode).toBe(409);
expect(writes.filter((s) => s === '\r')).toHaveLength(1);
// Codex's command popup opens on `/`; Enter would run the highlighted command.
composer = '';
writes.length = 0;
const slash = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: '/stat' },
});
expect(slash.statusCode).toBe(409);
expect(slash.json().error).toMatch(/starting with "\/".*codeman agent send/);
expect(writes).toEqual([]);
} finally {
await session.stop(false);
}
});
it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its owner', async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { Session } = await import('../../src/session.js');
const root = await mkdtemp(join(tmpdir(), 'agent-inspect-route-'));
const home = join(root, 'codex');
await mkdir(join(home, 'sessions'), { recursive: true });
const session = new Session({
id: 'inspect-session',
workingDir: root,
mode: 'codex',
envOverrides: { CODEX_HOME: home },
});
const id = '11111111-1111-4111-8111-111111111111';
const row =
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n';
await writeFile(join(home, 'sessions', `rollout-${id}.jsonl`), row + '{"text":"hello"}\n');
harness = await createRouteTestHarness(registerAgentSessionRoutes, {
authUser: { username: 'alice', role: 'admin' },
});
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
try {
const first = await harness.app.inject({
url: `/api/sessions/${session.id}/inspect?maxBytes=${Buffer.byteLength(row)}`,
});
expect(first.statusCode).toBe(200);
const data = first.json().data;
expect(data.source).toBe('codex-jsonl');
expect(data.evidence).toBe('{\"text\":\"hello\"}\n');
expect(data.omittedBeforeBytes).toBe(Buffer.byteLength(row));
expect(data.identity).toEqual({ sessionId: session.id, mode: 'codex', conversationId: id });
expect(data.cursor).not.toContain(home);
const next = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` });
expect(next.json().data.evidence).toBe('');
const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null };
access.ptyProcess = { resize: () => {} };
session.resize(80, 24);
access.ptyProcess = null;
expect(
(await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode
).toBe(200);
session.owner = 'changed-owner';
expect(
(await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode
).toBe(409);
} finally {
await session.stop(false);
await rm(root, { recursive: true, force: true });
}
});
it('refuses a stale launch-resume cursor once a new managed rollout conflicts with it', async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { Session } = await import('../../src/session.js');
const root = await mkdtemp(join(tmpdir(), 'agent-resume-conflict-'));
const home = join(root, 'codex');
const old = '11111111-1111-4111-8111-111111111111',
fresh = '22222222-2222-4222-8222-222222222222';
await mkdir(join(home, 'sessions'), { recursive: true });
const session = new Session({
id: 'resumed-session',
workingDir: root,
mode: 'codex',
envOverrides: { CODEX_HOME: home },
codexConfig: { resumeSessionId: old },
});
const row = (id: string, originator: string) =>
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator } }) + '\n';
await writeFile(join(home, 'sessions', `rollout-${old}.jsonl`), row(old, 'original-launch'));
harness = await createRouteTestHarness(registerAgentSessionRoutes);
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
try {
const initial = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data;
expect(initial.source).toBe('codex-jsonl');
await writeFile(join(home, 'sessions', `rollout-${fresh}.jsonl`), row(fresh, `codeman_${session.id}`));
const stale = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${initial.cursor}` });
expect(stale.statusCode).toBe(409);
const fallback = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data;
expect(fallback.source).toBe('pane-snapshot');
expect(fallback.cursor).toBeNull();
} finally {
await session.stop(false);
await rm(root, { recursive: true, force: true });
}
});
+103
View File
@@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie';
import { registerApprovalRoutes } from '../../src/web/routes/approval-routes.js';
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { raiseScreenBlocked } from '../../src/web/screen-blocked.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockSession } from '../mocks/index.js';
@@ -455,6 +456,108 @@ describe('approval routes', () => {
});
});
describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
let harness: RouteTestHarness;
let session: MockSession;
beforeEach(async () => {
harness = await createEnvelopeHarness();
session = harness.ctx.sessions.get(SESSION_ID)!;
session.mode = 'deepseek';
session.terminalBuffer = 'some dsh dialog frame';
});
afterEach(async () => {
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
await harness.app.close();
});
it('creates an answerInTerminal inbox item and omits approvalId from push', async () => {
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const approvals = await listApprovals(harness);
expect(approvals).toHaveLength(1);
expect(approvals[0]).toMatchObject({
sessionId: SESSION_ID,
kind: 'permission',
answerInTerminal: true,
});
const push = harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
expect(push?.[1]).toBeDefined();
expect(push?.[1]).not.toHaveProperty('approvalId');
});
it('refuses Approve/Deny with a terminal-only error', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/answered in the terminal/i);
expect(session.writeBuffer).toEqual([]);
// Item stays pending — it is an alert, not a failed answer.
expect(await listApprovals(harness)).toHaveLength(1);
});
});
describe('approval routes: dialogs read off a hook-less screen (codex) are alert-only', () => {
let harness: RouteTestHarness;
let session: MockSession;
const noopDeps = {
broadcast: () => {},
broadcastSessionStateDebounced: () => {},
sendPushNotifications: () => {},
};
beforeEach(async () => {
harness = await createEnvelopeHarness();
session = harness.ctx.sessions.get(SESSION_ID)!;
session.mode = 'codex';
});
afterEach(async () => {
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
await harness.app.close();
});
it('lists the item, and the page-load sweep keeps it', async () => {
raiseScreenBlocked(session as never, noopDeps, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
const approvals = await listApprovals(harness);
expect(approvals).toHaveLength(1);
expect(approvals[0]).toMatchObject({ sessionId: SESSION_ID, kind: 'permission', answerInTerminal: true });
});
it('refuses an answer with the terminal-only error, not "cannot have pending approvals"', async () => {
// The hooks gate would say a codex session cannot have an item at all, which is false
// about the one the caller holds; the alert-only check runs first.
raiseScreenBlocked(session as never, noopDeps, null);
const [item] = await listApprovals(harness);
for (const payload of [{ action: 'approve' }, { action: 'deny' }, { action: 'option', option: 1 }]) {
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/answer`, payload });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/^Codex approvals must be answered in the terminal/);
}
expect(session.writeBuffer).toEqual([]);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('can still be dismissed', async () => {
raiseScreenBlocked(session as never, noopDeps, null);
const [item] = await listApprovals(harness);
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/dismiss`, payload: {} });
expect(res.statusCode).toBe(200);
expect(await listApprovals(harness)).toHaveLength(0);
});
});
describe('approval routes: multi-user scoping', () => {
const saved: Record<string, string | undefined> = {};
@@ -0,0 +1,389 @@
/**
* @fileoverview POST /api/cases/clone with a `path`: clone INTO that folder (an empty or not yet existing one,
* with no subfolder added) and register it as a linked case. Real filesystem under test/setup.ts's temp
* HOME and a REAL `git` cloning a local bare repo (the path policy itself is in test/case-path.test.ts).
* Port: N/A (app.inject()).
*
* Like case-custom-path-routes.test.ts this suite rewrites the linked-cases registry, so it refuses to
* start outside the sandboxed HOME (a raw `npx vitest` would reach the real ~/.codeman).
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import {
classifyGitFailure,
cloneRepository,
getActiveGitOperationCount,
getQueuedGitOperationCount,
isGitAvailable,
} from '../../src/git-clone.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const clone = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/cases/clone', payload });
describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder', () => {
let root: string;
let origin: string;
let workDir = '';
const git = (args: string[], cwd: string) =>
execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-clone-custom-'));
origin = join(root, 'origin.git');
mkdirSync(origin);
git(['init', '--bare', '--quiet'], origin);
const work = join(root, 'work');
mkdirSync(work);
git(['init', '--quiet'], work);
git(['config', 'user.email', 'test@example.com'], work);
git(['config', 'user.name', 'Codeman Test'], work);
writeFileSync(join(work, 'README.md'), '# fixture\n');
git(['add', '.'], work);
git(['commit', '--quiet', '-m', 'initial'], work);
git(['branch', '-M', 'main'], work);
git(['remote', 'add', 'origin', origin], work);
git(['push', '--quiet', 'origin', 'main'], work);
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin);
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
beforeEach(() => {
if (!isSandboxedHome()) {
throw new Error('case-clone-custom-folder.test.ts rewrites the linked-cases registry: run it via npm test');
}
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder.
workDir = realpathSync(mkdtempSync(join(homedir(), 'clone-custom-')));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (isSandboxedHome()) safeRmHomeTree(LINKED());
});
it('clones into the chosen folder, scaffolds it and registers it as a linked case', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'my-clone');
const res = await clone(app, { name: 'my-clone', repository: origin, path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'my-clone', path: target });
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
expect(linked()).toEqual({ 'my-clone': target });
expect(ctx.broadcast).toHaveBeenCalledWith('case:created', { name: 'my-clone', path: target });
// Nothing lands under the cases directory.
expect(existsSync(join(homedir(), 'codeman-cases', 'my-clone'))).toBe(false);
});
it('expands ~ in the folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const folder = workDir.slice(homedir().length + 1);
const res = await clone(app, { name: 'tilde', repository: origin, path: `~/${folder}/tilde` });
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, 'tilde', 'README.md'))).toBe(true);
});
it('refuses a parent folder that does not exist, and creates nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await clone(app, { name: 'orphan', repository: origin, path: join(workDir, 'nope', 'orphan') });
expect(res.statusCode).toBe(404);
expect(existsSync(join(workDir, 'nope'))).toBe(false);
expect(linked()).toEqual({});
});
it('refuses a target folder that already has files, and leaves them alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'busy');
mkdirSync(target);
writeFileSync(join(target, 'mine.txt'), 'keep me');
const res = await clone(app, { name: 'busy', repository: origin, path: target });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/already has files/);
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
expect(linked()).toEqual({});
});
it('clones straight INTO the chosen empty folder, with no subfolder named after the case', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'picked');
mkdirSync(target);
const res = await clone(app, { name: 'a-different-name', repository: origin, path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'a-different-name', path: target });
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
expect(existsSync(join(target, 'a-different-name'))).toBe(false);
expect(linked()).toEqual({ 'a-different-name': target });
});
it('leaves the empty folder in place, and empty, when the clone fails', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'stays');
mkdirSync(target);
const res = await clone(app, { name: 'stays', repository: join(root, 'no-such-repo.git'), path: target });
expect(res.json().success).toBe(false);
expect(existsSync(target)).toBe(true);
expect(readdirSync(target)).toEqual([]);
expect(linked()).toEqual({});
});
it('two clones into one empty folder at once: exactly one wins and its files survive', async () => {
const target = join(workDir, 'raced');
mkdirSync(target);
const results = await Promise.all([
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
]);
expect(results.filter((r) => r.ok)).toHaveLength(1);
const loser = results.find((r) => !r.ok);
expect(loser && !loser.ok && loser.failure.code).toBe('DESTINATION_EXISTS');
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
});
it('a name linked while the clone ran is a 409 that keeps the folder and names it', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [a, b] = await Promise.all([
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-a') }),
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-b') }),
]);
const loserDir = a.json().success ? join(workDir, 'twin-b') : join(workDir, 'twin-a');
const loser = a.json().success ? b : a;
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
expect(loser.statusCode).toBe(409);
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
expect(loser.json().error).toContain(loserDir);
expect(existsSync(join(loserDir, 'README.md'))).toBe(true);
expect(Object.keys(linked())).toEqual(['twin']);
});
it('a direct clone whose git-slot wait times out leaves what was written into the folder meanwhile', async () => {
const target = join(workDir, 'queued');
mkdirSync(target);
// Two slow ssh clones hold both git slots (no network needed: the ssh command just sleeps).
const prior = process.env.GIT_SSH_COMMAND;
process.env.GIT_SSH_COMMAND = 'sleep 4; true';
try {
const holders = [0, 1].map((i) =>
cloneRepository({
repository: 'ssh://localhost/x.git',
destination: join(workDir, `hold-${i}`),
timeoutMs: 10_000,
})
);
await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2));
const queued = cloneRepository({
repository: origin,
destination: target,
intoEmptyDir: true,
timeoutMs: 1_000,
});
await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1));
// Written during the wait, so not this attempt's: a user copying files in, or another case created there.
writeFileSync(join(target, 'mine.txt'), 'keep me');
mkdirSync(join(target, 'src'));
const result = await queued;
expect(!result.ok && result.failure.code).toBe('TIMEOUT');
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
expect(existsSync(join(target, 'src'))).toBe(true);
await Promise.all(holders);
} finally {
if (prior === undefined) delete process.env.GIT_SSH_COMMAND;
else process.env.GIT_SSH_COMMAND = prior;
}
}, 30_000);
it('refuses a folder at or inside a Docker case workspace, and creates nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const workspace = join(workDir, 'dockerws');
mkdirSync(workspace);
writeFileSync(
join(dataPath('docker-cases.json')),
JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: workspace, container: 'c' }])
);
try {
for (const path of [workspace, join(workspace, 'repo')]) {
const res = await clone(app, { name: 'inside', repository: origin, path });
expect(res.json().errorCode, path).toBe('INVALID_INPUT');
expect(res.json().error, path).toMatch(/Docker case workspace/);
}
expect(readdirSync(workspace)).toEqual([]);
expect(linked()).toEqual({});
// A sibling of the workspace is fine.
const ok = await clone(app, { name: 'beside', repository: origin, path: join(workDir, 'beside') });
expect(ok.statusCode).toBe(200);
} finally {
safeRmHomeTree(dataPath('docker-cases.json'));
}
});
it('fails closed when the Docker case list cannot be read', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
writeFileSync(dataPath('docker-cases.json'), '{not json');
try {
const res = await clone(app, { name: 'blind', repository: origin, path: join(workDir, 'blind') });
expect(res.statusCode).toBe(503);
expect(existsSync(join(workDir, 'blind'))).toBe(false);
} finally {
safeRmHomeTree(dataPath('docker-cases.json'));
}
});
it.each(['plain', 'openssl', 'rustls', 'does not exist'])(
'a refused folder named %s keeps what was written meanwhile and reports DESTINATION_EXISTS',
async (folder) => {
const target = join(workDir, folder);
mkdirSync(target);
const prior = process.env.GIT_SSH_COMMAND;
process.env.GIT_SSH_COMMAND = 'sleep 2; true';
try {
const holders = [0, 1].map((i) =>
cloneRepository({
repository: 'ssh://localhost/x.git',
destination: join(workDir, `hold-${i}`),
timeoutMs: 10_000,
})
);
await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2));
// Queues behind the holders but has time to get a slot, so git runs and refuses the folder.
const queued = cloneRepository({
repository: origin,
destination: target,
intoEmptyDir: true,
timeoutMs: 15_000,
});
await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1));
writeFileSync(join(target, 'mine.txt'), 'keep me');
const result = await queued;
expect(!result.ok && result.failure.code).toBe('DESTINATION_EXISTS');
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
await Promise.all(holders);
} finally {
if (prior === undefined) delete process.env.GIT_SSH_COMMAND;
else process.env.GIT_SSH_COMMAND = prior;
}
},
30_000
);
it('classifies git stderr by what it says, not by words in a path it quotes', () => {
const refusal = (dir: string) => `fatal: destination path '${dir}' already exists and is not an empty directory.`;
for (const dir of ['/home/u/src/openssl', '/home/u/rustls', '/x/tls-test', '/x/repository not found']) {
expect(classifyGitFailure(refusal(dir), false).code, dir).toBe('DESTINATION_EXISTS');
}
expect(classifyGitFailure('fatal: unable to access: SSL certificate problem: self signed', false).code).toBe(
'HOST_UNREACHABLE'
);
expect(classifyGitFailure('fatal: TLS handshake failure', false).code).toBe('HOST_UNREACHABLE');
// `_` is a word character, so a bare \bssl\b misses OpenSSL's and LibreSSL's usual flaky-network error.
expect(
classifyGitFailure(
"fatal: unable to access 'https://github.com/o/r/': OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443",
false
).code
).toBe('HOST_UNREACHABLE');
expect(classifyGitFailure('fatal: could not resolve host: example.invalid', false).code).toBe('HOST_UNREACHABLE');
expect(classifyGitFailure("fatal: repository 'https://x/y.git/' not found", false).code).toBe('NOT_FOUND');
});
it('refuses a clone without a path into the cases folder when it is a Docker case workspace', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
writeFileSync(
dataPath('docker-cases.json'),
JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: casesDir, container: 'c' }])
);
try {
const res = await clone(app, { name: 'default-inside', repository: origin });
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(res.json().error).toMatch(/Docker case workspace/);
expect(existsSync(join(casesDir, 'default-inside'))).toBe(false);
} finally {
safeRmHomeTree(dataPath('docker-cases.json'));
}
});
it('points Clone Repo at itself, not Create New, for the cases folder and a folder directly in it', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
for (const path of [casesDir, join(casesDir, 'direct-child')]) {
const res = await clone(app, { name: 'direct-child', repository: origin, path });
expect(res.statusCode, path).toBe(400);
expect(res.json().error, path).toMatch(/clone there without a custom folder/);
expect(res.json().error, path).not.toMatch(/Create New/);
}
});
it('refuses a blocked tree such as ~/.ssh', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(homedir(), '.ssh'), { recursive: true });
const res = await clone(app, { name: 'sneaky', repository: origin, path: join(homedir(), '.ssh', 'sneaky') });
expect(res.statusCode).toBe(400);
expect(existsSync(join(homedir(), '.ssh', 'sneaky'))).toBe(false);
});
it('refuses a name already linked, before running git', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
writeFileSync(LINKED(), JSON.stringify({ taken: join(workDir, 'elsewhere') }));
const res = await clone(app, { name: 'taken', repository: origin, path: join(workDir, 'taken') });
// The harness has no preSerialization status mapping, so read the envelope's code.
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
expect(existsSync(join(workDir, 'taken'))).toBe(false);
});
it('leaves no folder behind when the clone fails', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'ghost');
const res = await clone(app, { name: 'ghost', repository: join(root, 'no-such-repo.git'), path: target });
expect(res.json().errorCode).toBe('NOT_FOUND');
expect(existsSync(target)).toBe(false);
expect(linked()).toEqual({});
});
it('without a path the clone still goes under the cases directory and is not linked', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await clone(app, { name: 'custom-folder-default', repository: origin });
try {
expect(res.statusCode).toBe(200);
expect(res.json().data.case.path).toBe(join(homedir(), 'codeman-cases', 'custom-folder-default'));
expect(linked()).toEqual({});
} finally {
safeRmHomeTree(join(homedir(), 'codeman-cases', 'custom-folder-default'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is cloned; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const denied = await clone(user.app, { name: 'bobs', repository: origin, path: join(workDir, 'bobs') });
expect(denied.statusCode).toBe(403);
expect(existsSync(join(workDir, 'bobs'))).toBe(false);
expect(linked()).toEqual({});
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
const ok = await clone(admin.app, { name: 'roots', repository: origin, path: join(workDir, 'roots') });
expect(ok.statusCode).toBe(200);
});
});
+43 -1
View File
@@ -17,7 +17,28 @@
* Port: N/A (app.inject).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
// Unreachable-mount seam: `stat()` of a path under this root never settles (a hard
// network mount that went away), so the bounded path probe can be driven to its
// stall cap. Every other stat is the real one. The short timeout is read at import.
const deadMount = vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return { root: '/mnt/codeman-clone-test-dead', releases: [] as Array<() => void> };
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (String(path).startsWith(deadMount.root + '/')) {
return new Promise((resolve) => deadMount.releases.push(() => resolve({} as never)));
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { execFileSync } from 'node:child_process';
@@ -38,6 +59,8 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { isGitAvailable } from '../../src/git-clone.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
const gitPresent = isGitAvailable();
@@ -252,6 +275,25 @@ describe.skipIf(!gitPresent)('POST /api/cases/clone — real clone', () => {
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
});
it('still warns about repo-supplied .claude settings while unrelated mounts are unreachable', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `${deadMount.root}/nas-${i}/project`);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
// Engage the probe's stall cap: every new bounded probe is now refused.
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
created.push('warns-under-cap');
const res = await clone({ name: 'warns-under-cap', repository: origin });
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.warnings.join(' ')).toMatch(/ships its own \.claude/);
} finally {
deadMount.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
warn.mockRestore();
}
});
it('installs Codeman hooks alongside whatever the repo shipped', async () => {
created.push('hooked');
await clone({ name: 'hooked', repository: origin });
+211
View File
@@ -0,0 +1,211 @@
/**
* @fileoverview POST /api/cases with a `path`: create a new case in a custom folder. Real
* filesystem under test/setup.ts's temp HOME (the path policy itself is in test/case-path.test.ts).
* Port: N/A (app.inject()).
*
* This suite deletes and rewrites the linked-cases registry. Under test/setup.ts that file lives in a
* throwaway HOME; a raw `npx vitest` (no setup) would reach the real ~/.codeman, so every test refuses
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
* safeRmHomeTree, so a run can only ever delete what it created.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
/** test/setup.ts's temp HOME (its prefix is pinned by test/test-env-isolation.test.ts). */
const sandboxed = () => isSandboxedHome();
let workDir = '';
const work = () => workDir;
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const create = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/cases', payload });
beforeEach(() => {
if (!sandboxed()) {
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
}
// Recursive: the rollback tests turn the registry into a directory.
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (sandboxed()) safeRmHomeTree(LINKED());
});
describe('POST /api/cases with a custom path', () => {
it('creates the folder, scaffolds it like a normal case, and registers it as a linked case', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'my-app');
const res = await create(app, { name: 'my-app', description: 'A thing', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'my-app', path: target });
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('my-app');
expect(existsSync(join(target, 'src'))).toBe(true);
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
expect(linked()).toEqual({ 'my-app': target });
});
it('appears in GET /api/cases at its custom path', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'listed');
await create(app, { name: 'listed', path: target });
const list = (await app.inject({ method: 'GET', url: '/api/cases' })).json();
const cases = Array.isArray(list) ? list : list.data;
expect(cases.find((c: { name: string }) => c.name === 'listed')).toMatchObject({ path: target });
});
it('expands ~ and fills an existing EMPTY folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'empty-one'));
const res = await create(app, { name: 'empty-one', path: `~/${basename(work())}/empty-one` });
expect(res.statusCode).toBe(200);
expect(existsSync(join(work(), 'empty-one', 'CLAUDE.md'))).toBe(true);
});
it('leaves the cases directory alone: nothing is created under codeman-cases', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'elsewhere', path: join(work(), 'elsewhere') });
expect(existsSync(join(homedir(), 'codeman-cases', 'elsewhere'))).toBe(false);
});
it('refuses a folder that already has files (409) and touches nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'existing'));
writeFileSync(join(work(), 'existing', 'keep.txt'), 'mine');
const res = await create(app, { name: 'existing', path: join(work(), 'existing') });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/Link Existing/);
expect(readdirSync(join(work(), 'existing'))).toEqual(['keep.txt']);
expect(linked()).toEqual({});
});
it.each([
['a system folder', () => '/etc/my-case', 400],
['a credential folder', () => join(homedir(), '.ssh', 'x'), 400],
['the home folder itself', () => homedir(), 400],
['a relative path', () => 'projects/x', 400],
['a path with traversal', () => `${work()}/../x`, 400],
['a missing parent', () => join(work(), 'nope', 'deep', 'app'), 404],
] as const)('refuses %s', async (_label, path, status) => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'x', path: path() });
expect(res.statusCode).toBe(status);
expect(linked()).toEqual({});
});
it('refuses a duplicate case name, and a folder that is already a case, without creating anything', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'one', path: join(work(), 'one') });
const dupName = await create(app, { name: 'one', path: join(work(), 'two') });
expect(dupName.statusCode).toBe(409);
expect(existsSync(join(work(), 'two'))).toBe(false);
// Same folder under another name: the first case's folder now has files, which is refused earlier.
const dupPath = await create(app, { name: 'other', path: join(work(), 'one') });
expect(dupPath.statusCode).toBe(409);
expect(linked()).toEqual({ one: join(work(), 'one') });
});
it('validates the case name like a normal create', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: '../evil', path: join(work(), 'x') });
expect(res.statusCode).toBe(400);
expect(existsSync(join(work(), 'x'))).toBe(false);
});
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'doomed'))).toBe(false);
});
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'picked'));
mkdirSync(LINKED(), { recursive: true });
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'picked'))).toBe(true);
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
});
it('a request without a path still creates under the cases directory, as before', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'plain-case' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(homedir(), 'codeman-cases', 'plain-case', 'CLAUDE.md'))).toBe(true);
expect(linked()).toEqual({});
safeRmHomeTree(join(CASES_DIR(), 'plain-case'));
});
it('refuses a target in the cases directory (400): a case there is a plain create, never a linked one', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(CASES_DIR(), 'existing-empty'), { recursive: true });
// A link from the custom parent into the cases dir is judged on its resolved form too.
symlinkSync(CASES_DIR(), join(work(), 'into-cases'));
try {
for (const path of [
join(CASES_DIR(), 'bar'),
join(CASES_DIR(), 'existing-empty'),
join(work(), 'into-cases', 'via-link'),
]) {
const res = await create(app, { name: 'bar', path });
expect(res.statusCode, path).toBe(400);
expect(res.json().error, path).toMatch(/plain Create New/);
}
expect(existsSync(join(CASES_DIR(), 'bar'))).toBe(false);
expect(existsSync(join(CASES_DIR(), 'via-link'))).toBe(false);
expect(readdirSync(join(CASES_DIR(), 'existing-empty'))).toEqual([]);
expect(linked()).toEqual({});
} finally {
safeRmHomeTree(join(CASES_DIR(), 'existing-empty'));
}
});
it('creates and links a folder two levels under the cases directory (not listed as a local case)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(CASES_DIR(), 'parent-dir'), { recursive: true });
try {
const target = join(realpathSync(join(CASES_DIR(), 'parent-dir')), 'nested');
const res = await create(app, { name: 'nested', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'nested', path: target });
expect(linked()).toEqual({ nested: target });
expect(existsSync(join(target, 'CLAUDE.md'))).toBe(true);
} finally {
safeRmHomeTree(join(CASES_DIR(), 'parent-dir'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const denied = await create(user.app, { name: 'bobs', path: join(work(), 'bobs') });
expect(denied.statusCode).toBe(403);
expect(existsSync(join(work(), 'bobs'))).toBe(false);
expect(linked()).toEqual({});
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
expect((await create(admin.app, { name: 'roots', path: join(work(), 'roots') })).statusCode).toBe(200);
});
});
@@ -0,0 +1,551 @@
/**
* @fileoverview Per-case default session group over HTTP: the case routes that
* read and write it, and the create routes that carry the case into the real
* TabLayoutService so a new session lands in its case's group in the creating
* write (POST /api/sessions, and POST /api/quick-start for local, remote and
* Docker cases). The tab "+" picker (app.js) is driven end to end against the
* same routes, since an ungrouped tab relies on this fallback by design.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { homedir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { Session } from '../../src/session.js';
import { TabLayoutService } from '../../src/tab-layout-service.js';
import { ownerLayoutKey } from '../../src/tab-layout-persistence.js';
import type { TabLayout } from '../../src/tab-layout.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { safeRmHomeTree } from '../mocks/index.js';
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
const CASE = 'cdgcase';
const DATA_FILES = ['docker-hosts.json', 'docker-cases.json', 'remote-hosts.json', 'remote-cases.json'];
interface World {
layouts: Record<string, TabLayout>;
order: string[];
defaults: Record<string, Record<string, string | null>>;
service: TabLayoutService;
}
/** The real layout service over an in-memory store, wired the way server.ts wires it. */
function attachRealLayout(harness: RouteTestHarness): World {
const world = { layouts: {}, order: [], defaults: {} } as unknown as World;
const caseDefaults = {
getCaseDefaultGroup: (owner: string, name: string) =>
world.defaults[owner] && name in world.defaults[owner] ? world.defaults[owner][name] : undefined,
setCaseDefaultGroup: (owner: string, name: string, id: string | null) => {
world.defaults[owner] = { ...(world.defaults[owner] ?? {}), [name]: id };
},
deleteCaseDefaultGroup: (owner: string, name: string) => {
if (world.defaults[owner]) delete world.defaults[owner][name];
},
};
world.service = new TabLayoutService({
store: {
getTabLayout: (owner: string) => world.layouts[owner] ?? null,
getTabLayouts: () => ({ ...world.layouts }),
getSessions: () => ({}),
getSessionOrder: () => [...world.order],
commitTabLayoutProjection: (
updates: Readonly<Record<string, TabLayout>>,
project: (latest: readonly string[]) => readonly string[]
) => {
world.order = [...project(world.order)];
Object.assign(world.layouts, structuredClone(updates));
return { layouts: structuredClone(updates), sessionOrder: [...world.order] };
},
} as never,
sessions: harness.ctx.sessions as never,
readWebviews: async () => [],
broadcast: () => {},
broadcastSessionOrder: () => {},
caseDefaults,
});
world.service.markRestorationSkipped();
(harness.ctx as unknown as { tabLayouts: TabLayoutService }).tabLayouts = world.service;
harness.ctx.addSession.mockImplementation((session: Session, placement?: object) =>
world.service.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
harness.ctx.sessions.set(session.id, session as never);
return () => harness.ctx.sessions.delete(session.id);
})
);
return world;
}
const OWNER = ownerLayoutKey(undefined);
const withGroups = (world: World) => {
world.layouts[OWNER] = {
version: 3,
updatedAt: '2026-10-21T00:00:00.000Z',
groups: [
{ id: 'g-core', name: 'Core', refs: [] },
{ id: 'g-ops', name: 'Ops', refs: [] },
],
ungrouped: [],
};
};
const groupOf = (layout: TabLayout, sessionId: string): string | null =>
layout.groups.find((g) => g.refs.some((r) => r.id === sessionId))?.id ?? null;
const unwrap = (raw: string) => {
const parsed = JSON.parse(raw);
return parsed.data ?? parsed;
};
/**
* The shipping frontend in JSDOM, with fetch answered by the route harnesses, so
* a click in the tab "+" picker reaches the real create route and layout service.
*/
async function bootTabApp(route: (url: string, init?: { method?: string; body?: string }) => Promise<unknown>) {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = dom.window as any;
win.setInterval = () => 0;
win.requestAnimationFrame = () => 0;
win.__codemanCliCatalog = [{ id: 'shell', kind: 'shell', label: 'Shell', enabled: true }];
const publicDir = join(process.cwd(), 'src/web/public');
win.eval(
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
['constants.js', 'app.js', 'mobile-overview.js', 'session-ui.js']
.map((file) => readFileSync(join(publicDir, file), 'utf8'))
.join('\n;\n') +
'\n;window.__CdgCodemanApp = CodemanApp;'
);
win.fetch = async (url: string, init?: { method?: string; body?: string }) => {
const body = await route(url, init);
return { ok: true, status: 200, json: async () => body };
};
win.document.body.innerHTML = '<div id="sessionTabs"></div><select id="quickStartCase"></select>';
const app = Object.create(win.__CdgCodemanApp.prototype);
Object.assign(app, {
$: (id: string) => win.document.getElementById(id),
sessions: new Map(),
activeSessionId: 'tab-1',
terminal: { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 },
_runMinLockMs: 0,
_runMode: 'claude',
getTerminalDimensions: () => null,
selectSession: async () => {},
loadQuickStartCases: () => {},
showToast: vi.fn(),
_applyTabLayout: () => {},
_onSessionCreated: () => {},
_renderSessionTabsImmediate: () => {},
});
return { app, win };
}
describe('case default group: create routes', () => {
let harness: RouteTestHarness;
let world: World;
const caseDir = join(CASES_DIR, CASE);
const outside = join(homedir(), 'cdg-not-a-case');
beforeEach(async () => {
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
await mkdir(caseDir, { recursive: true });
await mkdir(outside, { recursive: true });
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
harness = await createRouteTestHarness(registerSessionRoutes);
harness.ctx.sessions.clear();
world = attachRealLayout(harness);
withGroups(world);
world.defaults[OWNER] = { [CASE]: 'g-ops' };
});
afterEach(async () => {
await harness.app.close();
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(outside);
vi.restoreAllMocks();
});
it('POST /api/sessions in a case folder lands in the case default group and returns that layout', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-cdg', mode: 'shell', workingDir: caseDir },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBe('g-ops');
// The response carries the committed layout so the browser draws the tab in place.
expect(data.tabLayout).toEqual(world.layouts[OWNER]);
});
it('POST /api/sessions in a Claude worktree of the case lands in the case default group too', async () => {
// What the Git worktrees modal sends for Claude; every other CLI goes through quick-start with the case name.
const worktree = join(caseDir, '.claude', 'worktrees', 'feat');
await mkdir(worktree, { recursive: true });
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-cdg-wt', mode: 'shell', workingDir: worktree },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBe('g-ops');
expect(data.tabLayout).toEqual(world.layouts[OWNER]);
});
it('POST /api/sessions without a workingDir infers no case from the server cwd', async () => {
vi.spyOn(process, 'cwd').mockReturnValue(caseDir); // the server's own cwd happens to be a case folder
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-nowd', mode: 'shell' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.session.workingDir).toBe(caseDir);
expect(Object.keys(data)).toEqual(['session']);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull();
});
it('POST /api/sessions outside any case gets no case default and keeps its response shape', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-out', mode: 'shell', workingDir: outside },
});
const data = unwrap(res.body);
expect(Object.keys(data)).toEqual(['session']);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull();
});
it('POST /api/sessions: an explicit tabGroupId still wins', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-cdg', mode: 'shell', workingDir: caseDir, tabGroupId: 'g-core' },
});
expect(groupOf(world.layouts[OWNER], unwrap(res.body).session.id)).toBe('g-core');
});
it('POST /api/quick-start for a local case lands in the case default group', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-ops');
expect(data.tabLayout).toEqual(world.layouts[OWNER]);
});
it('POST /api/quick-start: explicit tabGroupId wins over the case default', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell', tabGroupId: 'g-core' },
});
expect(groupOf(world.layouts[OWNER], unwrap(res.body).sessionId)).toBe('g-core');
});
it('POST /api/quick-start for a remote case lands in the case default group', async () => {
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'cdgremote', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }])
);
world.defaults[OWNER].cdgremote = 'g-core';
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'cdgremote', mode: 'claude' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.sessionId, res.body).toBeTruthy();
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-core');
});
it('POST /api/quick-start for a Docker case lands in the case default group', async () => {
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
JSON.stringify([{ id: 'docker1', label: 'Docker', image: 'codeman/agent:base' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([
{ name: 'cdgdocker', type: 'docker', hostId: 'docker1', hostWorkspacePath: caseDir, container: 'codeman-cdg' },
])
);
world.defaults[OWNER].cdgdocker = 'g-core';
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'cdgdocker', mode: 'claude' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.sessionId, res.body).toBeTruthy();
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-core');
});
it('a deleted default group never fails creation: the session lands ungrouped and the value is cleared', async () => {
world.defaults[OWNER][CASE] = 'g-deleted';
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBeNull();
expect(data.tabLayout).toBeUndefined();
expect(CASE in world.defaults[OWNER]).toBe(false);
});
it('a creation that fails after registration leaves no layout reference', async () => {
vi.mocked(Session.prototype.startShell).mockRejectedValueOnce(new Error('pane failed'));
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell' },
});
expect(unwrap(res.body).sessionId).toBeUndefined();
// The route's own error path ran cleanupSession for the registered id.
expect(harness.ctx.cleanupSession).toHaveBeenCalledTimes(1);
});
describe('from a tab "+" in the browser', () => {
/** Opens the tab's "+" picker, picks Shell, and returns the create request and the new session's group. */
async function launchBeside(tabGroupId: string | null) {
const cases = await createRouteTestHarness(registerCaseRoutes);
const creates: Array<Record<string, unknown>> = [];
try {
const { app, win } = await bootTabApp(async (url, init) => {
const payload = init?.body ? JSON.parse(init.body) : undefined;
if (url === '/api/sessions' && init?.method === 'POST') creates.push(payload);
const target = url.startsWith('/api/cases/') ? cases : harness;
const res = await target.app.inject({ method: (init?.method ?? 'GET') as 'GET', url, payload });
// The envelope server.ts's preSerialization hook adds, which the harness does not install.
const body = res.body ? JSON.parse(res.body) : {};
return typeof body.success === 'boolean' ? body : { success: true, data: body };
});
app.cases = [{ name: CASE, path: caseDir }];
app.sessions.set('tab-1', { id: 'tab-1', name: 'w1-cdg', workingDir: caseDir });
app.tabLayout = {
...world.layouts[OWNER],
groups: world.layouts[OWNER].groups.map((g) => ({
...g,
refs: g.id === tabGroupId ? [{ kind: 'session', id: 'tab-1' }] : [],
})),
};
app.collapsedTabGroupIds = new Set();
app.toggleTabGroupCollapsed = () => {};
const plus = win.document.createElement('span');
win.document.body.appendChild(plus);
app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus }, 'tab-1');
const shell = [...win.document.querySelectorAll('[aria-label="New session here"] button')].find(
(button: Element) => button.textContent === 'Terminal / Shell'
) as HTMLElement;
shell.click();
await vi.waitFor(() => expect(creates).toHaveLength(1));
await vi.waitFor(() => expect(harness.ctx.sessions.size).toBe(1));
const [id] = [...harness.ctx.sessions.keys()];
return { create: creates[0], group: groupOf(world.layouts[OWNER], id), toasts: app.showToast.mock.calls };
} finally {
await cases.app.close();
}
}
it('an ungrouped tab sends no group, so the case default group places the session (chosen on purpose)', async () => {
const { create, group, toasts } = await launchBeside(null);
expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}` });
expect(group).toBe('g-ops');
// The launch went through: no error reported along the way.
expect(toasts.filter(([, kind]: [string, string]) => kind === 'error')).toEqual([]);
});
it('a grouped tab sends its own group, which wins over the case default', async () => {
const { create, group } = await launchBeside('g-core');
expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}`, tabGroupId: 'g-core' });
expect(group).toBe('g-core');
});
});
});
describe('case default group: GET/PUT /api/cases/:name/default-group', () => {
let harness: RouteTestHarness;
let world: World;
const caseDir = join(CASES_DIR, CASE);
beforeEach(async () => {
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
await mkdir(caseDir, { recursive: true });
harness = await createRouteTestHarness(registerCaseRoutes);
world = attachRealLayout(harness);
withGroups(world);
});
afterEach(async () => {
await harness.app.close();
delete process.env.CODEMAN_MULTIUSER;
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
safeRmHomeTree(CASES_DIR);
vi.restoreAllMocks();
});
const get = (name = CASE) => harness.app.inject({ method: 'GET', url: `/api/cases/${name}/default-group` });
const put = (groupId: unknown, name = CASE) =>
harness.app.inject({ method: 'PUT', url: `/api/cases/${name}/default-group`, payload: { groupId } });
it('lists Ungrouped (null) and every named group of the owner', async () => {
const res = await get();
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
success: true,
data: {
caseName: CASE,
groupId: null,
groups: [
{ id: 'g-core', name: 'Core' },
{ id: 'g-ops', name: 'Ops' },
],
},
});
});
it('saves a group and reads it back; null is an intentional Ungrouped', async () => {
expect((await put('g-ops')).json().data.groupId).toBe('g-ops');
expect(world.defaults[OWNER][CASE]).toBe('g-ops');
expect((await get()).json().data.groupId).toBe('g-ops');
expect((await put(null)).json().data.groupId).toBeNull();
expect(world.defaults[OWNER][CASE]).toBeNull();
});
it('reads the current group names, so a rename shows through (the id is stored)', async () => {
await put('g-ops');
world.layouts[OWNER].groups[1].name = 'Operations';
const data = (await get()).json().data;
expect(data.groupId).toBe('g-ops');
expect(data.groups[1]).toEqual({ id: 'g-ops', name: 'Operations' });
});
it('a deleted group reads back as Ungrouped, and the read changes nothing', async () => {
world.defaults[OWNER] = { [CASE]: 'g-gone' };
expect((await get()).json().data.groupId).toBeNull();
// GET is read-only; the next session created for the case forgets the stale id.
expect(world.defaults[OWNER][CASE]).toBe('g-gone');
});
it.each([
['an unknown group', 'g-nope', 400],
['an empty string', '', 400],
['a number', 7, 400],
])('refuses %s without changing the stored value', async (_label, groupId, status) => {
await put('g-core');
const res = await put(groupId);
expect(res.statusCode).toBe(status);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(world.defaults[OWNER][CASE]).toBe('g-core');
});
it('404s a case that does not exist (or is not the caller’s), for both verbs', async () => {
expect((await get('nosuchcase')).statusCode).toBe(404);
expect((await put('g-core', 'nosuchcase')).statusCode).toBe(404);
expect(world.defaults[OWNER]).toBeUndefined();
});
it('accepts a remote case and a Docker case by name', async () => {
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'cdgremote', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: 'cdgdocker', type: 'docker', hostId: 'docker1', hostWorkspacePath: caseDir }])
);
expect((await put('g-core', 'cdgremote')).statusCode).toBe(200);
expect((await put('g-ops', 'cdgdocker')).statusCode).toBe(200);
expect(world.defaults[OWNER]).toMatchObject({ cdgremote: 'g-core', cdgdocker: 'g-ops' });
});
it('DELETE /api/cases/:name forgets the mapping', async () => {
await put('g-ops');
const res = await harness.app.inject({ method: 'DELETE', url: `/api/cases/${CASE}` });
expect(res.statusCode).toBe(200);
expect(CASE in world.defaults[OWNER]).toBe(false);
});
});
describe('case default group: multi-user scoping', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
vi.restoreAllMocks();
});
it("stores a non-admin's choice under their own owner key and validates against their own groups", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const harness = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
harness.ctx.sessions.clear();
const world = attachRealLayout(harness);
world.layouts.alice = {
version: 1,
updatedAt: '2026-10-21T00:00:00.000Z',
groups: [{ id: 'g-alice', name: 'Alice', refs: [] }],
ungrouped: [],
};
world.layouts.bob = {
version: 1,
updatedAt: '2026-10-21T00:00:00.000Z',
groups: [{ id: 'g-bob', name: 'Bob', refs: [] }],
ungrouped: [],
};
const { resolveCasesDir } = await import('../../src/web/route-helpers.js');
const bobCases = resolveCasesDir({ username: 'bob', role: 'user' } as never);
await mkdir(join(bobCases, 'bobcase'), { recursive: true });
const read = await harness.app.inject({ method: 'GET', url: '/api/cases/bobcase/default-group' });
expect(read.json().data.groups).toEqual([{ id: 'g-bob', name: 'Bob' }]);
const foreign = await harness.app.inject({
method: 'PUT',
url: '/api/cases/bobcase/default-group',
payload: { groupId: 'g-alice' },
});
expect(foreign.statusCode).toBe(400);
const own = await harness.app.inject({
method: 'PUT',
url: '/api/cases/bobcase/default-group',
payload: { groupId: 'g-bob' },
});
expect(own.statusCode).toBe(200);
expect(world.defaults).toEqual({ bob: { bobcase: 'g-bob' } });
safeRmHomeTree(join(bobCases, '..'));
} finally {
await harness.app.close();
}
});
});
+434
View File
@@ -0,0 +1,434 @@
/**
* @fileoverview POST /api/cases/:name/rename: a local case moves with its name, a linked case renames its
* alias (and, on request, its real folder), live sessions are listed and only stopped on request, and the
* registries (caseOrder, lastUsedCase, linked-cases.json) and Claude's history follow. Real filesystem under
* test/setup.ts's temp HOME; every folder is removed through safeRmHomeTree. Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import { SETTINGS_PATH, resolveCasesDir } from '../../src/web/route-helpers.js';
import { caseSessionSettled, claudeProjectDirName } from '../../src/case-rename.js';
import { Session } from '../../src/session.js';
import { safeRmHomeTree } from '../mocks/index.js';
import { mutateSettingsFile } from '../../src/web/settings-writer.js';
const LINKED = () => dataPath('linked-cases.json');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-');
let work = '';
let claudeCfg = '';
const made: string[] = [];
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const settings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
const rename = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], name: string, payload: object) =>
app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload });
const localCase = (name: string) => {
const dir = join(CASES_DIR(), name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'CLAUDE.md'), name);
made.push(dir);
return dir;
};
beforeEach(() => {
if (!sandboxed()) throw new Error('case-rename-routes.test.ts renames folders: run it via npm test');
safeRmHomeTree(LINKED());
work = realpathSync(mkdtempSync(join(homedir(), 'case-rename-')));
claudeCfg = join(work, 'claude-config');
process.env.CLAUDE_CONFIG_DIR = claudeCfg;
mkdirSync(join(homedir(), '.codeman'), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['keep', 'alpha', 'tail'], lastUsedCase: 'alpha' }));
});
afterEach(() => {
delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.CODEMAN_MULTIUSER;
for (const dir of made.splice(0)) safeRmHomeTree(dir);
if (work) safeRmHomeTree(work);
work = '';
if (sandboxed()) safeRmHomeTree(LINKED());
});
describe('POST /api/cases/:name/rename (a case under the cases directory)', () => {
it('moves the folder, keeps its place in the order, follows lastUsedCase and carries Claude history', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
mkdirSync(join(claudeCfg, 'projects', claudeProjectDirName(oldDir)), { recursive: true });
writeFileSync(join(claudeCfg, 'projects', claudeProjectDirName(oldDir), 'c.jsonl'), '{}');
const res = await rename(app, 'alpha', { newName: 'beta' });
expect(res.statusCode).toBe(200);
const newDir = join(CASES_DIR(), 'beta');
made.push(newDir);
expect(res.json().data).toMatchObject({ oldName: 'alpha', newName: 'beta', newPath: newDir, folderMoved: true });
expect(existsSync(oldDir)).toBe(false);
expect(readFileSync(join(newDir, 'CLAUDE.md'), 'utf8')).toBe('alpha');
expect(settings()).toMatchObject({ caseOrder: ['keep', 'beta', 'tail'], lastUsedCase: 'beta' });
expect(existsSync(join(claudeCfg, 'projects', claudeProjectDirName(newDir), 'c.jsonl'))).toBe(true);
});
it('carries Claude history into the project directory a deleted case of the new name left behind', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const newDir = join(CASES_DIR(), 'beta');
const oldKey = join(claudeCfg, 'projects', claudeProjectDirName(oldDir));
const newKey = join(claudeCfg, 'projects', claudeProjectDirName(newDir));
mkdirSync(oldKey, { recursive: true });
writeFileSync(join(oldKey, 'conv-alpha.jsonl'), '{}');
// case delete never removes Claude Code's history, so an earlier "beta" left its project directory
mkdirSync(newKey, { recursive: true });
writeFileSync(join(newKey, 'conv-old-beta.jsonl'), '{}');
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(newDir);
expect(res.statusCode).toBe(200);
expect(existsSync(join(newKey, 'conv-alpha.jsonl'))).toBe(true);
expect(existsSync(join(newKey, 'conv-old-beta.jsonl'))).toBe(true);
expect(existsSync(oldKey)).toBe(false);
});
it('refuses a dangling symlink at the new folder before it stops any session', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const target = join(CASES_DIR(), 'beta');
symlinkSync(join(work, 'nowhere'), target);
made.push(target);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: oldDir });
const res = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
expect(ctx.cleanupSession).not.toHaveBeenCalled();
expect(existsSync(oldDir)).toBe(true);
});
it('refuses a name that is taken (local or linked), the same name, or one that is not allowed', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
localCase('taken');
const target = join(work, 'elsewhere');
mkdirSync(target);
writeFileSync(LINKED(), JSON.stringify({ lk: target }));
expect((await rename(app, 'alpha', { newName: 'taken' })).statusCode).toBe(409);
expect((await rename(app, 'alpha', { newName: 'lk' })).statusCode).toBe(409);
expect((await rename(app, 'alpha', { newName: 'alpha' })).statusCode).toBe(400);
expect((await rename(app, 'alpha', { newName: '../evil' })).statusCode).toBe(400);
expect((await rename(app, 'nope', { newName: 'whatever' })).statusCode).toBe(404);
expect(existsSync(join(CASES_DIR(), 'alpha'))).toBe(true);
});
it('lists the live sessions that have the folder open and does not touch it until told to stop them', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
(ctx.sessions as Map<string, unknown>).set('s-live', {
id: 's-live',
name: 'w1-alpha',
workingDir: join(oldDir, 'sub'),
});
const refused = await rename(app, 'alpha', { newName: 'beta' });
expect(refused.statusCode).toBe(409);
expect(refused.json()).toMatchObject({ requiresStop: true, sessions: [{ id: 's-live', name: 'w1-alpha' }] });
expect(existsSync(oldDir)).toBe(true);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
const done = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
made.push(join(CASES_DIR(), 'beta'));
expect(done.statusCode).toBe(200);
expect(done.json().data.stoppedSessions).toBe(1);
expect(ctx.cleanupSession).toHaveBeenCalledWith('s-live', true, 'case_rename');
expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(true);
});
it('refuses to move a folder a session is still being created in, since it can neither list nor stop it', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
// Constructed but not in ctx.sessions yet: the gap a create spends awaiting the layout lock and settings.
const session = new Session({ workingDir: join(oldDir, 'sub') });
try {
const busy = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
expect(busy.statusCode).toBe(409);
expect(busy.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' });
expect(existsSync(oldDir)).toBe(true);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
// once registered it is an open session like any other: listed, and stopped only on request
(ctx.sessions as Map<string, unknown>).set(session.id, session);
caseSessionSettled(session);
const listed = await rename(app, 'alpha', { newName: 'beta' });
expect(listed.statusCode).toBe(409);
expect(listed.json()).toMatchObject({ requiresStop: true, sessions: [{ id: session.id }] });
} finally {
caseSessionSettled(session);
}
});
it('does not stop a remote session whose remote path equals the local folder', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const dir = localCase('alpha');
(ctx.sessions as Map<string, unknown>).set('s-remote', { id: 's-remote', name: 'r', workingDir: dir, remote: {} });
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(join(CASES_DIR(), 'beta'));
expect(res.statusCode).toBe(200);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
});
it("carries the owner's default session group to the new name", async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
vi.mocked(ctx.tabLayouts.caseDefaultGroup).mockResolvedValueOnce({ groupId: 'g1', groups: [] });
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(join(CASES_DIR(), 'beta'));
expect(res.statusCode).toBe(200);
expect(ctx.tabLayouts.setCaseDefaultGroup).toHaveBeenCalledWith(expect.any(String), 'beta', 'g1');
expect(ctx.tabLayouts.forgetCaseDefaultGroup).toHaveBeenCalledWith(expect.any(String), 'alpha');
});
it('answers a folder that appears at the target with ALREADY_EXISTS and keeps renameCode', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
localCase('beta');
const res = await rename(app, 'alpha', { newName: 'beta' });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
});
it('does not touch a case whose name merely starts with the same letters', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
const sibling = localCase('alpha2');
(ctx.sessions as Map<string, unknown>).set('s-other', { id: 's-other', name: 'x', workingDir: sibling });
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(join(CASES_DIR(), 'beta'));
expect(res.statusCode).toBe(200);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
});
});
describe('POST /api/cases/:name/rename (a linked case)', () => {
it('renames only the alias by default: the real folder and its sessions are left alone', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'o') }));
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
const res = await rename(app, 'proj', { newName: 'renamed' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ folderMoved: false, newPath: real, stoppedSessions: 0 });
expect(linked()).toEqual({ other: join(work, 'o'), renamed: real });
expect(existsSync(real)).toBe(true);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
});
it('renames only the alias while a session is being created in the folder, which an alias rename leaves alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
const session = new Session({ workingDir: real });
try {
expect((await rename(app, 'proj', { newName: 'renamed' })).statusCode).toBe(200);
expect((await rename(app, 'renamed', { newName: 'moved', renameFolder: true })).json()).toMatchObject({
renameCode: 'BUSY',
});
expect(existsSync(real)).toBe(true);
} finally {
caseSessionSettled(session);
}
});
it('renames the real folder too, next to the old one, when asked', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(200);
expect(existsSync(real)).toBe(false);
expect(existsSync(join(work, 'renamed'))).toBe(true);
expect(linked()).toEqual({ renamed: join(work, 'renamed') });
});
it('puts the folder and settings.json back when the registry cannot be written', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
// the registry reads fine but cannot be written: the rename must undo the folder move
chmodSync(LINKED(), 0o444);
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
expect(existsSync(real)).toBe(true);
expect(linked()).toEqual({ proj: real });
expect(existsSync(join(work, 'renamed'))).toBe(false);
// settings.json is written first, so it must be put back too
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
});
it('puts back only the keys it wrote, keeping a settings write made in between', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
chmodSync(LINKED(), 0o444);
// Another writer (a settings save) lands after the rename wrote settings.json and before the
// registry write fails.
const realWriteFile = fs.writeFile;
const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => {
if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true }));
return realWriteFile(file, data, options);
});
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
} finally {
spy.mockRestore();
}
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
});
it('refuses to rename while settings.json is not a JSON object, leaving the folder and the file alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const torn = '{\n "agentSkillEnabled": true\n}tail"\n}';
writeFileSync(SETTINGS_PATH, torn);
const res = await rename(app, 'alpha', { newName: 'beta' });
expect(res.statusCode).toBe(409);
expect(res.json()).toEqual({
success: false,
errorCode: 'CONFLICT',
error: `${SETTINGS_PATH} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`,
});
expect(existsSync(oldDir)).toBe(true);
expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn);
});
it('checks settings.json before it stops any session, so none is ended for a rename that cannot complete', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const torn = '{\n "agentSkillEnabled": true\n}tail"\n}';
writeFileSync(SETTINGS_PATH, torn);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: oldDir });
const res = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('is not a valid JSON object');
expect(ctx.cleanupSession).not.toHaveBeenCalled();
expect(existsSync(oldDir)).toBe(true);
expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn);
});
it('says where the folder is when it cannot be moved back', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
chmodSync(LINKED(), 0o444);
const moved = join(work, 'renamed');
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (from === moved) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
return realRename(from, to);
});
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
expect(res.json().error).toContain(`is now at ${moved}`);
expect(existsSync(moved)).toBe(true);
} finally {
spy.mockRestore();
}
});
it('reports the sessions it stopped when the rename fails after stopping them', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
chmodSync(LINKED(), 0o444);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
expect(res.statusCode).toBe(500);
expect(res.json().stoppedSessions).toBe(1);
expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename');
expect(existsSync(real)).toBe(true);
});
it('puts the folder and the registry back when settings.json cannot be written', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'x') }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['proj'] }));
// settings.json is replaced by renaming a temp file over it, which a read-only file does not stop,
// so the failure is injected at that rename.
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === SETTINGS_PATH) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
return realRename(from, to);
});
let res;
try {
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
} finally {
spy.mockRestore();
}
expect(res.statusCode).toBe(500);
expect(existsSync(real)).toBe(true);
expect(linked()).toEqual({ proj: real, other: join(work, 'x') });
// The failed write leaves no temp file behind.
expect(readdirSync(dirname(SETTINGS_PATH)).filter((n) => n.startsWith('settings.json.'))).toEqual([]);
});
it('answers 404 for a linked case a non-admin cannot see in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const real = join(work, 'p');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
const res = await rename(app, 'proj', { newName: 'renamed' });
expect(res.statusCode).toBe(404);
expect(linked()).toEqual({ proj: real });
});
it('leaves the server-wide settings.json alone when a non-admin renames their own case', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const bobsDir = join(resolveCasesDir({ username: 'bob', role: 'user' } as never), 'alpha');
mkdirSync(bobsDir, { recursive: true });
made.push(dirname(bobsDir));
const before = readFileSync(SETTINGS_PATH, 'utf8');
const res = await rename(app, 'alpha', { newName: 'bobs' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(dirname(bobsDir), 'bobs'))).toBe(true);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(before);
});
});
+156 -1
View File
@@ -13,13 +13,24 @@
* behavior matches production exactly).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { probePath } from '../../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../../src/config/path-probe.js';
// A short path-probe timeout keeps the unreachable-mount tests quick. Read when the
// probe's config module is first imported, so it is set before any import runs.
vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '300';
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
// Mock filesystem modules
vi.mock('node:fs', async (importOriginal) => {
@@ -35,6 +46,7 @@ vi.mock('node:fs', async (importOriginal) => {
vi.mock('node:fs/promises', () => ({
default: {
stat: vi.fn(),
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
@@ -74,6 +86,7 @@ const mockedReaddirSync = vi.mocked(readdirSync);
const mockedReaddir = vi.mocked(fs.readdir);
const mockedReadFile = vi.mocked(fs.readFile);
const mockedWriteFile = vi.mocked(fs.writeFile);
const mockedStat = vi.mocked(fs.stat);
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
interface CaseRouteHarness {
@@ -127,6 +140,12 @@ describe('case-routes', () => {
// Default: existsSync returns false, readFile throws ENOENT
mockedExistsSync.mockReturnValue(false);
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
// Async stat (the bounded path probe) follows the mocked existsSync, so a
// test that sets up a path's presence via existsSync drives both the same way.
mockedStat.mockImplementation(async (path) => {
if (mockedExistsSync(path)) return { isDirectory: () => true } as never;
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
});
afterEach(async () => {
@@ -210,6 +229,53 @@ describe('case-routes', () => {
// Should have both regular and linked cases
expect(body.data.length).toBeGreaterThanOrEqual(1);
});
it('still answers promptly when a linked case sits on an unreachable mount', async () => {
// A hard network mount that went away: a synchronous probe blocks the
// thread (simulated by a busy-wait), and an async stat never settles.
const stalledPath = '/mnt/unreachable/linked-nfs';
const BLOCK_MS = 4_000;
mockedReaddir.mockResolvedValue([] as never);
mockedReadFile.mockResolvedValueOnce(JSON.stringify({ 'linked-nfs': stalledPath }) as never);
mockedExistsSync.mockImplementation((p) => {
if (String(p) !== stalledPath) return false;
const until = Date.now() + BLOCK_MS;
while (Date.now() < until) {
// spin: the event loop is frozen for as long as the mount does not answer
}
return true;
});
let release: (() => void) | undefined;
mockedStat.mockImplementation((p) => {
if (String(p) !== stalledPath) {
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
}
return new Promise((resolve) => {
release = () => resolve({ isDirectory: () => true } as never);
});
});
const started = Date.now();
const res = await harness.app.inject({ method: 'GET', url: '/api/cases' });
const elapsed = Date.now() - started;
release?.();
expect(res.statusCode).toBe(200);
expect(elapsed).toBeLessThan(BLOCK_MS - 1_000);
// The unreachable case is listed as such rather than holding the list
// hostage, or vanishing as though it had been deleted.
expect(JSON.parse(res.body).data).toEqual([
{
name: 'linked-nfs',
path: stalledPath,
hasClaudeMd: false,
linked: true,
location: 'linked-local',
unreachable: true,
},
]);
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
});
});
describe('remote host and remote case routes', () => {
@@ -581,6 +647,25 @@ describe('case-routes', () => {
expect(body.success).toBe(false);
});
it('says which field is wrong instead of a generic message', async () => {
const badName = await harness.app.inject({
method: 'POST',
url: '/api/cases/link',
payload: { name: 'bad name!', path: '/home/user/project' },
});
expect(badName.statusCode).toBe(400);
expect(JSON.parse(badName.body).error).toBe('Invalid case name format');
// A leading `~` is expanded later in the route but fails the absolute-path rule first.
const badPath = await harness.app.inject({
method: 'POST',
url: '/api/cases/link',
payload: { name: 'my-project', path: '~/project' },
});
expect(badPath.statusCode).toBe(400);
expect(JSON.parse(badPath.body).error).toMatch(/^Invalid path:/);
});
it('returns not found when folder does not exist', async () => {
mockedExistsSync.mockReturnValue(false);
@@ -670,6 +755,66 @@ describe('case-routes', () => {
expect(body.data.name).toBe('regular-case');
});
it('answers a linked case on an unreachable mount with its registered path, not NOT_FOUND', async () => {
// The timeout path: the mount does not answer at all.
const stalledPath = '/mnt/unreachable/linked-get';
mockedReadFile.mockResolvedValue(JSON.stringify({ 'linked-get': stalledPath }) as never);
let release: (() => void) | undefined;
mockedStat.mockImplementation((p) => {
if (String(p) !== stalledPath) {
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
}
return new Promise((resolve) => {
release = () => resolve({ isDirectory: () => true } as never);
});
});
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/linked-get' });
release?.();
await new Promise((r) => setTimeout(r, 0)); // let the released stat clear its stall
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data).toMatchObject({ name: 'linked-get', path: stalledPath, linked: true, unreachable: true });
});
it('still answers a healthy case while unrelated mounts are stalled past the cap', async () => {
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/linked`);
const releases: Array<() => void> = [];
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockImplementation((p) => {
if (dead.includes(String(p))) {
return new Promise((resolve) => releases.push(() => resolve({ isDirectory: () => true } as never)));
}
return Promise.resolve({ isDirectory: () => true } as never);
});
expect(await Promise.all(dead.map((p) => probePath(p)))).toEqual(dead.map(() => 'unknown'));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/healthy-local' });
releases.forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data).toMatchObject({ name: 'healthy-local' });
expect(JSON.parse(res.body).data.unreachable).toBeUndefined();
// Its CLAUDE.md is probed the same way as its folder, so it is not misreported missing.
expect(JSON.parse(res.body).data.hasClaudeMd).toBe(true);
});
it('answers a local case it cannot read with a non-NOT_FOUND error', async () => {
// A soft mount that gave up (EIO) is not proof the case is gone, and the Run
// button creates a case on NOT_FOUND.
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/eio-case' });
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(res.statusCode).not.toBe(404);
});
it('returns error when case not found anywhere', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedExistsSync.mockReturnValue(false);
@@ -704,6 +849,16 @@ describe('case-routes', () => {
expect(body.data.todos).toEqual([]);
});
it('reports an unreadable fix plan as an error, not as "no plan"', async () => {
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockedStat.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }));
const res = await harness.app.inject({ method: 'GET', url: '/api/cases/my-case/fix-plan' });
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
});
it('parses fix plan with todos and stats', async () => {
const fixPlanContent = [
'# Fix Plan',
+94 -3
View File
@@ -12,10 +12,16 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { installEnv, registerCliRegistryRoutes, type CliListItem } from '../../src/web/routes/cli-registry-routes.js';
import {
installEnv,
npmGlobalPrefixWritable,
registerCliRegistryRoutes,
type CliListItem,
} from '../../src/web/routes/cli-registry-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { CreateSessionSchema } from '../../src/web/schemas.js';
import { buildSpawnCommandFromRegistry } from '../../src/session-cli-registry-bridge.js';
@@ -711,7 +717,7 @@ describe('registry writes are serialized and never clobber a file the reader wou
} finally {
delete process.env.CODEMAN_TEST_SECRET;
}
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' }, () => true)).toEqual({ HOME: '/h' });
});
it('redirects npm installs to the persistent HOME inside the Compose container', () => {
@@ -719,6 +725,91 @@ describe('registry writes are serialized and never clobber a file the reader wou
installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/opt/codeman-cli' })
).toEqual({ HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/home/codeman/.local' });
});
it('redirects npm installs to ~/.local on a native install whose global prefix is not writable', () => {
// A system node under /usr: `npm install -g` as the server user dies with EACCES (exit 243).
expect(installEnv({ HOME: '/home/dev', CODEMAN_PASSWORD: 'x' }, () => false)).toEqual({
HOME: '/home/dev',
NPM_CONFIG_PREFIX: '/home/dev/.local',
});
});
it('leaves a writable, explicit or undeterminable npm prefix alone on a native install', () => {
expect(installEnv({ HOME: '/home/dev' }, () => true)).toEqual({ HOME: '/home/dev' });
// An operator-set prefix wins even if it is not writable: it is theirs to fix.
expect(installEnv({ HOME: '/home/dev', NPM_CONFIG_PREFIX: '/opt/npm' }, () => false)).toEqual({
HOME: '/home/dev',
NPM_CONFIG_PREFIX: '/opt/npm',
});
// No HOME means nowhere to redirect to.
expect(installEnv({ PATH: '/usr/bin' }, () => false)).toEqual({ PATH: '/usr/bin' });
});
it('drops every spelling of npm_config_prefix when it redirects (npm run exports the lowercase one)', () => {
// npm reads npm_config_* case-insensitively and a sorting /bin/sh lets the older key win.
expect(installEnv({ HOME: '/h', npm_config_prefix: '/usr' }, () => false)).toEqual({
HOME: '/h',
NPM_CONFIG_PREFIX: '/h/.local',
});
expect(installEnv({ HOME: '/h', Npm_Config_Prefix: '/usr', NPM_CONFIG_PREFIX: '' }, () => false)).toEqual({
HOME: '/h',
NPM_CONFIG_PREFIX: '/h/.local',
});
expect(
installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/h', npm_config_prefix: '/usr', NPM_CONFIG_PREFIX: '/opt/x' })
).toEqual({ HOME: '/h', NPM_CONFIG_PREFIX: '/h/.local' });
// The operator guard is the uppercase key only: npm run always injects the lowercase one.
expect(installEnv({ HOME: '/h', npm_config_prefix: '/usr' }, () => true)).toEqual({
HOME: '/h',
npm_config_prefix: '/usr',
});
});
describe('npmGlobalPrefixWritable', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(`${tmpdir()}/npm-prefix-`);
});
afterEach(() => {
chmodSync(dir, 0o755);
rmSync(dir, { recursive: true, force: true });
});
/** A fake `npm` on PATH that prints `prefix` for `npm config get prefix`. */
function fakeNpm(prefix: string): NodeJS.ProcessEnv {
const bin = `${dir}/bin`;
mkdirSync(bin, { recursive: true });
writeFileSync(`${bin}/npm`, `#!/bin/sh\necho '${prefix}'\n`, { mode: 0o755 });
return { PATH: `${bin}:/usr/bin:/bin` };
}
it('is true for a writable prefix, whether or not lib/node_modules exists', async () => {
mkdirSync(`${dir}/w`);
expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/w`))).toBe(true);
mkdirSync(`${dir}/w/lib/node_modules`, { recursive: true });
expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/w`))).toBe(true);
});
it('is false for a prefix the server user cannot write', async () => {
if (process.getuid?.() === 0) return; // root can write anywhere
mkdirSync(`${dir}/ro`);
chmodSync(`${dir}/ro`, 0o555);
expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/ro`))).toBe(false);
});
it('judges a prefix that does not exist yet by its nearest existing ancestor', async () => {
// A user .npmrc with prefix=~/.npm-global before it was created: npm makes it, so do not move it.
expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/not/yet/made`))).toBe(true);
if (process.getuid?.() === 0) return;
mkdirSync(`${dir}/ro2`);
chmodSync(`${dir}/ro2`, 0o555);
expect(await npmGlobalPrefixWritable(fakeNpm(`${dir}/ro2/not/yet`))).toBe(false);
});
it('treats a probe failure (npm missing) as writable, and never throws', async () => {
expect(await npmGlobalPrefixWritable({ PATH: '/nonexistent' })).toBe(true);
});
});
});
/**
@@ -0,0 +1,94 @@
/**
* @fileoverview A backup pass reads the backup folder once: one directory listing and one read per
* manifest, shared by the recorded-retention fallback, the change check and the prune. Each used to
* list the folder itself, which on a network folder meant every manifest read three times every five
* minutes (found in review of #661).
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (no server).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const rec = vi.hoisted(() => ({ dir: '', calls: [] as string[] }));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const record = <F extends (...a: never[]) => unknown>(name: string, real: F): F =>
((path: unknown, ...rest: unknown[]) => {
if (rec.dir && String(path).startsWith(rec.dir)) rec.calls.push(`${name} ${String(path)}`);
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
const wrapped = {
...actual,
readdirSync: record('readdirSync', actual.readdirSync),
readFileSync: record('readFileSync', actual.readFileSync),
};
return { ...wrapped, default: wrapped };
});
import { runConfigBackup } from '../../src/web/routes/config-backup-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { createConfigBackup, defaultConfigBackupDir, listConfigBackups } from '../../src/config-backup.js';
const dir = () => defaultConfigBackupDir(getDataDir());
const DAY = 86_400_000;
const BASE = Date.UTC(2025, 0, 1);
beforeEach(() => {
rec.dir = '';
rec.calls.length = 0;
rmSync(dir(), { recursive: true, force: true });
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
});
afterEach(() => {
rec.dir = '';
rmSync(dir(), { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
describe('one backup pass', () => {
it('lists the folder once and reads each manifest once', async () => {
// Only the OLDEST snapshot recorded a retention, so the fallback has to look through every one.
for (let i = 0; i < 6; i++) {
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: `v${i}` }));
createConfigBackup({
dataDir: getDataDir(),
backupDir: dir(),
reason: 'auto',
now: BASE + i * DAY,
...(i === 0 && { keepCount: 100, keepDays: 0 }),
});
}
// A change (so the pass writes a snapshot and then prunes) and no retention keys (the fallback reads).
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'changed' }));
rec.dir = dir();
const out = await runConfigBackup('auto');
rec.dir = '';
expect(out?.result.status).toBe('created');
expect(out?.removed).toEqual([]); // the recorded 100 / no age limit, not the defaults
expect(rec.calls.filter((c) => c.startsWith('readdirSync'))).toHaveLength(1);
const manifestReads = rec.calls.filter((c) => c.startsWith('readFileSync') && c.endsWith('manifest.json'));
expect(manifestReads).toHaveLength(6);
expect(new Set(manifestReads).size).toBe(6);
expect(listConfigBackups(dir())).toHaveLength(7);
});
it('counts the snapshot it just made when it prunes', async () => {
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 0 }));
for (let i = 0; i < 3; i++) {
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: i }));
createConfigBackup({ dataDir: getDataDir(), backupDir: dir(), reason: 'auto', now: BASE + i * DAY });
}
const before = listConfigBackups(dir()).map((b) => b.id);
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 9 }));
const out = await runConfigBackup('auto');
expect(out?.result.status).toBe('created');
const kept = listConfigBackups(dir()).map((b) => b.id);
// keep 2: the new one and the newest before it; the two older ones go
expect(kept).toEqual([(out?.result as { id: string }).id, before[0]]);
expect(out?.removed.sort()).toEqual([before[1], before[2]].sort());
});
});
+346
View File
@@ -0,0 +1,346 @@
/**
* @fileoverview /api/config-backups: list, back up now, restore. Settings are read fresh from
* settings.json, so a folder or retention change applies on the next request.
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerConfigBackupRoutes, runConfigBackup } from '../../src/web/routes/config-backup-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { createConfigBackup, defaultConfigBackupDir, listConfigBackups } from '../../src/config-backup.js';
import { intentStore } from '../../src/intent-store.js';
let custom: string;
const settings = (extra: Record<string, unknown> = {}) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'before', ...extra }));
};
beforeEach(() => {
custom = mkdtempSync(join(tmpdir(), 'cfg-backup-route-'));
rmSync(defaultConfigBackupDir(getDataDir()), { recursive: true, force: true });
settings();
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(custom, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
rmSync(defaultConfigBackupDir(getDataDir()), { recursive: true, force: true });
});
describe('/api/config-backups', () => {
it('lists the effective settings, the default folder and the tracked files', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method: 'GET', url: '/api/config-backups' });
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.settings).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 });
expect(d.dir).toBe(defaultConfigBackupDir(getDataDir()));
expect(d.tracked).toContain('settings.json');
expect(d.backups).toEqual([]);
});
it('backs up now into the default folder and lists it', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const made = await app.inject({ method: 'POST', url: '/api/config-backups' });
expect(made.statusCode).toBe(200);
expect(made.json().data.status).toBe('created');
const list = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data;
expect(list.backups).toHaveLength(1);
expect(list.backups[0].reason).toBe('manual');
expect(list.backups[0].files.map((f: { name: string }) => f.name)).toContain('settings.json');
// Names and sizes only, never content.
expect(JSON.stringify(list)).not.toContain('before');
});
it('follows a custom folder and retention from settings on the next request', async () => {
const dir = join(custom, 'snap');
settings({ configBackupDir: dir, configBackupKeepCount: 2, configBackupKeepDays: 0 });
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
for (let i = 0; i < 4; i++) {
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: dir, configBackupKeepCount: 2, i }));
await app.inject({ method: 'POST', url: '/api/config-backups' });
await new Promise((r) => setTimeout(r, 5));
}
const d = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data;
expect(d.dir).toBe(dir);
expect(d.backups).toHaveLength(2);
expect(existsSync(defaultConfigBackupDir(getDataDir()))).toBe(false);
});
it('restores a backup, reports what needs a restart and keeps a pre-restore copy', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
writeFileSync(join(dirname(SETTINGS_PATH), 'clis.json'), '{"schemaVersion":1,"clis":{}}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'polluted' }));
writeFileSync(join(dirname(SETTINGS_PATH), 'clis.json'), '{"clis":{"evil":{}}}');
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.restored).toEqual(expect.arrayContaining(['settings.json', 'clis.json']));
expect(d.restartRequired).not.toContain('clis.json'); // reloaded by the restore itself
expect(d.restartRequired).toContain('settings.json'); // its watcher toggles are not re-run by a restore
expect(d.safetySnapshot).toBeTruthy();
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before');
});
it('a restore that fails partway names what it restored, what failed and the pre-restore copy', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const clis = join(dirname(SETTINGS_PATH), 'clis.json');
writeFileSync(clis, '{"schemaVersion":1,"clis":{}}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'polluted' }));
// A file the server cannot replace: settings.json (first in the list) goes back, clis.json cannot.
rmSync(clis);
mkdirSync(clis);
try {
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.statusCode).toBe(500);
const body = res.json();
expect(body).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
expect(body.error).toContain('clis.json');
expect(body.data.restored).toContain('settings.json');
expect(body.data.failed.map((f: { name: string }) => f.name)).toEqual(['clis.json']);
expect(body.data.restartRequired).toContain('settings.json');
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before');
// The undo copy exists, holds the state the restore replaced, and is the one named.
const safety = body.data.safetySnapshot;
expect(safety).toBeTruthy();
expect(body.error).toContain(safety);
const listed = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.backups;
expect(listed.find((b: { id: string }) => b.id === safety)?.reason).toBe('pre-restore');
} finally {
rmSync(clis, { recursive: true, force: true });
}
});
it('a restored discord.json reaches the running bridge (it caches its config)', async () => {
const reloadDiscord = vi.fn(async () => undefined);
const { app } = await createRouteTestHarness((a) => registerConfigBackupRoutes(a, { reloadDiscord }));
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":false}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":true}', { mode: 0o600 });
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.json().data.restored).toContain('discord.json');
expect(reloadDiscord).toHaveBeenCalledTimes(1);
// A restore that does not carry the file leaves the bridge alone.
rmSync(join(dirname(SETTINGS_PATH), 'discord.json'), { force: true });
const id2 = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
await app.inject({ method: 'POST', url: `/api/config-backups/${id2}/restore` });
expect(reloadDiscord).toHaveBeenCalledTimes(1);
});
it('a restored intents.json is not reverted by the next captured prompt', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-'));
try {
intentStore.recordPrompt(undefined, dir, 's1', 'first prompt about the build pipeline');
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
intentStore.recordPrompt(undefined, dir, 's1', 'second prompt about the build pipeline');
await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
intentStore.recordPrompt(undefined, dir, 's1', 'third prompt about the build pipeline');
expect(intentStore.getProfile(undefined, dir).recentPrompts).toHaveLength(2);
} finally {
intentStore.deleteProfile(undefined, dir);
rmSync(dir, { recursive: true, force: true });
}
});
it('refuses a custom folder whose parent is missing instead of creating it', async () => {
settings({ configBackupDir: join(custom, 'no', 'such', 'dir') });
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method: 'POST', url: '/api/config-backups' });
expect(res.statusCode).toBe(503);
expect(existsSync(join(custom, 'no'))).toBe(false);
});
it('answers 404 for an unknown backup and 404 for a traversal attempt', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
for (const id of ['cfg-19990101-000000', '..%2F..%2Fetc', 'photos']) {
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.statusCode, id).toBe(404);
}
});
it('is admin only in multi-user mode: a non-admin is refused on every verb', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerConfigBackupRoutes, {
authUser: { username: 'bob', role: 'user' },
});
for (const [method, url] of [
['GET', '/api/config-backups'],
['POST', '/api/config-backups'],
['POST', '/api/config-backups/cfg-20261001-120000/restore'],
['GET', '/api/config-backups/diff?from=cfg-20261001-120000'],
] as const) {
expect((await app.inject({ method, url })).statusCode, `${method} ${url}`).toBe(403);
}
});
it('multi-user: a restore is written to the admin audit log', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerConfigBackupRoutes, {
authUser: { username: 'root', role: 'admin' },
});
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
const auditPath = join(getDataDir(), 'admin-audit.jsonl');
const written = () => existsSync(auditPath) && readFileSync(auditPath, 'utf8').includes('config_backup_restore');
for (let i = 0; i < 40 && !written(); i++) await new Promise((r) => setTimeout(r, 25));
const last = JSON.parse(readFileSync(auditPath, 'utf8').trim().split('\n').pop()!);
expect(last).toMatchObject({ admin: 'root', action: 'config_backup_restore', target: id });
expect(last.detail.restored).toContain('settings.json');
rmSync(auditPath, { force: true });
});
it('multi-user: an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerConfigBackupRoutes, {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/config-backups' })).json().success).toBe(true);
});
});
describe('GET /api/config-backups/diff', () => {
it('compares a backup with the running config, defaulting `to` to current', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const made = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data;
settings({ displayName: 'after' });
const res = await app.inject({ method: 'GET', url: `/api/config-backups/diff?from=${made.id}` });
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.to).toBe('current');
expect(d.files.find((f: { name: string }) => f.name === 'settings.json').changes).toEqual([
{ path: 'displayName', kind: 'changed', before: '"before"', after: '"after"' },
]);
});
it('400 without `from`, 404 for an unknown backup', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
expect((await app.inject({ method: 'GET', url: '/api/config-backups/diff' })).statusCode).toBe(400);
const missing = await app.inject({ method: 'GET', url: '/api/config-backups/diff?from=cfg-20200101-000000' });
expect(missing.statusCode).toBe(404);
});
});
describe('retention survives an unreadable or reset settings.json', () => {
const DAY = 86_400_000;
const BASE = Date.UTC(2025, 0, 1);
const dir = () => defaultConfigBackupDir(getDataDir());
/** 30 snapshots a day apart, made under a long-history retention (100 copies, no age limit). */
function seed(withKeys = true) {
settings({ configBackupKeepCount: 100, configBackupKeepDays: 0 });
for (let i = 0; i < 30; i++) {
writeFileSync(
SETTINGS_PATH,
JSON.stringify(
withKeys
? { displayName: `v${i}`, configBackupKeepCount: 100, configBackupKeepDays: 0 }
: { displayName: `v${i}` }
)
);
createConfigBackup({
dataDir: getDataDir(),
backupDir: dir(),
reason: 'auto',
now: BASE + i * DAY,
keepCount: 100,
keepDays: 0,
});
}
expect(listConfigBackups(dir())).toHaveLength(30);
}
it('a settings.json that does not parse still gets a snapshot but prunes nothing', async () => {
seed();
writeFileSync(SETTINGS_PATH, '{ "displayName": "half-written", }');
const out = await runConfigBackup('auto');
expect(out?.result.status).toBe('created');
expect(out?.removed).toEqual([]);
expect(listConfigBackups(dir())).toHaveLength(31);
});
it('a reset settings.json prunes with the retention the newest snapshot recorded, not the defaults', async () => {
seed();
writeFileSync(SETTINGS_PATH, '{}');
const out = await runConfigBackup('auto');
expect(out?.result.status).toBe('created');
// aged manifests (a year old) and keepDays 0: the defaults (20 copies, 30 days) would leave 20
expect(listConfigBackups(dir())).toHaveLength(31);
});
it.each([true, false])(
'a restore after a reset keeps the retention (the backup %s the four keys)',
async (withKeys) => {
seed(withKeys);
const oldest = listConfigBackups(dir()).at(-1)!.id;
writeFileSync(SETTINGS_PATH, '{}'); // the reset
expect((await runConfigBackup('auto'))?.removed).toEqual([]);
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${oldest}/restore` });
expect(res.statusCode).toBe(200);
// the restored file carries the retention, or the recorded one still applies via the pre-restore copy
const out = await runConfigBackup('auto');
expect(out?.removed).toEqual([]);
expect(listConfigBackups(dir()).length).toBeGreaterThanOrEqual(32);
const status = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.settings;
expect(status).toMatchObject({ keepCount: 100, keepDays: 0 }); // what the form shows is what the pass uses
}
);
it("re-entering a custom folder after a reset finds that folder's recorded retention", async () => {
const folder = join(custom, 'kept');
settings({ configBackupDir: folder, configBackupKeepCount: 100, configBackupKeepDays: 0 });
for (let i = 0; i < 30; i++) {
createConfigBackup({
dataDir: getDataDir(),
backupDir: folder,
reason: 'auto',
force: true,
now: BASE + i * DAY,
keepCount: 100,
keepDays: 0,
});
}
writeFileSync(SETTINGS_PATH, '{}'); // the reset drops the folder too
await runConfigBackup('auto'); // goes to the default folder, which holds none of those manifests
// the user enters the folder again and saves; the form no longer sends retention it only displayed
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: folder, configBackupEnabled: true }));
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const reported = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.settings;
expect(reported).toMatchObject({ keepCount: 100, keepDays: 0 });
const out = await runConfigBackup('auto');
expect(out?.removed).toEqual([]);
expect(listConfigBackups(folder).length).toBeGreaterThanOrEqual(30);
});
it('only the retention key the file lacks falls back to the recorded one', async () => {
seed();
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepDays: 3650 }));
await runConfigBackup('auto');
expect(listConfigBackups(dir())).toHaveLength(31); // keepCount 100 from the manifests, not the default 20
});
it('a settings.json that sets the retention still wins', async () => {
seed();
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 5, configBackupKeepDays: 0 }));
await runConfigBackup('auto');
expect(listConfigBackups(dir())).toHaveLength(5);
});
it('listing never creates config-backup-id', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const idFile = join(getDataDir(), 'config-backup-id');
rmSync(idFile, { force: true });
expect((await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.backups).toEqual([]);
expect(existsSync(idFile)).toBe(false);
});
});
@@ -0,0 +1,78 @@
/**
* @fileoverview A custom backup folder that stops answering (a network mount) must never be listed or
* read before the bounded probe says it is there: GET, Back up now, diff and restore answer 503 without
* touching it.
* With only `configBackupDir` in settings.json the recorded-retention fallback would otherwise
* `readdirSync` the folder first (found in review of #661).
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const dead = vi.hoisted(() => ({ dir: '', touched: [] as string[] }));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const record = <F extends (...a: never[]) => unknown>(name: string, real: F): F =>
((path: unknown, ...rest: unknown[]) => {
if (dead.dir && String(path).startsWith(dead.dir)) dead.touched.push(`${name} ${String(path)}`);
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
const wrapped = {
...actual,
readdirSync: record('readdirSync', actual.readdirSync),
readFileSync: record('readFileSync', actual.readFileSync),
statSync: record('statSync', actual.statSync),
lstatSync: record('lstatSync', actual.lstatSync),
existsSync: record('existsSync', actual.existsSync),
};
return { ...wrapped, default: wrapped };
});
vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>();
return {
...actual,
probePathKind: async (path: string, options?: { pastCap?: boolean }) =>
dead.dir && path.startsWith(dead.dir) ? 'unknown' : actual.probePathKind(path, options),
};
});
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerConfigBackupRoutes } from '../../src/web/routes/config-backup-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { configBackupOrigin } from '../../src/config-backup.js';
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'cfg-backup-dead-'));
dead.dir = join(root, 'mount');
dead.touched.length = 0;
configBackupOrigin(getDataDir()); // an instance that already has an id: the fallback has something to look for
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
// Only the folder: no retention keys, the shape that makes the recorded-retention fallback want to read it.
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: dead.dir }));
});
afterEach(() => {
dead.dir = '';
rmSync(root, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
describe('a custom backup folder that does not answer', () => {
it.each([
['GET', '/api/config-backups'],
['POST', '/api/config-backups'],
['GET', '/api/config-backups/diff?from=cfg-20261001-120000'],
['POST', '/api/config-backups/cfg-20261001-120000/restore'],
] as const)('%s %s answers 503 without reading the folder', async (method, url) => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method, url });
expect(res.statusCode).toBe(503);
expect(dead.touched).toEqual([]);
});
});
+164 -1
View File
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../../src/web/webview-egress.js';
import { createRouteTestHarness } from './_route-test-utils.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
vi.mock('../../src/web/webview-egress.js', async () => {
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
@@ -417,3 +417,166 @@ describe('apiKey is never handed back to the browser', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe('context-length discovery from the /v1/models listing', () => {
// The gateway path (a Bifrost in front of llama-swap, an OpenRouter-style server):
// no /running, no /props, just a per-model `context_length` or `context_window` in the
// model listing. Distinct ports per test keep the file's shared data dir unambiguous.
afterEach(() => {
fetchMock.mockReset();
});
type StoredHost = { id: string; modelContextLengths?: Record<string, number>; defaultContextLength?: number };
type HarnessApp = RouteTestHarness['app'];
async function endpointWith(id: string, port: number, extra: Record<string, unknown> = {}): Promise<HarnessApp> {
const { app } = await setup();
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id, label: 'A', baseUrl: `http://localhost:${port}`, ...extra },
});
expect(res.statusCode).toBe(200);
return app;
}
async function storedHost(app: HarnessApp, id: string): Promise<StoredHost | undefined> {
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
return (list.json() as StoredHost[]).find((h) => h.id === id);
}
it('reads context_length (preferred) then context_window from each entry into modelContextLengths', async () => {
const app = await endpointWith('ep-ctx-listed', 8101);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'both', context_length: 131072, context_window: 32768 },
{ id: 'window-only', context_window: 8192 },
{ id: 'no-figure' },
],
}),
{ status: 200 }
)
);
const discover = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-listed/discover-models' });
expect(discover.json().success).toBe(true);
const stored = await storedHost(app, 'ep-ctx-listed');
expect(stored?.modelContextLengths).toEqual({ both: 131072, 'window-only': 8192 });
});
it('ignores non-numeric, zero, negative and absurd listed values; the schema bound itself is still accepted', async () => {
const app = await endpointWith('ep-ctx-bad', 8102);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'string', context_length: '131072' },
{ id: 'zero', context_window: 0 },
{ id: 'negative', context_length: -10 },
{ id: 'over-bound', context_length: 100_000_001 },
{ id: 'fractional', context_window: 16384.5 },
{ id: 'at-bound', context_length: 100_000_000 },
],
}),
{ status: 200 }
)
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-bad/discover-models' });
const stored = await storedHost(app, 'ep-ctx-bad');
expect(stored?.modelContextLengths).toEqual({ 'at-bound': 100_000_000 });
});
it('prefers the /running launch command over the listed figure for a loaded model', async () => {
const app = await endpointWith('ep-ctx-cmd', 8103);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(
JSON.stringify({ data: [{ id: 'cmd-wins', status: { value: 'loaded' }, context_length: 131072 }] }),
{ status: 200 }
);
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({ running: [{ model: 'cmd-wins', state: 'ready', cmd: 'llama-server --ctx-size 16384' }] }),
{ status: 200 }
);
}
if (url.pathname === '/props') return new Response('nope', { status: 404 });
throw new Error(`unexpected request in this test: ${url.href}`);
});
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-cmd/discover-models' });
const stored = await storedHost(app, 'ep-ctx-cmd');
expect(stored?.modelContextLengths).toEqual({ 'cmd-wins': 16384 });
});
it('keeps a previously learned value the fresh listing says nothing about, and discovery never touches defaultContextLength', async () => {
const app = await endpointWith('ep-ctx-keep', 8104, {
models: ['re-listed', 'untouched'],
modelContextLengths: { 're-listed': 16384, untouched: 4096 },
defaultContextLength: 65536,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }),
{ status: 200 }
)
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-keep/discover-models' });
const stored = await storedHost(app, 'ep-ctx-keep');
expect(stored?.modelContextLengths).toEqual({ 're-listed': 32768, untouched: 4096 });
expect(stored?.defaultContextLength).toBe(65536);
});
});
describe('defaultContextLength (the user-set fallback discovery never overwrites)', () => {
afterEach(() => {
fetchMock.mockReset();
});
it('stores it on create, updates it on PUT, and a PUT without it clears it', async () => {
const { app } = await setup();
const create = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'ep-defctx', label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 131072 },
});
expect(create.json().data.host.defaultContextLength).toBe(131072);
const update = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/ep-defctx',
payload: { label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 32768 },
});
expect(update.json().data.host.defaultContextLength).toBe(32768);
const cleared = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/ep-defctx',
payload: { label: 'A', baseUrl: 'http://localhost:8105' },
});
expect(cleared.json().data.host.defaultContextLength).toBeUndefined();
});
it('rejects zero, negative, fractional, over-the-bound and non-numeric values', async () => {
const { app } = await setup();
for (const defaultContextLength of [0, -1, 1.5, 100_000_001, '131072']) {
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'ep-defctx-bad', label: 'A', baseUrl: 'http://localhost:8106', defaultContextLength },
});
expect(res.json().success, String(defaultContextLength)).toBe(false);
expect(res.json().errorCode, String(defaultContextLength)).toBe('INVALID_INPUT');
}
});
});
@@ -0,0 +1,196 @@
/**
* @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the
* Discord bridge posts from), with a message that asks SEVERAL questions: every question must come
* back whole, in order, on its own line, and survive the split into Discord posts.
*
* Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts.
* Port: N/A
*
* Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode,
* gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own
* transcript formats and are covered by their reader's tests.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { splitForDiscord } from '../../src/discord-bridge.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
const QUESTIONS = [
'Should the sweep run daily or hourly?',
'Do you want archived threads deleted as well?',
'Is one channel per Codeman acceptable?',
];
const INTRO = 'I have three questions before I continue:';
const OUTRO = 'Once you answer, I will apply the change.';
/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */
const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n');
/** Every question present, in order, each on a line of its own. */
function expectWholeQuestions(text: string): void {
let from = 0;
for (const q of QUESTIONS) {
const at = text.indexOf(q, from);
expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from);
from = at + q.length;
}
const lines = text.split('\n').map((l) => l.trim());
for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true);
expect(text).toContain(INTRO);
expect(text).toContain(OUTRO);
// What goes to Discord: the same questions after the split, joined back.
expectWholeQuestionsAfterSplit(text);
}
function expectWholeQuestionsAfterSplit(text: string): void {
const joined = splitForDiscord(text).join('\n');
for (const q of QUESTIONS) expect(joined).toContain(q);
}
async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
app.addHook('preSerialization', (req, _reply, payload: unknown, done) => {
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown };
return done(null, p.success === undefined ? { success: true, data: payload } : payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('the last message with several questions, per CLI', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
let home: string;
let prevHome: string | undefined;
let prevCodexHome: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any;
async function lastText(): Promise<string> {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` });
return JSON.parse(res.body).data.text as string;
}
beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-'));
prevHome = process.env.HOME;
prevCodexHome = process.env.CODEX_HOME;
process.env.HOME = home;
process.env.CODEX_HOME = join(home, '.codex');
harness = await createHarness();
session = harness.ctx._session;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
rmSync(home, { recursive: true, force: true });
await harness.app.close();
});
it('claude: reads the whole message from the JSONL transcript', async () => {
const id = '40568a29-d4eb-4eb6-b671-8401428e4f39';
session.mode = 'claude';
session.claudeSessionId = id;
session.adoptClaudeSessionId = () => undefined;
const dir = join(home, '.claude', 'projects', '-workspace');
mkdirSync(dir, { recursive: true });
const row = (type: string, text: string, ts: string) => ({
type,
timestamp: ts,
message: { content: [{ type: 'text', text }] },
});
writeFileSync(
join(dir, `${id}.jsonl`),
[row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')]
.map((e) => JSON.stringify(e))
.join('\n')
);
const text = await lastText();
expect(text).toBe(MESSAGE);
expectWholeQuestions(text);
});
it('codex: reads the whole message from the rollout', async () => {
const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const workdir = join(home, 'work');
mkdirSync(workdir, { recursive: true });
session.mode = 'codex';
session.workingDir = workdir;
session.codexConfig = { resumeSessionId: uuid };
const dir = join(home, '.codex', 'sessions', '2026', '10', '30');
mkdirSync(dir, { recursive: true });
const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`);
const entries = [
{
timestamp: '2026-10-30T00:00:00Z',
type: 'session_meta',
payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' },
},
{
timestamp: '2026-10-30T00:00:01Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'plan the cleanup' },
},
{
timestamp: '2026-10-30T00:00:02Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] },
},
];
writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
utimesSync(file, 1_750_000_000, 1_750_000_000);
const text = await lastText();
expectWholeQuestions(text);
});
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
it(`${mode}: reads the whole message from the pane`, async () => {
session.mode = mode;
session.terminalBuffer = [
'› plan the cleanup',
'',
'model · project · main · Ready · Context 100% left',
'',
'• Called write_file',
'',
MESSAGE,
].join('\n');
expectWholeQuestions(await lastText());
});
}
it('copilot: reads the whole message from the pane screen', async () => {
session.mode = 'copilot';
const screen = [
' ❯ plan the cleanup 10:01',
'',
` ● ${INTRO}`,
'',
...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`),
'',
` ${OUTRO}`,
'',
' ~/project Session: 1 AIC used',
'────────────────────────────────',
' ❯ ',
'────────────────────────────────',
' ← open sidebar · / commands · ? help',
].join('\n');
harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux;
expectWholeQuestions(await lastText());
});
});
+354
View File
@@ -0,0 +1,354 @@
/**
* @fileoverview /api/discord: the bot token is a bearer secret, so it is never returned and the
* settings routes are admin only in multi-user mode. Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { _resetNotifyBuckets, registerDiscordRoutes } from '../../src/web/routes/discord-routes.js';
import { DiscordBridge, discordConfigPath, readDiscordConfig, type DiscordFetch } from '../../src/discord-bridge.js';
const TOKEN = 'MTA1.secret.token-value-1234567890';
const FULL = {
enabled: true,
token: TOKEN,
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
};
let dir: string;
let fetchImpl: ReturnType<typeof vi.fn<DiscordFetch>>;
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
const bridge = new DiscordBridge({
configDir: dir,
fetchImpl,
sendInput: async () => true,
socketFactory: () => ({ send() {}, close() {}, on() {} }),
});
const h = await createRouteTestHarness(
(app, ctx) => registerDiscordRoutes(app, { bridge, configDir: dir, ctx, hostTitle: () => 'codeman:test' }),
authUser ? { authUser } : undefined
);
return { ...h, bridge };
}
beforeEach(() => {
_resetNotifyBuckets();
dir = mkdtempSync(join(tmpdir(), 'discord-routes-'));
fetchImpl = vi.fn<DiscordFetch>(
async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 })
);
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('/api/discord', () => {
it('starts disabled with no token', async () => {
const { app } = await harness();
const data = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(data).toMatchObject({ enabled: false, hasToken: false, tokenMasked: '', gateway: 'off' });
expect(data).not.toHaveProperty('token');
});
it('saves, masks the token in every response and writes the file 0600', async () => {
const { app, bridge } = await harness();
const put = await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
expect(put.statusCode).toBe(200);
expect(put.body).not.toContain('secret');
expect(put.json().data).toMatchObject({ enabled: true, hasToken: true });
const get = await app.inject({ method: 'GET', url: '/api/discord' });
expect(get.body).not.toContain('secret');
expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600);
expect((await readDiscordConfig(dir)).token).toBe(TOKEN);
bridge.stop();
});
it('keeps the token when only an option changes, and clears it on an empty string', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' },
});
const cfg = await readDiscordConfig(dir);
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' });
const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } });
expect(cleared.json().data.hasToken).toBe(false);
bridge.stop();
});
it('stores startupHint (default on) and returns it', async () => {
const { app, bridge } = await harness();
const first = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(first).toMatchObject({ startupHint: true });
const put = await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { startupHint: false },
});
expect(put.json().data).toMatchObject({ startupHint: false });
bridge.stop();
});
it('refuses a bad id, an incomplete enable, and unknown fields', async () => {
const { app } = await harness();
const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } });
expect(bad.statusCode).toBe(400);
const incomplete = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: true } });
expect(incomplete.statusCode).toBe(400);
const extra = await app.inject({ method: 'PUT', url: '/api/discord', payload: { nope: 1 } });
expect(extra.statusCode).toBe(400);
});
it('POST /test needs a saved token, then posts to the parent channel', async () => {
const { app, bridge } = await harness();
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(400);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const res = await app.inject({ method: 'POST', url: '/api/discord/test' });
expect(res.json().data.ok).toBe(true);
expect(String(fetchImpl.mock.calls.at(-1)![0])).toContain('/channels/222222222222222222/messages');
bridge.stop();
});
it('is admin only in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ username: 'bob', role: 'user' });
expect((await app.inject({ method: 'GET', url: '/api/discord' })).statusCode).toBe(403);
expect((await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL })).statusCode).toBe(403);
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(403);
});
});
describe('POST /api/sessions/:id/discord-notify', () => {
const url = '/api/sessions/test-session-1/discord-notify';
it('is refused while the bridge is off, and posts into the session thread once on', async () => {
const { app, bridge } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
const off = await app.inject({ method: 'POST', url, payload: { message: 'press reset' } });
expect(off.statusCode).toBe(409);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const on = await app.inject({ method: 'POST', url, payload: { message: 'press reset', urgent: true } });
expect(on.json().data.ok).toBe(true);
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.some((b) => b.includes('press reset') && b.includes('<@333333333333333333>'))).toBe(true);
bridge.stop();
});
it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => {
const { app, bridge, ctx } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const ok = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ok.statusCode).toBe(200);
ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!);
const ambiguous = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ambiguous.statusCode).toBe(404);
const short = await app.inject({
method: 'POST',
url: '/api/sessions/test/discord-notify',
payload: { message: 'hi' },
});
expect(short.statusCode).toBe(404);
bridge.stop();
});
it("does not reveal another user's session through its id prefix (multi-user)", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, bridge, ctx } = await harness({ username: 'bob', role: 'user' });
(ctx.sessions.get('test-session-1') as unknown as { owner: string }).owner = 'alice';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(404);
expect(res.json().error).not.toContain('test-session-1');
bridge.stop();
});
it('lets two different deliberate messages from one tab through within 3 s (neither is lost)', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const first = await app.inject({ method: 'POST', url, payload: { message: 'first thing' } });
const second = await app.inject({ method: 'POST', url, payload: { message: 'second thing' } });
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(second.json().data.deduped).toBeUndefined();
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.filter((b) => b.includes('first thing') || b.includes('second thing'))).toHaveLength(2);
bridge.stop();
});
it('answers 200 with data.ok false when Discord refuses the post, which the CLI turns into exit 1', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ code: 0 }), { status: 401 }));
const res = await app.inject({ method: 'POST', url, payload: { message: 'help' } });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: false, status: 401 });
bridge.stop();
});
it('answers 429 RATE_LIMITED once a tab posts more than its share in a minute', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const codes: number[] = [];
for (let i = 0; i < 8; i++) {
codes.push(
(await app.inject({ method: 'POST', url, payload: { message: `ping ${i}`, urgent: true } })).statusCode
);
}
expect(codes.slice(0, 6)).toEqual([200, 200, 200, 200, 200, 200]);
expect(codes.slice(6)).toEqual([429, 429]);
const limited = await app.inject({ method: 'POST', url, payload: { message: 'again' } });
expect(limited.json().errorCode).toBe('RATE_LIMITED');
expect(limited.headers['retry-after']).toBe('60');
bridge.stop();
});
it('does not spend rate-limit tokens on a request refused for its file', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
for (let i = 0; i < 8; i++) {
const bad = await app.inject({ method: 'POST', url, payload: { message: 'x', file: 'no-such-file.txt' } });
expect(bad.statusCode).toBe(400);
}
expect((await app.inject({ method: 'POST', url, payload: { message: 'still allowed' } })).statusCode).toBe(200);
bridge.stop();
});
it('rejects an empty or oversized message', async () => {
const { app } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
expect((await app.inject({ method: 'POST', url, payload: { message: '' } })).statusCode).toBe(400);
expect((await app.inject({ method: 'POST', url, payload: { message: 'x'.repeat(2000) } })).statusCode).toBe(400);
});
});
describe('discord-notify with a file', () => {
let work: string;
let outside: string;
const url = '/api/sessions/test-session-1/discord-notify';
async function ready() {
const h = await harness();
(h.ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = work;
await h.app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
return h;
}
const post = (app: Awaited<ReturnType<typeof harness>>['app'], file: string) =>
app.inject({ method: 'POST', url, payload: { message: 'log attached', file } });
beforeEach(() => {
work = mkdtempSync(join(tmpdir(), 'discord-work-'));
outside = mkdtempSync(join(tmpdir(), 'discord-outside-'));
writeFileSync(join(work, 'run.log'), 'hello');
writeFileSync(join(outside, 'secret.txt'), 'nope');
});
afterEach(() => {
rmSync(work, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
it('uploads a file inside the session folder as multipart', async () => {
const { app, bridge } = await ready();
const res = await post(app, join(work, 'run.log'));
expect(res.json().data.ok).toBe(true);
const form = fetchImpl.mock.calls.map((c) => c[1].body).find((b) => b instanceof FormData) as FormData;
expect((form.get('files[0]') as File).name).toBe('run.log');
bridge.stop();
});
it.each([
['a relative path', () => 'run.log'],
['a file outside the folder', () => join(outside, 'secret.txt')],
['the folder itself', () => work],
['a missing file', () => join(work, 'nope.log')],
])('refuses %s', async (_n, path) => {
const { app, bridge } = await ready();
const res = await post(app, path());
expect(res.statusCode).toBe(400);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it.each([
['a .env file', () => ['.env', 'TOKEN=x']],
['a private key under .ssh', () => ['.ssh/id_ed25519', 'key']],
['Codeman state with the bot token', () => ['.codeman/discord.json', '{}']],
])('refuses %s even inside the session folder, as with a home directory', async (_n, entry) => {
const [rel, body] = entry();
mkdirSync(dirname(join(work, rel)), { recursive: true });
writeFileSync(join(work, rel), body);
const { app, bridge } = await ready();
const res = await post(app, join(work, rel));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/credentials/);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it('refuses a symlink inside the folder that points at a blocked file', async () => {
writeFileSync(join(work, '.env'), 'TOKEN=x');
symlinkSync(join(work, '.env'), join(work, 'notes.txt'));
const { app, bridge } = await ready();
const res = await post(app, join(work, 'notes.txt'));
expect(res.statusCode).toBe(400);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it('refuses to send when the session folder is not a directory it can probe', async () => {
const { app, ctx, bridge } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = join(work, 'run.log');
const res = await post(app, join(work, 'run.log'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/session folder/);
bridge.stop();
});
it('probes the file before realpath: a path that is not a regular file is refused', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'dir'));
expect((await post(app, join(work, 'dir'))).json().error).toMatch(/Not a regular file/);
bridge.stop();
});
it('refuses a symlink that points outside the folder', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'sub'));
symlinkSync(join(outside, 'secret.txt'), join(work, 'sub', 'link.txt'));
expect((await post(app, join(work, 'sub', 'link.txt'))).statusCode).toBe(400);
bridge.stop();
});
it('refuses a file over 8 MB', async () => {
const { app, bridge } = await ready();
writeFileSync(join(work, 'big.bin'), Buffer.alloc(8 * 1024 * 1024 + 1));
const res = await post(app, join(work, 'big.bin'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/8 MB/);
bridge.stop();
});
it('refuses a file for a remote session', async () => {
const { app, bridge, ctx } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { remote: object }).remote = { label: 'box' };
expect((await post(app, join(work, 'run.log'))).statusCode).toBe(400);
bridge.stop();
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* @fileoverview GET /api/doctor: the `codeman doctor` report for Settings → System → Diagnostics.
* The route runs the probe out of process (the engine is synchronous), so every test injects the
* runner; the default runner's parsing is covered against a faked `execFile`, and the CLI contract
* it relies on is exercised for real in test/doctor-cli-json.test.ts.
*
* Port: N/A (app.inject()).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { defaultDoctorRunner, registerDoctorRoutes, type DoctorRunner } from '../../src/web/routes/doctor-routes.js';
import type { DependencyReportJson } from '../../src/utils/dependency-report.js';
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, execFile: execFileMock };
});
const REPORT: DependencyReportJson = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
tools: [
{ id: 'node', label: 'Node.js', category: 'core', required: true, usedBy: [], status: 'ok', version: '22.1.0' },
{ id: 'tmux', label: 'tmux', category: 'core', required: true, usedBy: [], status: 'missing' },
],
};
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
execFileMock.mockReset();
});
describe('GET /api/doctor', () => {
it('returns the runner’s report in the success envelope', async () => {
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: REPORT });
expect(runner).toHaveBeenCalledWith(undefined);
});
it('passes a valid category through and rejects an unknown one without running anything', async () => {
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
expect((await app.inject({ method: 'GET', url: '/api/doctor?category=office' })).statusCode).toBe(200);
expect(runner).toHaveBeenLastCalledWith('office');
runner.mockClear();
const bad = await app.inject({ method: 'GET', url: '/api/doctor?category=%3Brm%20-rf' });
expect(bad.statusCode).toBe(400);
expect(bad.json().errorCode).toBe('INVALID_INPUT');
expect(runner).not.toHaveBeenCalled();
});
it('answers 500 with a message when the runner fails', async () => {
const runner: DoctorRunner = async () => {
throw new Error('spawn blew up');
};
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(500);
expect(res.json().error).toContain('spawn blew up');
expect(res.json().errorCode).toBe('INTERNAL_ERROR');
});
it('single-flights: concurrent requests for a category share one run, and a later one runs again', async () => {
const releases: Array<(r: DependencyReportJson) => void> = [];
const runner = vi.fn<DoctorRunner>(() => new Promise<DependencyReportJson>((res) => releases.push(res)));
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const first = app.inject({ method: 'GET', url: '/api/doctor' });
const second = app.inject({ method: 'GET', url: '/api/doctor' });
const other = app.inject({ method: 'GET', url: '/api/doctor?category=office' });
await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(2));
releases[0](REPORT);
expect((await first).statusCode).toBe(200);
expect((await second).statusCode).toBe(200);
expect(runner).toHaveBeenCalledTimes(2); // unfiltered (shared) + office
releases[1](REPORT);
await other;
runner.mockImplementation(async () => REPORT);
await app.inject({ method: 'GET', url: '/api/doctor' });
expect(runner).toHaveBeenCalledTimes(3);
});
it('multi-user: a non-admin is refused and nothing is probed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner), {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(403);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, async () => REPORT), {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/doctor' })).statusCode).toBe(200);
});
});
describe('defaultDoctorRunner', () => {
type Done = (err: Error | null, stdout: string) => void;
const respond = (err: Error | null, stdout: string) =>
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, done: Done) => done(err, stdout));
it('runs `doctor --json` in a child of this same entry script, never in-process', async () => {
respond(null, JSON.stringify(REPORT));
await defaultDoctorRunner('core');
const [bin, args, opts] = execFileMock.mock.calls[0];
expect(bin).toBe(process.execPath);
expect(args.slice(-4)).toEqual(['doctor', '--json', '--category', 'core']);
expect(args).toContain(process.argv[1]);
expect((opts as { timeout: number }).timeout).toBeGreaterThan(0);
});
it('treats a non-zero exit with a valid report as a normal result (a missing required tool exits 1)', async () => {
respond(Object.assign(new Error('exit 1'), { code: 1 }), JSON.stringify(REPORT));
await expect(defaultDoctorRunner()).resolves.toEqual(REPORT);
});
it.each([
['empty output', ''],
['non-JSON output', 'Segmentation fault'],
['JSON of the wrong shape', '{"hello":"world"}'],
])('rejects %s', async (_label, stdout) => {
respond(null, stdout);
await expect(defaultDoctorRunner()).rejects.toThrow();
});
it('reports a killed child (the 30 s timeout) as a timeout, not the raw command line', async () => {
respond(Object.assign(new Error('Command failed: node doctor --json'), { killed: true, signal: 'SIGTERM' }), '');
await expect(defaultDoctorRunner()).rejects.toThrow('timed out after 30 s');
});
it('passes the child’s own error through when there is no report at all', async () => {
respond(new Error('ETIMEDOUT'), '');
await expect(defaultDoctorRunner()).rejects.toThrow('ETIMEDOUT');
});
});
+288
View File
@@ -0,0 +1,288 @@
/**
* @fileoverview The edit history panel's API (GET/POST /api/sessions/:id/edits…): a real
* EditHistorySync and store over a fake transcript reader. Port: N/A (app.inject()).
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { hunksToUnified, registerEditHistoryRoutes, relativeTo } from '../../src/web/routes/edit-history-routes.js';
import { EditHistorySync, NO_TRANSCRIPT, type EditHistorySyncDeps } from '../../src/web/edit-history-sync.js';
import { EditHistoryStore } from '../../src/edit-history-store.js';
import type { EditSources } from '../../src/edit-history-local-source.js';
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
let dir: string;
let session: Record<string, unknown>;
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
return (
JSON.stringify({
type: 'user',
timestamp: new Date(ts).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 2, lines: ['-a', '+b', '+c'] }],
},
...extra,
}) + '\n'
);
}
function sources(main: string, sub?: string): EditSources {
const files = [{ relPath: 'main', size: Buffer.byteLength(main), startOffset: 0, chunk: Buffer.from(main) }];
const metas: Record<string, string> = {};
if (sub) {
files.push({
relPath: 'subagents/agent-a1.jsonl',
size: Buffer.byteLength(sub),
startOffset: 0,
chunk: Buffer.from(sub),
});
metas.a1 = JSON.stringify({
description: 'Fix it',
agentType: 'coding',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
});
}
return { transcriptId: 'conv1', files, metas };
}
async function setup(
deps: Partial<EditHistorySyncDeps> = {},
opts: { authUser?: { username: string; role: 'admin' | 'user' } } = {}
) {
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts')));
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: read,
probe: async (p) => p.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir, debounceMs: 0 });
opened.push(store);
return store;
},
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
...deps,
});
const h = await createRouteTestHarness((app, ctx) => registerEditHistoryRoutes(app, ctx, sync), {
authUser: opts.authUser,
});
session = h.ctx._session as unknown as Record<string, unknown>;
session.workingDir = '/w';
session.mode = 'claude';
session.claudeSessionId = 'conv1';
return { ...h, read, sync };
}
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
const opened: EditHistoryStore[] = [];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-routes-'));
});
afterEach(async () => {
delete process.env.CODEMAN_MULTIUSER;
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(dir, { recursive: true, force: true });
});
const URL = '/api/sessions/test-session-1/edits';
describe('GET /api/sessions/:id/edits', () => {
it('syncs on ?sync=1 and lists entries without diffs, paths relative to the workspace', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(200);
const data = res.json().data;
expect(data).toMatchObject({ state: 'ok', backfillTruncated: false, agents: {} });
expect(data.entries).toEqual([
expect.objectContaining({
id: 't1',
path: '/w/src/a.ts',
relPath: 'src/a.ts',
tool: 'Edit',
added: 2,
removed: 1,
}),
]);
expect(data.entries[0]).not.toHaveProperty('hunks');
});
it('starts the sync before it loads the store (a deletion right after the session check stops the sync)', async () => {
const { app, sync } = await setup();
const calls: string[] = [];
const syncFn = sync.sync.bind(sync);
const getStore = sync.getStore.bind(sync);
vi.spyOn(sync, 'sync').mockImplementation((...a) => (calls.push('sync'), syncFn(...a)));
vi.spyOn(sync, 'getStore').mockImplementation((...a) => (calls.push('getStore'), getStore(...a)));
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
await app.inject({ method: 'POST', url: `${URL}/older` });
expect(calls).toEqual(['sync', 'getStore', 'sync', 'getStore']);
});
it('without sync=1 answers from the store and does not read', async () => {
const { app, read } = await setup();
const res = await app.inject({ method: 'GET', url: URL });
expect(res.json().data).toMatchObject({ state: 'ok', entries: [] });
expect(read).not.toHaveBeenCalled();
});
it('labels subagent entries and shows their paths relative to the worktree', async () => {
const sub = editLine('t9', T0 + 5, '/w/.claude/worktrees/agent-a1/lib/x.ts', { isSidechain: true, agentId: 'a1' });
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'), sub));
const { app } = await setup({ readSources: read });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data.agents).toEqual({
a1: { description: 'Fix it', agentType: 'coding', worktreeBranch: 'worktree-agent-a1' },
});
expect(data.entries.find((e: { id: string }) => e.id === 't9')).toMatchObject({
agentId: 'a1',
relPath: 'lib/x.ts',
});
});
it('a read error answers unavailable with the stored entries', async () => {
let fail = false;
const read = vi.fn(async () => {
if (fail) throw new Error('EACCES: permission denied');
return sources(editLine('t1', T0, '/w/src/a.ts'));
});
const { app } = await setup({ readSources: read });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
fail = true;
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: 'EACCES: permission denied' });
expect(data.entries).toHaveLength(1);
});
it('hands the reader the session ids and its own CLAUDE_CONFIG_DIR', async () => {
const seen: unknown[] = [];
const { app } = await setup({
transcriptPath: async (s) => {
seen.push({ id: s.id, claudeSessionId: s.claudeSessionId, claudeConfigDir: s.claudeConfigDir });
return '/p/conv1.jsonl';
},
});
session.claudeConfigDirOverride = '/cfg/alt ';
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(seen).toEqual([{ id: 'test-session-1', claudeSessionId: 'conv1', claudeConfigDir: '/cfg/alt' }]);
});
it('a session without a transcript yet answers unavailable (no-transcript)', async () => {
const { app, read } = await setup({ transcriptPath: async () => null });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: NO_TRANSCRIPT, entries: [] });
expect(read).not.toHaveBeenCalled();
});
it.each([
['remote', { remote: { host: 'huff' } }],
['docker', { docker: { image: 'x' } }],
['mode', { mode: 'shell' }],
])('answers unsupported (%s) without reading or loading a store', async (reason, patch) => {
const loadStore = vi.fn(async (id: string) => EditHistoryStore.load(id, { dir, debounceMs: 0 }));
const { app, read } = await setup({ loadStore });
Object.assign(session, patch);
const empty = { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
for (const url of [URL, `${URL}?sync=1`]) {
expect((await app.inject({ method: 'GET', url })).json().data).toEqual(empty);
}
expect((await app.inject({ method: 'POST', url: `${URL}/older` })).json().data).toEqual(empty);
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
expect(read).not.toHaveBeenCalled();
expect(loadStore).not.toHaveBeenCalled();
});
it('404s an unknown session', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: '/api/sessions/nope/edits' })).statusCode).toBe(404);
});
it('multi-user: another user’s session is not found and nothing is read', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, read } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(404);
expect(read).not.toHaveBeenCalled();
});
});
describe('GET /api/sessions/:id/edits/:entryId', () => {
it('returns the diff as unified text', async () => {
const { app } = await setup();
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
const res = await app.inject({ method: 'GET', url: `${URL}/t1` });
expect(res.json().data).toEqual({ diff: '@@ -4,1 +4,2 @@\n-a\n+b\n+c', truncated: false });
});
it('404s an edit the session does not have', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: `${URL}/missing` })).statusCode).toBe(404);
});
it('multi-user: another user’s diff is not found', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
session.owner = 'alice';
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
});
});
describe('POST /api/sessions/:id/edits/older', () => {
it('reads the next older block and lists the result', async () => {
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
const full = Buffer.from(lines);
const read = vi.fn(
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[], budget: number) => {
const req = requests.find((r) => r.relPath === 'main');
const start = req ? req.offset : full.length - budget;
const len = req?.len ?? full.length - start;
return {
transcriptId: 'conv1',
metas: {},
files: [{ relPath: 'main', size: full.length, startOffset: start, chunk: full.subarray(start, start + len) }],
};
}
);
const { app } = await setup({ readSources: read, budget: 400 });
const first = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(first.backfillTruncated).toBe(true);
const older = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(older.entries.length).toBeGreaterThan(first.entries.length);
});
it('answers unsupported for a remote session without reading', async () => {
const { app, read } = await setup();
session.remote = { host: 'huff' };
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
expect(read).not.toHaveBeenCalled();
});
});
describe('helpers', () => {
it('relativeTo strips only a real directory prefix', () => {
expect(relativeTo('/w/a/b.ts', '/w')).toBe('a/b.ts');
expect(relativeTo('/w/a/b.ts', '/w/')).toBe('a/b.ts');
expect(relativeTo('/wx/b.ts', '/w')).toBe('/wx/b.ts');
expect(relativeTo('/w/b.ts', undefined)).toBe('/w/b.ts');
});
it('hunksToUnified writes one header per hunk', () => {
expect(
hunksToUnified([
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-x', '+y'] },
{ oldStart: 9, oldLines: 0, newStart: 9, newLines: 1, lines: ['+z'] },
])
).toBe('@@ -1,1 +1,1 @@\n-x\n+y\n@@ -9,0 +9,1 @@\n+z');
});
});
@@ -104,6 +104,39 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
});
it("forces a non-granted owner's copilot --yolo off and keeps the model", async () => {
const out = await _clampExternalCliBypassForOwner(
'peon',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ allowAll: true, model: 'claude-sonnet-5.5' }
);
expect(out.copilotConfig).toEqual({ allowAll: false, model: 'claude-sonnet-5.5' });
});
it('leaves copilot absent when nothing was sent (a bare copilot is already Manual Approval)', async () => {
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
expect(out.copilotConfig).toBeUndefined();
});
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
// fields would drop the effort here without a word.
const out = await _clampExternalCliBypassForOwner(
'peon',
{ dangerouslyBypassApprovals: true, reasoningEffort: 'xhigh' },
undefined,
undefined,
undefined,
undefined
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' });
});
it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => {
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
expect(out.codexConfig).toBeUndefined();
@@ -556,6 +556,52 @@ describe('file-routes attachment path guard (COD-53)', () => {
});
});
// ===== XLSX: client-side preview, same guard and routes =====
// xlsx joins the extension allowlist so the overlay can preview a workbook
// outside the workspace by id. Nothing else about the pipeline changes; only
// `?preview=true` adds a tighter size cap because the browser parses it.
describe('xlsx attachments', () => {
async function registerXlsx(size: number) {
mockedStat.mockResolvedValue({ size, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/report.xlsx', notify: false },
});
expect(res.statusCode).toBe(200);
return JSON.parse(res.body).data as { attachmentId: string; attachmentType: string };
}
it('registers an xlsx as a spreadsheet attachment', async () => {
const data = await registerXlsx(2048);
expect(data.attachmentType).toBe('spreadsheet');
});
it('still refuses xls and ods (download-only, no preview)', async () => {
for (const path of ['/tmp/legacy.xls', '/tmp/open.ods']) {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path, notify: false },
});
expect(res.statusCode, path).toBe(400);
}
});
it('caps a ?preview=true fetch at 10 MB but still serves a plain download', async () => {
const { attachmentId } = await registerXlsx(11 * 1024 * 1024);
const base = `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/raw`;
const preview = await harness.app.inject({ method: 'GET', url: `${base}?preview=true` });
expect(preview.statusCode).toBe(413);
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
mockedCreateReadStream.mockReturnValue(Readable.from([Buffer.from('PK')]) as never);
const download = await harness.app.inject({ method: 'GET', url: `${base}?preview=true&download=true` });
expect(download.statusCode).toBe(200);
});
});
// ===== Quiet registration (click-to-preview) =====
// The file-preview overlay registers a clicked out-of-workspace path to mint
// an id it can render by. It is already putting the file on screen, so the
+148 -2
View File
@@ -18,6 +18,7 @@ vi.mock('node:fs/promises', () => ({
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => 'file content'),
stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })),
realpath: vi.fn(async (p: string) => p),
},
}));
@@ -442,6 +443,86 @@ describe('file-routes', () => {
}
});
// The folder grid is a third file-serving surface (folder-listing.ts): the
// same rule as the picker, on the resolved folder AND on each served file.
describe('folder grid', () => {
const ALICE = `${SPACES}/alice/cases`;
const BOB = `${SPACES}/bob/renders`;
const owned = async (role: 'admin' | 'user', username: string) => {
const scoped = await harnessAs(role, username);
scoped.ctx._session.owner = username;
return scoped;
};
const grant = (scoped: RouteTestHarness, path: string) =>
scoped.app.inject({
method: 'POST',
url: `/api/sessions/${scoped.ctx._sessionId}/folders`,
payload: { path },
});
beforeEach(() => {
mockedStat.mockImplementation(async (p) => {
const file = /\.\w+$/.test(String(p));
return { size: 100, mtimeMs: 1, isFile: () => file, isDirectory: () => !file } as never;
});
});
it("refuses another user's space and never reads it", async () => {
const scoped = await owned('user', 'bob');
try {
mockedReaddir.mockClear();
const res = await grant(scoped, ALICE);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('Folder is outside your space');
expect(mockedReaddir).not.toHaveBeenCalled();
} finally {
await scoped.app.close();
}
});
it('lists a folder in their own space', async () => {
const scoped = await owned('user', 'bob');
try {
mockedReaddir.mockResolvedValueOnce([] as never);
const res = await grant(scoped, BOB);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.path).toBe(BOB);
} finally {
await scoped.app.close();
}
});
it("refuses a file of their own folder that resolves into another user's space", async () => {
const scoped = await owned('user', 'bob');
try {
mockedReaddir.mockResolvedValueOnce([] as never);
const { folderId } = JSON.parse((await grant(scoped, BOB)).body).data;
mockedRealpathSync.mockImplementation(((p: string) =>
p === `${BOB}/plan.png` ? `${ALICE}/secret-project/plan.png` : p) as never);
const res = await scoped.app.inject({
method: 'GET',
url: `/api/sessions/${scoped.ctx._sessionId}/folders/${folderId}/files/plan.png/raw`,
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('File is outside your space');
expect(mockedCreateReadStream).not.toHaveBeenCalled();
} finally {
await scoped.app.close();
}
});
it('lets a multi-user admin list any folder', async () => {
const scoped = await owned('admin', 'root');
try {
mockedReaddir.mockResolvedValueOnce([] as never);
expect((await grant(scoped, ALICE)).statusCode).toBe(200);
} finally {
await scoped.app.close();
}
});
});
it('keeps the host-wide roots for a multi-user admin', async () => {
const scoped = await harnessAs('admin', 'root');
try {
@@ -681,6 +762,18 @@ describe('file-routes', () => {
expect(body.data.url).toContain('file-raw');
});
it('classifies avif as an image so the viewer renders it instead of dumping bytes', async () => {
mockedStat.mockResolvedValue({ size: 1024 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=photo.avif`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.type).toBe('image');
});
it('returns audio metadata for audio files', async () => {
mockedStat.mockResolvedValue({ size: 2048 } as never);
@@ -695,12 +788,12 @@ describe('file-routes', () => {
expect(body.data.url).toContain('file-raw');
});
it('flags known-binary extensions (e.g. xlsx) instead of dumping mojibake', async () => {
it('flags known-binary extensions (e.g. xls) instead of dumping mojibake', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xls`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
@@ -709,6 +802,30 @@ describe('file-routes', () => {
expect(body.data.content).toBeUndefined();
});
it('classifies xlsx as a client-side spreadsheet preview, never a text body', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
});
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.type).toBe('spreadsheet');
expect(body.data.url).toBe(`/api/sessions/${harness.ctx._sessionId}/file-raw?path=sheet.xlsx`);
expect(body.data.content).toBeUndefined();
});
it('keeps ods (and xls) download-only binaries', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.ods`,
});
expect(JSON.parse(res.body).data.type).toBe('binary');
});
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
mockedReadFile.mockResolvedValue(binary as never);
@@ -817,6 +934,19 @@ describe('file-routes', () => {
expect(res.headers['content-type']).toBe('image/png');
});
it('serves avif with its image type, since <img> refuses an octet-stream', async () => {
const content = Buffer.from('fake avif data');
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
mockedStat.mockResolvedValue({ size: content.length } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=photo.avif`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('image/avif');
});
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
@@ -868,6 +998,22 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(413);
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
});
it('caps an xlsx ?preview=true fetch at 10 MB, leaving downloads and small previews alone', async () => {
mockedStat.mockResolvedValue({ size: 11 * 1024 * 1024 } as never);
const url = `/api/sessions/${harness.ctx._sessionId}/file-raw?path=book.xlsx`;
const preview = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
expect(preview.statusCode).toBe(413);
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
const download = await harness.app.inject({ method: 'GET', url: `${url}&preview=true&download=true` });
expect(download.statusCode).toBe(200);
mockedStat.mockResolvedValue({ size: 2048 } as never);
const small = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
expect(small.statusCode).toBe(200);
});
});
// ========== DELETE /api/sessions/:id/tail-file/:streamId ==========
+213
View File
@@ -0,0 +1,213 @@
/**
* @fileoverview POST /api/filesystem/mkdir: the path picker's "New folder". Real filesystem under
* test/setup.ts's temp HOME (a root the picker offers), so this proves what is on disk, not a mock.
* Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { LinkCaseSchema } from '../../src/web/schemas.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
describe('POST /api/filesystem/mkdir', () => {
let workDir = '';
beforeEach(() => {
if (!isSandboxedHome()) throw new Error('filesystem-mkdir-routes.test.ts creates folders: run it via npm test');
workDir = realpathSync(mkdtempSync(join(homedir(), 'picker-mkdir-')));
});
afterEach(() => {
if (workDir) safeRmHomeTree(workDir);
workDir = '';
});
const mkdir = async (payload: Record<string, unknown>) => {
const { app } = await createRouteTestHarness(registerFileRoutes);
return app.inject({ method: 'POST', url: '/api/filesystem/mkdir', payload });
};
it('creates one folder inside the folder being viewed and answers its path', async () => {
const res = await mkdir({ path: workDir, name: 'fresh' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toEqual({ path: join(workDir, 'fresh') });
expect(readdirSync(workDir)).toEqual(['fresh']);
});
it.each(['Kay app', 'project_v2', 'v1.2', 'my-folder', 'Café 2026'])(
'accepts %s, and Link Existing then accepts the folder it made',
async (name) => {
const res = await mkdir({ path: workDir, name });
expect(res.statusCode).toBe(200);
expect(LinkCaseSchema.safeParse({ name: 'x', path: res.json().data.path }).success).toBe(true);
}
);
it('refuses the cases directory as the parent (400): a folder there is already a case', async () => {
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
try {
const res = await mkdir({ path: casesDir, name: 'myproj' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Add Case > New Case');
expect(existsSync(join(casesDir, 'myproj'))).toBe(false);
// A folder inside a case is fine.
mkdirSync(join(casesDir, 'real-case'));
expect((await mkdir({ path: join(casesDir, 'real-case'), name: 'sub' })).statusCode).toBe(200);
} finally {
safeRmHomeTree(casesDir);
}
});
it('answers 404 for a parent that is gone, the same answer browse gives', async () => {
const res = await mkdir({ path: join(workDir, 'gone'), name: 'x' });
expect(res.statusCode).toBe(404);
});
it('refuses a parent that is a file (400), like browse', async () => {
writeFileSync(join(workDir, 'afile'), 'x');
const res = await mkdir({ path: join(workDir, 'afile'), name: 'x' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('The folder to create in must be a directory');
});
it('creates inside a hidden folder only when the picker is showing hidden entries', async () => {
mkdirSync(join(workDir, '.hid'));
const denied = await mkdir({ path: join(workDir, '.hid'), name: 'x' });
expect(denied.statusCode).toBe(403);
const ok = await mkdir({ path: join(workDir, '.hid'), name: 'x', showHidden: 'true' });
expect(ok.statusCode).toBe(200);
expect(existsSync(join(workDir, '.hid', 'x'))).toBe(true);
// A dot name is still refused there.
expect((await mkdir({ path: join(workDir, '.hid'), name: '.y', showHidden: 'true' })).statusCode).toBe(400);
});
it('trims the name', async () => {
const res = await mkdir({ path: workDir, name: ' spaced ' });
expect(res.json().data.path).toBe(join(workDir, 'spaced'));
});
it('refuses an existing folder (409) without touching it', async () => {
mkdirSync(join(workDir, 'taken'));
writeFileSync(join(workDir, 'taken', 'keep.txt'), 'mine');
const res = await mkdir({ path: workDir, name: 'taken' });
expect(res.statusCode).toBe(409);
expect(res.json().error).toContain('already exists');
expect(readdirSync(join(workDir, 'taken'))).toEqual(['keep.txt']);
});
it.each([
['a slash', 'a/b'],
['a backslash', 'a\\b'],
['a traversal', '..'],
['a dot name', '.'],
['a hidden name', '.secret'],
['a lone tilde', '~'],
['a leading tilde', '~tilde'],
['an empty name', ' '],
['an apostrophe', "Kay's app"],
['parentheses', 'project (v2)'],
['a plus', 'a+b'],
['a comma', 'notes, 2026'],
['a hash', 'c#'],
['brackets', '[draft]'],
['a double dot inside', 'v1..2'],
['a tab', 'a\tb'],
['an escape', 'a\x1b[31mb'],
['a bell', 'a\x07b'],
['a delete char', 'a\x7fb'],
['a name over 255 bytes', 'é'.repeat(200)],
])('refuses %s in the name (400) and creates nothing', async (_label, name) => {
const res = await mkdir({ path: workDir, name });
expect(res.statusCode).toBe(400);
expect(readdirSync(workDir)).toEqual([]);
});
it('refuses a parent that does not exist (404)', async () => {
const res = await mkdir({ path: join(workDir, 'nope'), name: 'x' });
expect(res.statusCode).toBe(404);
expect(existsSync(join(workDir, 'nope'))).toBe(false);
});
it('refuses a parent outside the browse roots (403)', async () => {
const res = await mkdir({ path: '/etc', name: 'x' });
expect(res.statusCode).toBe(403);
expect(existsSync('/etc/x')).toBe(false);
});
it('refuses a relative or traversing parent (400)', async () => {
expect((await mkdir({ path: 'relative', name: 'x' })).statusCode).toBe(400);
expect((await mkdir({ path: `${workDir}/../x`, name: 'x' })).statusCode).toBe(400);
});
describe('multi-user scoping', () => {
let prevMultiUser: string | undefined;
let prevSpaces: string | undefined;
let spaces = '';
beforeEach(() => {
prevMultiUser = process.env.CODEMAN_MULTIUSER;
prevSpaces = process.env.CODEMAN_USER_SPACES_DIR;
spaces = join(workDir, 'spaces');
mkdirSync(join(spaces, 'alice'), { recursive: true });
mkdirSync(join(spaces, 'bob'));
process.env.CODEMAN_MULTIUSER = '1';
process.env.CODEMAN_USER_SPACES_DIR = spaces;
});
afterEach(() => {
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR;
else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces;
});
it("refuses a non-admin creating inside another user's space (403) and creates nothing", async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'alice'), name: 'x' },
});
expect(res.statusCode).toBe(403);
expect(readdirSync(join(spaces, 'alice'))).toEqual([]);
});
it("refuses a non-admin's own cases folder (400) but not the top of their space", async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const own = join(spaces, 'bob', 'cases');
mkdirSync(own, { recursive: true });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: own, name: 'myproj' },
});
expect(res.statusCode).toBe(400);
expect(existsSync(join(own, 'myproj'))).toBe(false);
});
it('lets a non-admin create inside their own space', async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'bob'), name: 'mine' },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(spaces, 'bob', 'mine'))).toBe(true);
});
it('404s a sessionId owned by another user, and adopts none of its folder', async () => {
const scoped = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
scoped.ctx._session.owner = 'alice';
const res = await scoped.app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'bob'), name: 'x', sessionId: scoped.ctx._sessionId },
});
expect(res.statusCode).toBe(404);
expect(existsSync(join(spaces, 'bob', 'x'))).toBe(false);
});
});
});
@@ -0,0 +1,37 @@
/**
* @fileoverview `POST /api/filesystem/mkdir` probes the folder bounded before the picker's
* resolver runs `realpathSync` on it (#516): a mount that stopped answering is refused, and
* the request never reaches a synchronous call that would freeze the server.
*
* The probe is mocked: a mount that never answers cannot be made here. Port: none (inject).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn(actual.probePathKind) };
});
import { probePathKind } from '../../src/utils/bounded-path-probe.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { createRouteTestHarness } from './_route-test-utils.js';
describe('POST /api/filesystem/mkdir on an unresponsive mount', () => {
afterEach(() => vi.mocked(probePathKind).mockClear());
it('refuses before the picker resolver touches the path', async () => {
vi.mocked(probePathKind).mockResolvedValueOnce('unknown');
const { app } = await createRouteTestHarness(registerFileRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: '/mnt/dead/projects', name: 'fresh' },
});
expect(res.statusCode).toBe(422);
expect(res.json().errorCode).toBe('OPERATION_FAILED');
expect(res.json().error).toMatch(/not responding|not checked/);
expect(probePathKind).toHaveBeenCalledWith('/mnt/dead/projects', { pastCap: true });
});
});
+598
View File
@@ -0,0 +1,598 @@
/**
* @fileoverview Folder grid — `POST /api/sessions/:id/folders` grants + lists a
* folder, `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)` serves one
* of its files by name. Local folders are real temp dirs; a remote case mocks only
* the ssh IO, and the remote listing script itself runs on a real `sh`.
*
* Port: N/A (app.inject doesn't open ports)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Readable } from 'node:stream';
import { execFileSync } from 'node:child_process';
import {
closeSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { folderGrants, validFolderFileName } from '../../src/folder-listing.js';
import type { SessionRemote } from '../../src/types/session.js';
vi.mock('../../src/remote-files.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/remote-files.js')>();
return {
...actual,
remoteListFolder: vi.fn(),
remoteProbePaths: vi.fn(),
remoteCreateReadStream: vi.fn(),
};
});
vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn(actual.probePathKind) };
});
import { probePathKind } from '../../src/utils/bounded-path-probe.js';
import {
buildRemoteFolderListCommand,
parseRemoteFolderListOutput,
remoteCreateReadStream,
remoteListFolder,
remoteProbePaths,
} from '../../src/remote-files.js';
const mockedProbePathKind = vi.mocked(probePathKind);
const mockedListFolder = vi.mocked(remoteListFolder);
const mockedProbePaths = vi.mocked(remoteProbePaths);
const mockedCreateReadStream = vi.mocked(remoteCreateReadStream);
function touch(path: string, body: string, mtimeSec: number) {
writeFileSync(path, body);
utimesSync(path, mtimeSec, mtimeSec);
}
describe('folder grid routes — local folder', () => {
let harness: RouteTestHarness;
let sessionId: string;
let root: string;
let dir: string;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
sessionId = harness.ctx._sessionId;
root = mkdtempSync(join(tmpdir(), 'codeman-folder-grid-'));
dir = join(root, 'renders');
mkdirSync(dir);
touch(join(dir, 'old.png'), 'old-png', 1_700_000_000);
touch(join(dir, 'new.PNG'), 'new-png', 1_700_000_500);
touch(join(dir, 'clip.mp4'), 'mp4', 1_700_000_100);
touch(join(dir, 'notes.xyz'), 'unsupported', 1_700_000_900);
touch(join(dir, '.hidden.png'), 'hidden', 1_700_000_900);
mkdirSync(join(dir, 'shots'));
mkdirSync(join(dir, '.cache'));
harness.ctx._session.workingDir = root;
});
afterEach(async () => {
await harness.app.close();
folderGrants.clearSession(sessionId);
rmSync(root, { recursive: true, force: true });
delete process.env.CODEMAN_ATTACHMENT_CONFINE;
delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
});
async function grant(path: string) {
return harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/folders`, payload: { path } });
}
it('lists supported files newest first plus subfolders, skipping hidden + unsupported', async () => {
const res = await grant(dir);
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body).data;
expect(data.folderId).toMatch(/^fld_/);
expect(data.path).toBe(dir);
expect(data.files.map((f: { name: string }) => f.name)).toEqual(['new.PNG', 'clip.mp4', 'old.png']);
expect(data.files[0]).toMatchObject({ extension: 'png', attachmentType: 'image', size: 7 });
expect(data.folders).toEqual(['shots']);
expect(data.truncated).toBe(false);
});
it('grants the same folder the same id', async () => {
const a = JSON.parse((await grant(dir)).body).data.folderId;
const b = JSON.parse((await grant(`${dir}/`)).body).data.folderId;
expect(b).toBe(a);
});
it('rejects a relative path, a file, and a missing folder', async () => {
expect((await grant('renders')).statusCode).toBe(400);
expect((await grant(join(dir, 'old.png'))).statusCode).toBe(400);
expect((await grant(join(root, 'nope'))).statusCode).toBe(404);
});
it('refuses a blocked folder and a folder outside a confined workspace', async () => {
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = dir;
expect((await grant(dir)).statusCode).toBe(403);
delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
harness.ctx._session.workingDir = join(dir, 'shots');
expect((await grant(dir)).statusCode).toBe(403);
harness.ctx._session.workingDir = root;
expect((await grant(dir)).statusCode).toBe(200);
});
it('never reads a refused folder: the guard runs on the resolved folder first', async () => {
const readdir = vi.spyOn(fs, 'readdir');
try {
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = dir;
expect((await grant(dir)).statusCode).toBe(403);
expect(readdir).not.toHaveBeenCalled();
} finally {
readdir.mockRestore();
}
});
it('answers a folder or file that does not respond with 503, never 404, and does not read it', async () => {
const readdir = vi.spyOn(fs, 'readdir');
try {
mockedProbePathKind.mockResolvedValueOnce('unknown');
const res = await grant(dir);
expect(res.statusCode).toBe(503);
expect(JSON.parse(res.body).error).toContain(`Folder is not responding or not readable: ${dir}`);
expect(readdir).not.toHaveBeenCalled();
} finally {
readdir.mockRestore();
}
const { folderId } = JSON.parse((await grant(dir)).body).data;
mockedProbePathKind.mockResolvedValueOnce('unknown');
const file = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`,
});
expect(file.statusCode).toBe(503);
expect(JSON.parse(file.body).error).toContain('File is not responding or not readable');
});
it('does not serve a hidden file by name, as the listing hides it', async () => {
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/.hidden.png/raw`,
});
expect(res.statusCode).toBe(400);
});
it('serves a granted file by name, with ranges', async () => {
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
expect(res.body).toBe('old-png');
const ranged = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`,
headers: { range: 'bytes=0-2' },
});
expect(ranged.statusCode).toBe(206);
expect(ranged.body).toBe('old');
});
it('redirects a non-office preview to raw', async () => {
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/clip.mp4/preview`,
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe(`/api/sessions/${sessionId}/folders/${folderId}/files/clip.mp4/raw`);
});
it('never serves a name that leaves the folder or has an unsupported type', async () => {
const { folderId } = JSON.parse((await grant(dir)).body).data;
writeFileSync(join(root, 'outside.png'), 'outside');
for (const name of ['..%2Foutside.png', '..', '.', 'notes.xyz', 'shots%2Fx.png']) {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/${name}/raw`,
});
expect(res.statusCode, name).not.toBe(200);
}
const missing = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/gone.png/raw`,
});
expect(missing.statusCode).toBe(404);
});
it('refuses a file in the folder that symlinks to a blocked target', async () => {
const secret = join(root, 'secret');
mkdirSync(secret);
writeFileSync(join(secret, 'key.png'), 'secret');
symlinkSync(join(secret, 'key.png'), join(dir, 'innocent.png'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = secret;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/innocent.png/raw`,
});
expect(res.statusCode).toBe(403);
});
// The attachment gate (`registerExternalAttachment`) judges the file a symlink
// RESOLVES to: its extension must be a supported one and it must be a regular
// file. The folder routes only looked at the requested name, so a name that
// passed (`shot.png`) could serve anything it pointed at.
describe('the resolved target is held to the attachment gate', () => {
const files = (folderId: string, name: string, leaf: 'raw' | 'preview' | 'thumbnail' = 'raw') =>
harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/${encodeURIComponent(name)}/${leaf}`,
});
it('refuses a supported-looking name whose target has an unsupported extension', async () => {
writeFileSync(join(root, 'secret.bin'), 'secret');
symlinkSync(join(root, 'secret.bin'), join(dir, 'shot.png'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
for (const leaf of ['raw', 'preview', 'thumbnail'] as const) {
const res = await files(folderId, 'shot.png', leaf);
expect(res.statusCode, leaf).toBe(400);
expect(res.body, leaf).not.toContain('secret');
}
});
it('refuses a name whose target has no extension at all', async () => {
writeFileSync(join(root, 'history'), 'private history');
symlinkSync(join(root, 'history'), join(dir, 'notes.txt'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await files(folderId, 'notes.txt');
expect(res.statusCode).toBe(400);
expect(res.body).not.toContain('private history');
});
it('refuses a device node behind a supported name', async () => {
// /dev/null, not /dev/zero: the same class (character device, no
// extension), but it ends the stream instead of filling the test's memory
// should the guard ever be missing.
symlinkSync('/dev/null', join(dir, 'zero.txt'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await files(folderId, 'zero.txt');
expect(res.statusCode).toBe(400);
});
it('refuses a FIFO without opening it', async () => {
const fifo = join(dir, 'pipe.txt');
execFileSync('mkfifo', [fifo]);
// Hold both ends open from here: if the route ever did open it, the open
// returns at once and the read blocks, which the race below reports as a
// hang instead of leaving a libuv worker parked on a writer that never comes.
const fd = openSync(fifo, 'r+');
try {
const { folderId } = JSON.parse((await grant(dir)).body).data;
const outcome = await Promise.race([
files(folderId, 'pipe.txt').then((res) => res.statusCode),
new Promise<string>((resolve) => setTimeout(() => resolve('hung'), 2000)),
]);
expect(outcome).toBe(400);
} finally {
closeSync(fd);
}
});
it('still serves a symlink whose target is a supported regular file', async () => {
writeFileSync(join(root, 'real.png'), 'real-png');
symlinkSync(join(root, 'real.png'), join(dir, 'alias.png'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await files(folderId, 'alias.png');
expect(res.statusCode).toBe(200);
expect(res.body).toBe('real-png');
});
});
it('serves a file whose name is longer than the 100-character default of a route parameter', async () => {
// The listing shows it (names go up to 255), so the raw/preview/thumbnail
// routes have to be able to take it back.
const longName = `${'n'.repeat(146)}.png`;
expect(longName.length).toBe(150);
touch(join(dir, longName), 'long-png', 1_700_000_300);
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/${longName}/raw`,
});
expect(res.statusCode).toBe(200);
expect(res.body).toBe('long-png');
});
// A Docker case is a LOCAL session for the file routes, but its `~` is the
// container's home: a separate, seeded directory. `~/x.png` printed inside the
// container used to open the HOST's `~/x.png`.
describe('~/ in a Docker case', () => {
beforeEach(() => {
(harness.ctx._session as { docker?: unknown }).docker = {
hostId: 'local',
label: 'local',
engine: 'docker',
image: 'codeman-sandbox',
containerName: 'codeman-case-test',
hostWorkspacePath: root,
containerWorkdir: root,
network: 'bridge',
};
});
it('refuses a ~/ folder, and still lists an absolute one', async () => {
const home = await grant('~/renders');
expect(home.statusCode).toBe(400);
expect(JSON.parse(home.body).error).toMatch(/docker/i);
// A bare `~` is this host's home as much as `~/` is.
expect((await grant('~')).statusCode).toBe(400);
expect((await grant(dir)).statusCode).toBe(200);
});
it('refuses a ~/ attachment, and still registers an absolute one', async () => {
const post = (path: string) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/attachments`, payload: { path } });
const home = await post('~/renders/old.png');
expect(home.statusCode).toBe(400);
expect(JSON.parse(home.body).error).toMatch(/docker/i);
expect((await post(join(dir, 'old.png'))).statusCode).toBe(200);
});
});
it('keeps resolving ~/ for a session that is not in a Docker case', async () => {
// The contrast: only the Docker case is turned away, `~/` itself still works.
const res = await grant('~/this-folder-does-not-exist-codeman-test');
expect(res.statusCode).toBe(404);
});
it('404s an unknown folder id and another session’s grant', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/fld_nope/files/old.png/raw`,
});
expect(res.statusCode).toBe(404);
const foreign = folderGrants.grant('another-session', dir);
const res2 = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${foreign}/files/old.png/raw`,
});
expect(res2.statusCode).toBe(404);
folderGrants.clearSession('another-session');
});
});
describe('folder grid routes — remote (SSH) case', () => {
const REMOTE_DIR = '/srv/remote/case';
const remote: SessionRemote = {
hostId: 'host-1',
label: 'testhost',
host: '192.0.2.10',
username: 'j',
remotePath: REMOTE_DIR,
};
let harness: RouteTestHarness;
let sessionId: string;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
sessionId = harness.ctx._sessionId;
harness.ctx._session.workingDir = REMOTE_DIR;
harness.ctx._session.remote = { ...remote };
mockedListFolder.mockResolvedValue({
dir: '/mnt/raid/styleframes',
entries: [
{ kind: 'file', name: 'a.png', size: 3, mtimeMs: 1000 },
{ kind: 'file', name: 'b.jpg', size: 4, mtimeMs: 2000 },
{ kind: 'directory', name: 'old', size: 0, mtimeMs: 0 },
],
truncated: true,
});
mockedProbePaths.mockResolvedValue([
{ realPath: '/mnt/raid/styleframes/a.png', kind: 'file', size: 3, mtimeMs: 1000 },
{ realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 },
]);
mockedCreateReadStream.mockReturnValue({ stream: Readable.from([Buffer.from('abc')]), close: vi.fn() } as never);
});
afterEach(async () => {
await harness.app.close();
folderGrants.clearSession(sessionId);
vi.clearAllMocks();
});
it('lists the folder on the remote host and serves its files over ssh', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/folders`,
payload: { path: '~/styleframes' },
});
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body).data;
expect(mockedListFolder).toHaveBeenCalledWith(
expect.objectContaining({ host: '192.0.2.10' }),
'~/styleframes',
expect.arrayContaining(['png', 'pdf']),
expect.any(Number)
);
expect(data.path).toBe('/mnt/raid/styleframes');
expect(data.files.map((f: { name: string }) => f.name)).toEqual(['b.jpg', 'a.png']);
expect(data.folders).toEqual(['old']);
expect(data.truncated).toBe(true);
const raw = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${data.folderId}/files/a.png/raw`,
});
expect(raw.statusCode).toBe(200);
expect(raw.body).toBe('abc');
expect(mockedProbePaths).toHaveBeenCalledWith(expect.anything(), ['/mnt/raid/styleframes/a.png', REMOTE_DIR]);
const thumb = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${data.folderId}/files/a.png/thumbnail`,
});
expect(thumb.statusCode).toBe(400);
});
describe('the resolved remote target is held to the attachment gate', () => {
async function rawOf(name: string) {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/folders`,
payload: { path: '~/styleframes' },
});
const { folderId } = JSON.parse(res.body).data;
return harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/${name}/raw`,
});
}
it('refuses a name whose remote symlink resolves to an unsupported extension', async () => {
mockedProbePaths.mockResolvedValue([
{ realPath: '/mnt/raid/secret.bin', kind: 'file', size: 6, mtimeMs: 1000 },
{ realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 },
]);
const res = await rawOf('a.png');
expect(res.statusCode).toBe(400);
expect(mockedCreateReadStream).not.toHaveBeenCalled();
});
it('refuses a name that resolves to something that is not a regular file', async () => {
mockedProbePaths.mockResolvedValue([
{ realPath: '/mnt/raid/styleframes/a.png', kind: 'other', size: 0, mtimeMs: 0 },
{ realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 },
]);
const res = await rawOf('a.png');
expect(res.statusCode).toBe(400);
expect(mockedCreateReadStream).not.toHaveBeenCalled();
});
});
it('answers 400 "Not a folder" for a remote file, so the client falls back to the preview', async () => {
mockedListFolder.mockResolvedValue('not-a-folder');
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/folders`,
payload: { path: '/srv/remote/case/Makefile' },
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/not a folder/i);
});
it('answers 404 for a missing remote folder', async () => {
mockedListFolder.mockResolvedValue(null);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/folders`,
payload: { path: '/nope' },
});
expect(res.statusCode).toBe(404);
});
});
describe('remote folder listing script (real sh)', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-folder-sh-'));
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
function run(dir: string, max = 100, home = root) {
const stdout = execFileSync('sh', ['-c', buildRemoteFolderListCommand(dir, ['png', 'pdf'], max)], {
encoding: 'utf8',
env: { ...process.env, HOME: home },
});
return parseRemoteFolderListOutput(stdout);
}
it('lists supported files with size/mtime, subfolders, odd names; resolves ~ and symlinks', () => {
const dir = join(root, 'frames');
mkdirSync(dir);
touch(join(dir, "it's a | frame.PNG"), '12345', 1_700_000_000);
touch(join(dir, 'doc.pdf'), 'pdf', 1_700_000_100);
touch(join(dir, 'shot.Png'), 'x', 1_700_000_100);
touch(join(dir, 'skip.txt'), 'x', 1_700_000_100);
touch(join(dir, '.hidden.png'), 'x', 1_700_000_100);
mkdirSync(join(dir, 'sub dir'));
symlinkSync(dir, join(root, 'link'));
const listing = run('~/link');
expect(listing?.dir).toBe(dir);
expect(listing?.truncated).toBe(false);
const byName = Object.fromEntries((listing?.entries ?? []).map((e) => [e.name, e]));
// Any case, as the local listing: `shot.Png` too.
expect(Object.keys(byName).sort()).toEqual(['doc.pdf', "it's a | frame.PNG", 'shot.Png', 'sub dir']);
expect(byName["it's a | frame.PNG"]).toMatchObject({ kind: 'file', size: 5, mtimeMs: 1_700_000_000_000 });
expect(byName['sub dir']).toMatchObject({ kind: 'directory' });
});
it('is not thrown off by a login shell whose `cd` prints the directory (Hufflepuff: alias cd=folderprompt)', () => {
// sshd hands the command to the remote user's LOGIN shell. There `cd` is an
// alias that echoes the new directory, so `r=$(cd -P "$d" && pwd -P)` captured
// the path twice and the listing failed ("zsh: no matches found: /x\n/x/*").
// The script has to run under an explicit `sh`, which reads no rc file.
const dir = join(root, 'frames');
mkdirSync(dir);
touch(join(dir, 'a.png'), 'x', 1_700_000_000);
const stdout = execFileSync(
'bash',
['-c', `cd() { builtin cd "$@" && pwd; }; ${buildRemoteFolderListCommand(dir, ['png'], 100)}`],
{ encoding: 'utf8', env: { ...process.env, HOME: root } }
);
const listing = parseRemoteFolderListOutput(stdout);
expect(listing).not.toBeNull();
expect(listing).not.toBe('not-a-folder');
expect((listing as { dir: string }).dir).toBe(dir);
expect((listing as { entries: { name: string }[] }).entries.map((e) => e.name)).toEqual(['a.png']);
});
it('caps entries; tells a missing folder (null) from a file', () => {
for (let i = 0; i < 5; i++) touch(join(root, `f${i}.png`), 'x', 1_700_000_000);
const capped = run(root, 3);
expect(capped?.entries).toHaveLength(3);
expect(capped?.truncated).toBe(true);
expect(run(join(root, 'missing'))).toBeNull();
expect(run(join(root, 'f0.png'))).toBe('not-a-folder');
});
});
describe('validFolderFileName', () => {
it('accepts bare supported names only', () => {
expect(validFolderFileName('a b.png')).toBe('a b.png');
for (const bad of ['', '.', '..', '.hidden.png', 'a/b.png', 'a\\b.png', 'x.xyz', 'a\0.png', 42]) {
expect(validFolderFileName(bad)).toBeNull();
}
});
});
describe('server wiring', () => {
// The route harness builds its own Fastify instance, so a test through it
// cannot notice the real server losing the option. Same style as the other
// wiring guards: read the source, not a comment.
it('gives the real server the same route-parameter limit the folder routes are tested with', () => {
const source = readFileSync(join(__dirname, '../../src/web/server.ts'), 'utf-8');
const instances = source.match(/Fastify\(\{[^}]*\}[^)]*\)/g) ?? [];
expect(instances.length).toBeGreaterThanOrEqual(2);
for (const instance of instances) {
expect(instance).toContain('maxParamLength: MAX_ROUTE_PARAM_LENGTH');
}
});
});
+673
View File
@@ -0,0 +1,673 @@
/**
* @fileoverview GET /api/sessions/:id/git-status: the git snapshot behind the bottom-bar Git
* indicator. Real git for the happy path; an injected runner for the cases that must not run git at
* all (remote and Docker sessions). Port: N/A (app.inject()).
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { defaultDockerWorkspaces, registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
import { getDataDir } from '../../src/config/instance.js';
import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js';
import { clearGitFetchLane, fetchWorkspaceOnce } from '../../src/git-fetch.js';
const ENV = {
...process.env,
// As the production runner (git-workspace-status.ts): git's not-a-repo message is matched in English.
LC_ALL: 'C',
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, env: ENV, stdio: 'ignore' });
let dir: string;
let session: Record<string, unknown>;
async function setup(
opts: {
git?: GitRunner;
authUser?: { username: string; role: 'admin' | 'user' };
/** A fixed list, a reader of the test's own, or `'real'` for the route's default (the data dir's file). */
dockerWorkspaces?: string[] | (() => Promise<string[]>) | 'real';
} = {}
) {
const docker = opts.dockerWorkspaces;
const reader = docker === 'real' ? undefined : typeof docker === 'function' ? docker : async () => docker ?? [];
const h = await createRouteTestHarness((app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, reader), {
authUser: opts.authUser,
});
session = h.ctx._session as unknown as Record<string, unknown>;
session.workingDir = dir;
return h;
}
beforeEach(() => {
clearGitStatusCache();
// The fetch lane is module state: a run left in it by the previous test would be joined by the next one.
clearGitFetchLane();
dir = mkdtempSync(join(tmpdir(), 'git-status-route-'));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('GET /api/sessions/:id/git-status', () => {
it('returns the snapshot of the session workspace in the success envelope', async () => {
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'a.txt'), '1\n');
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', 'base');
writeFileSync(join(dir, 'a.txt'), '2\n');
writeFileSync(join(dir, 'new.txt'), 'n\n');
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.state).toBe('ok');
expect(body.data.repos).toHaveLength(1);
const repo = body.data.repos[0];
expect(repo).toMatchObject({ path: '.', status: { state: 'ok', branch: 'main' } });
expect(repo.status.counts).toMatchObject({ unstaged: 1, untracked: 1, uncommitted: 2 });
expect(repo.status.files.map((f: { path: string }) => f.path).sort()).toEqual(['a.txt', 'new.txt']);
});
it('reports each repository found below a folder that holds several projects', async () => {
for (const name of ['api', 'web']) {
mkdirSync(join(dir, name));
git(join(dir, name), 'init', '-q', '-b', 'main');
}
writeFileSync(join(dir, 'api', 'dirty.txt'), 'x');
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
const data = res.json().data;
expect(data.state).toBe('ok');
expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([
['api', 'api'],
['web', 'web'],
]);
expect(data.repos[0].status.counts.uncommitted).toBe(1);
expect(data.repos[1].status.counts.uncommitted).toBe(0);
});
it('answers not-a-repo for a folder that is not a repository', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.json().data.state).toBe('not-a-repo');
});
it('404s an unknown session', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/git-status' });
expect(res.statusCode).toBe(404);
});
it('reuses a recent result for a poll, and recomputes for ?fresh=1', async () => {
// The workspace is the root of its repository, as real git would say.
const runner = vi.fn<GitRunner>(async (cwd, args) => (args[0] === 'rev-parse' ? `${cwd}\n` : ''));
const { app } = await setup({ git: runner });
const calls = (verb: string) => runner.mock.calls.filter(([, args]) => args[0] === verb).length;
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
const revParses = calls('rev-parse');
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(calls('status')).toBe(1);
expect(calls('rev-parse')).toBe(revParses); // the enclosing repository is reused too
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?fresh=1' });
expect(calls('status')).toBe(2);
});
it('runs git in the session working directory', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(runner).toHaveBeenCalled();
for (const [cwd] of runner.mock.calls) expect(cwd).toBe(dir);
});
it.each([
['remote', { host: 'h', user: 'u' }],
['docker', { container: 'c' }],
])('does not run git for a %s session and says it is unsupported', async (kind, value) => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
session[kind] = value;
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind });
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: another user’s session is not found, and git is not run for it', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(res.statusCode).toBe(404);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: the owner and an admin can read it', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<GitRunner>(async () => '');
for (const authUser of [
{ username: 'alice', role: 'user' as const },
{ username: 'root', role: 'admin' as const },
]) {
clearGitStatusCache();
const { app } = await setup({ git: runner, authUser });
session.owner = 'alice';
expect((await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).statusCode).toBe(
200
);
}
});
});
describe('GET /api/sessions/:id/git-diff', () => {
const url = (q: Record<string, string>) => `/api/sessions/test-session-1/git-diff?${new URLSearchParams(q)}`;
let root: string;
beforeEach(() => {
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'a.txt'), 'one\n');
writeFileSync(join(dir, 'b.txt'), 'bee\n');
git(dir, 'add', '-A');
git(dir, 'commit', '-q', '-m', 'base');
writeFileSync(join(dir, 'a.txt'), 'two\n');
writeFileSync(join(dir, 'b.txt'), 'staged\n');
git(dir, 'add', 'b.txt');
writeFileSync(join(dir, 'new.txt'), 'fresh\n');
root = realpathSync(dir);
});
it.each([
['unstaged', 'a.txt', ['-one', '+two']],
['staged', 'b.txt', ['-bee', '+staged']],
['untracked', 'new.txt', ['+fresh']],
])('returns the %s diff of %s', async (kind, path, lines) => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: url({ repo: root, path, kind }) });
expect(res.statusCode).toBe(200);
const { diff, truncated, binary } = res.json().data;
for (const l of lines) expect(diff).toContain(l);
expect(truncated).toBe(false);
expect(binary).toBe(false);
});
it('answers 404 for a path or repo the status does not list, running no diff', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
for (const q of [
{ repo: root, path: '../../etc/passwd', kind: 'unstaged' },
{ repo: '/etc', path: 'a.txt', kind: 'unstaged' },
{ repo: root, path: 'a.txt', kind: 'staged' },
]) {
const res = await app.inject({ method: 'GET', url: url(q) });
expect(res.statusCode).toBe(404);
}
expect(runner.mock.calls.some(([, args]) => args[0] === 'diff')).toBe(false);
});
it('does not run git for remote and Docker sessions', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner });
session.remote = { host: 'h' };
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
expect(res.statusCode).toBe(400);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: another user’s session is not found', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await setup({ authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: url({ repo: root, path: 'a.txt', kind: 'unstaged' }) });
expect(res.statusCode).toBe(404);
});
it('diffs a staged rename against its old name, and a merge conflict as git’s combined diff (real git)', async () => {
// beforeEach left a.txt/b.txt modified; start this case from a clean tree.
git(dir, 'checkout', '-q', '--', '.');
git(dir, 'reset', '-q', '--hard');
git(dir, 'clean', '-fdq');
writeFileSync(join(dir, 'old.txt'), 'a\nb\nc\nd\ne\nf\ng\n');
git(dir, 'add', 'old.txt');
git(dir, 'commit', '-q', '-m', 'old');
// A real conflict on c.txt.
writeFileSync(join(dir, 'c.txt'), 'base\n');
git(dir, 'add', 'c.txt');
git(dir, 'commit', '-q', '-m', 'c');
git(dir, 'checkout', '-q', '-b', 'other');
writeFileSync(join(dir, 'c.txt'), 'theirs\n');
git(dir, 'commit', '-q', '-am', 'theirs');
git(dir, 'checkout', '-q', 'main');
writeFileSync(join(dir, 'c.txt'), 'ours\n');
git(dir, 'commit', '-q', '-am', 'ours');
try {
git(dir, 'merge', 'other');
} catch {
/* the conflict is the point */
}
// A staged rename, made once the merge has stopped on the conflict.
git(dir, 'mv', 'old.txt', 'new-name.txt');
writeFileSync(join(dir, 'new-name.txt'), 'a\nb\nc\nd\ne\nf\nCHANGED\n');
git(dir, 'add', 'new-name.txt');
const { app } = await setup();
const root = realpathSync(dir);
const rename = await app.inject({ method: 'GET', url: url({ repo: root, path: 'new-name.txt', kind: 'staged' }) });
expect(rename.statusCode).toBe(200);
expect(rename.json().data.diff).toContain('rename from old.txt');
expect(rename.json().data.diff).toContain('+CHANGED');
const conflict = await app.inject({ method: 'GET', url: url({ repo: root, path: 'c.txt', kind: 'conflicted' }) });
expect(conflict.statusCode).toBe(200);
expect(conflict.json().data.diff).toMatch(/<<<<<<<|\+\+<<<<<<</);
});
it('404s a repository inside a Docker case workspace without running git in it', async () => {
const runner = vi.fn<GitRunner>(async () => '');
const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] });
const res = await app.inject({
method: 'GET',
url: url({ repo: realpathSync(dir), path: 'a.txt', kind: 'unstaged' }),
});
expect(res.statusCode).toBe(404);
expect(runner).not.toHaveBeenCalled();
});
});
describe('GET /api/sessions/:id/git-diff in a folder of several repositories', () => {
it('checks the repository against the listed ones and re-reads only that one', async () => {
for (const name of ['api', 'web']) {
const r = join(dir, name);
mkdirSync(r);
git(r, 'init', '-q', '-b', 'main');
writeFileSync(join(r, 'f.txt'), `${name}\n`);
}
const calls: Array<[string, string]> = [];
const runner: GitRunner = async (cwd, args) => {
calls.push([cwd, args[0]]);
return runGit(cwd, args);
};
const { app } = await setup({ git: runner });
const overview = (await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).json().data;
const api = overview.repos.find((r: { name: string }) => r.name === 'api').status.repoRoot;
calls.length = 0;
const q = new URLSearchParams({ repo: api, path: 'f.txt', kind: 'untracked' });
const res = await app.inject({ method: 'GET', url: `/api/sessions/test-session-1/git-diff?${q}` });
expect(res.statusCode).toBe(200);
expect(res.json().data.diff).toContain('+api');
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
});
});
describe('GET /api/sessions/:id/git-status limits', () => {
it('honours maxRepos and timeout (seconds), clamped, and reports the limit it used', async () => {
for (const n of ['a', 'b', 'c']) {
mkdirSync(join(dir, n));
git(join(dir, n), 'init', '-q', '-b', 'main');
}
const seen: Array<number | undefined> = [];
const spy: GitRunner = (cwd, args, opts) => {
seen.push(opts?.timeoutMs);
return execFileSyncGit(cwd, args);
};
const { app } = await setup({ git: spy });
const res = await app.inject({
method: 'GET',
url: '/api/sessions/test-session-1/git-status?maxRepos=2&timeout=7',
});
const data = res.json().data;
expect(data.repos.map((r: { name: string }) => r.name)).toEqual(['a', 'b']);
expect(data).toMatchObject({ reposTruncated: true, repoLimit: 2 });
expect(new Set(seen)).toEqual(new Set([7000]));
clearGitStatusCache();
const wild = await app.inject({
method: 'GET',
url: '/api/sessions/test-session-1/git-status?maxRepos=9999&timeout=1&fresh=1',
});
expect(wild.json().data.repoLimit).toBe(50);
expect(seen.at(-1)).toBe(5000);
clearGitStatusCache();
const junk = await app.inject({
method: 'GET',
url: '/api/sessions/test-session-1/git-status?maxRepos=abc&timeout=xyz&fresh=1',
});
expect(junk.json().data.repoLimit).toBe(12);
expect(seen.at(-1)).toBe(30_000);
clearGitStatusCache();
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?maxRepos=&timeout=&fresh=1' });
expect(seen.at(-1)).toBe(30_000); // empty means "not given", not 0
// A repeated key reaches the route as an array: it means "default", never a 500.
clearGitStatusCache();
const repeated = await app.inject({
method: 'GET',
url: '/api/sessions/test-session-1/git-status?timeout=5&timeout=6&fresh=1',
});
expect(repeated.statusCode).toBe(200);
expect(seen.at(-1)).toBe(30_000);
});
});
describe('POST /api/sessions/:id/git-fetch', () => {
/** Records what each repository was asked to do, and lets real git answer the rest. */
function recordingRunner() {
const calls: Array<[string, string[]]> = [];
const seenTimeouts: Array<number | undefined> = [];
const runner: GitRunner = async (cwd, args, opts) => {
calls.push([cwd, args]);
seenTimeouts.push(opts?.timeoutMs);
return execFileSyncGit(cwd, args);
};
return { calls, seenTimeouts, runner, fetches: () => calls.filter(([, args]) => args[0] === 'fetch') };
}
it('runs one fetch per repository the window would show, in the session working directory', async () => {
for (const name of ['api', 'web']) {
mkdirSync(join(dir, name));
git(join(dir, name), 'init', '-q', '-b', 'main');
}
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.statusCode).toBe(200);
const data = res.json().data;
expect(data.state).toBe('ok');
expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([
['api', 'api'],
['web', 'web'],
]);
expect(data.repos.every((r: { ok: boolean }) => r.ok)).toBe(true);
// A bounded run does not promise which repository starts first, so compare the set.
expect(fetches().sort()).toEqual([
[join(dir, 'api'), ['fetch', '--quiet']],
[join(dir, 'web'), ['fetch', '--quiet']],
]);
// Every repository git was asked to touch sits under the session folder, and under nothing else.
for (const [cwd] of calls) expect(cwd.startsWith(dir)).toBe(true);
});
it('keeps a repository it could not fetch, with its reason, and says which ones it fetched', async () => {
for (const name of ['ok', 'private']) {
mkdirSync(join(dir, name));
git(join(dir, name), 'init', '-q', '-b', 'main');
}
const { fetches, runner } = recordingRunner();
const injected: GitRunner = async (cwd, args, opts) => {
const recorded = runner(cwd, args, opts); // records the call before deciding what git would have said
if (args[0] === 'fetch' && cwd === join(dir, 'private')) {
throw Object.assign(new Error('Command failed'), {
stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
});
}
return recorded;
};
const { app } = await setup({ git: injected });
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
const priv = data.repos.find((r: { name: string }) => r.name === 'private');
expect(priv?.ok).toBe(false);
expect(priv?.failure?.code).toBe('AUTH_REQUIRED');
expect(data.repos.find((r: { name: string }) => r.name === 'ok')?.ok).toBe(true);
// Both repositories are still in the list, so both were attempted: a failure does not cancel the run.
expect(fetches()).toHaveLength(2);
});
it('redacts credentials git may have echoed back before the failure reaches the client', async () => {
git(dir, 'init', '-q', '-b', 'main');
// Only the fetch is faked: the resolver still needs its own `rev-parse` to find the repository at all.
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
throw Object.assign(new Error('Command failed'), {
stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m",
});
}
return execFileSyncGit(cwd, args);
};
const { app } = await setup({ git: runner });
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
expect(data.state).toBe('ok');
expect(data.repos[0].failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m");
});
it.each([
['remote', { host: 'h', user: 'u' }],
['docker', { container: 'c' }],
])('does not run git for a %s session and says it is unsupported', async (kind, value) => {
git(dir, 'init', '-q', '-b', 'main');
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
session[kind] = value;
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind });
expect(fetches()).toEqual([]);
expect(calls).toEqual([]); // the resolver never ran either: the refusal comes before any git
});
it('refuses a repository inside a Docker case workspace before any git runs', async () => {
git(dir, 'init', '-q', '-b', 'main');
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] });
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' });
expect(fetches()).toEqual([]);
expect(calls).toEqual([]);
});
/**
* docker-cases.json is rewritten in place (truncate, then write), so a read can land on an empty or
* half-written file. Read as "no Docker workspaces", a fetch would run git, and with it the repository's
* hooks, inside a folder a container can write to.
*/
it('fetches nothing when the Docker case list cannot be read, and answers a failed run', async () => {
git(dir, 'init', '-q', '-b', 'main');
const { calls, runner } = recordingRunner();
const { app, ctx } = await setup({
git: runner,
dockerWorkspaces: async () => {
throw new Error('docker-cases.json is not a valid JSON array');
},
});
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ state: 'error', repos: [] });
expect(res.json().data.error).toMatch(/Docker case list/);
expect(calls).toEqual([]); // not even the resolver's rev-parse
expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'error', repos: 0, failed: 0 });
expect(ctx.broadcast).toHaveBeenCalled();
});
it('reads the real Docker case list strictly for a fetch, and leaves the status route as it was', async () => {
git(dir, 'init', '-q', '-b', 'main');
mkdirSync(getDataDir(), { recursive: true });
const listPath = join(getDataDir(), 'docker-cases.json');
try {
writeFileSync(listPath, '[{"name":"c","hostWorkspacePath":"/x"');
await expect(defaultDockerWorkspaces()).rejects.toThrow(/not a valid JSON array/);
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner, dockerWorkspaces: 'real' });
const fetched = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(fetched.json().data.state).toBe('error');
expect(calls).toEqual([]);
// The read-only status route still reads an unreadable list as none, as it always has.
const status = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(status.json().data.state).toBe('ok');
expect(fetches()).toEqual([]);
// A readable list that names this folder refuses the fetch as a Docker workspace, so the strict
// reader is the one the route really reads.
writeFileSync(listPath, JSON.stringify([{ name: 'c', hostWorkspacePath: realpathSync(dir) }]));
expect(await defaultDockerWorkspaces()).toEqual([realpathSync(dir)]);
const refused = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(refused.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' });
expect(fetches()).toEqual([]);
} finally {
rmSync(listPath, { force: true });
}
});
it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => {
const { fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
expect(data.state).toBe('not-a-repo');
// The resolver looked once and found nothing, so nothing was fetched, and it never guessed a repository.
expect(fetches()).toEqual([]);
});
it("publishes what the click got as the session's own fetch state, naming the trigger the user asked for", async () => {
git(dir, 'init', '-q', '-b', 'main');
const { runner } = recordingRunner();
const { app, ctx } = await setup({ git: runner });
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.statusCode).toBe(200);
// The response body is one tab's copy of the run; the state is what every other tab open on the same
// folder sees, and a number the user did not ask for has to read differently from one they did.
expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'ok', repos: 1, failed: 0 });
expect(ctx.persistSessionState).toHaveBeenCalled();
expect(ctx.broadcast).toHaveBeenCalled();
});
it('joins a run already going for the same folder instead of starting a second git process in the same clone', async () => {
git(dir, 'init', '-q', '-b', 'main');
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
const fetches: string[] = [];
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
fetches.push(cwd);
running();
await held;
}
return execFileSyncGit(cwd, args);
};
const { app } = await setup({ git: runner });
const first = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
await runStarted; // the run is going, and still going
// A click from another tab open on the same clone. Two fetches there would collide on git's ref lock, so
// this one joins the run already going rather than starting its own.
const second = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
// Assert the join BEFORE releasing the run: the lane entry goes away when the run finishes, so releasing
// first would let the run complete before this request reached the lane, and it would then be entitled to
// start its own fetch: a test that passes for the wrong reason. One tick of the loop is enough for both
// handlers to reach the lane while the run is still held open.
await new Promise((resolve) => setImmediate(resolve));
expect(fetches).toHaveLength(1); // one run, and the second request is waiting on it, not working
releaseRun();
const a = (await first).json().data;
const b = (await second).json().data;
expect(b).toEqual(a); // both tabs get the same run's answer, not a partial one
});
it('publishes the trigger of the run the click joined, because a click that fetched nothing cannot report itself as the fetch', async () => {
git(dir, 'init', '-q', '-b', 'main');
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
const fetches: string[] = [];
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
fetches.push(cwd);
running();
await held;
}
return execFileSyncGit(cwd, args);
};
const { app, ctx } = await setup({ git: runner });
// The interval tick got to this clone first. The click joins it, so the row the other tabs see has to name
// the interval: the user clicked, but nothing this click did was a fetch. The request is left open, not
// awaited: awaiting it here would wait on the run this test is holding, and the test would wait on itself.
const intervalRun = fetchWorkspaceOnce(dir, { git: runner }, 'interval');
await runStarted;
const res = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
await new Promise((resolve) => setImmediate(resolve));
releaseRun();
const run = await intervalRun;
const body = (await res).json().data;
expect(body).toEqual(run.report); // the run it joined, not a partial answer of its own
// Asserted after the run, so this can only pass if the click reached the lane and joined: a click that had
// started its own fetch would make this two, and would have nothing to call the interval.
expect(fetches).toHaveLength(1);
expect(session.gitFetch).toMatchObject({ trigger: 'interval', state: 'ok', repos: 1, failed: 0 });
expect(ctx.broadcast).toHaveBeenCalled();
});
it('404s an unknown session, running no fetch', async () => {
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
expect((await app.inject({ method: 'POST', url: '/api/sessions/nope/git-fetch' })).statusCode).toBe(404);
expect(fetches()).toEqual([]);
expect(calls).toEqual([]);
});
it('multi-user: another user’s session is not found, and no fetch runs', async () => {
process.env.CODEMAN_MULTIUSER = '1';
git(dir, 'init', '-q', '-b', 'main');
const { fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
expect((await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).statusCode).toBe(404);
expect(fetches()).toEqual([]);
});
it('honours maxRepos and timeout (seconds) for a fetch too, clamped, and reports the limit it used', async () => {
for (const n of ['a', 'b', 'c']) {
mkdirSync(join(dir, n));
git(join(dir, n), 'init', '-q', '-b', 'main');
}
const { seenTimeouts, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
const post = async (query: string) => {
const res = await app.inject({ method: 'POST', url: `/api/sessions/test-session-1/git-fetch${query}` });
expect(res.statusCode).toBe(200);
return res.json().data as { repoLimit: number; repos: Array<{ name: string }> };
};
const fetchCount = () => fetches().length;
const ran = async (query: string, expected: number) => {
const before = fetchCount();
const data = await post(query);
expect(fetchCount() - before).toBe(expected);
return data;
};
const limited = await ran('?maxRepos=2&timeout=7', 2);
expect(limited.repos.map((r) => r.name)).toEqual(['a', 'b']);
expect(limited).toMatchObject({ reposTruncated: true, repoLimit: 2 }); // the window has to say three were found
expect(new Set(seenTimeouts)).toEqual(new Set([7000]));
const wild = await ran('?maxRepos=9999&timeout=1', 3);
expect(wild.repoLimit).toBe(50); // clamped to MAX_REPOS_LIMIT, so one request cannot scan the whole disk
expect(seenTimeouts.at(-1)).toBe(5000);
const junk = await ran('?maxRepos=abc&timeout=xyz', 3);
expect(junk.repoLimit).toBe(12);
expect(seenTimeouts.at(-1)).toBe(30_000);
// A repeated key reaches the route as an array: it means "default", never a 500.
await ran('?timeout=5&timeout=6', 3);
expect(seenTimeouts.at(-1)).toBe(30_000);
});
});
function execFileSyncGit(cwd: string, args: string[]): Promise<string> {
return Promise.resolve(execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: ENV, encoding: 'utf8' }));
}
+149
View File
@@ -17,6 +17,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
@@ -98,6 +99,103 @@ describe('hook-event-routes', () => {
);
});
/**
* The prompt-cache keep-warm's own turn (prompt-cache-keepwarm.ts) ends with the same stop
* hook and idle prompt as a real one. While the session marks the turn as its keep-alive,
* both open pre-acknowledged: the broadcast says why, and nothing is pushed.
*/
it('opens the stop and idle prompt of a keep-alive turn pre-acknowledged and pushes nothing', async () => {
harness.ctx._session.keepAliveTurnActive = true;
for (const event of ['stop', 'idle_prompt']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId: harness.ctx._sessionId, data: null },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
`hook:${event}`,
expect.objectContaining({ sessionId: harness.ctx._sessionId, acknowledgedReason: 'cache keep-alive' })
);
}
expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled();
// The inbox item is what a reload seeds tab alerts from, so it must open acknowledged too.
const item = approvalInbox.getForSession(harness.ctx._sessionId);
approvalInbox.resolveForSession(harness.ctx._sessionId, 'dismissed');
expect(item?.acknowledgedReason).toBe('cache keep-alive');
// acknowledgedAt is what approvals-ui.js reads to skip the alert on a reload.
expect(item?.acknowledgedAt).toBe(item?.createdAt);
});
/**
* Only a human spends an idle alert (CLAUDE.md, Approvals Inbox). A keep-alive turn that
* starts while an idle item is pending must leave that item exactly as it found it: its stop
* does not resolve it, and its idle prompt does not replace it, so an unseen alert stays
* unseen (and pushes nothing a second time) and one a person looked at keeps saying so.
*/
it.each([
{ seen: false, label: 'an unseen' },
{ seen: true, label: 'a seen' },
])('leaves $label idle item pending through a keep-alive turn', async ({ seen }) => {
const sessionId = harness.ctx._sessionId;
const hook = (event: string) =>
harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } });
await hook('idle_prompt');
const before = approvalInbox.getForSession(sessionId);
expect(before?.acknowledgedAt).toBeUndefined();
if (seen) approvalInbox.acknowledge(sessionId);
const acknowledgedAt = before?.acknowledgedAt;
harness.ctx.sendPushNotifications.mockClear();
harness.ctx.broadcast.mockClear();
harness.ctx._session.keepAliveTurnActive = true;
await hook('stop');
await hook('idle_prompt');
const after = approvalInbox.getForSession(sessionId);
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(after?.id).toBe(before?.id);
expect(after?.acknowledgedAt).toBe(acknowledgedAt);
expect(after?.acknowledgedReason).toBeUndefined();
expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled();
// The page's _onHookStop and _onHookIdlePrompt return on the reason, leaving the tab alert.
for (const event of ['stop', 'idle_prompt']) {
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
`hook:${event}`,
expect.objectContaining({ acknowledgedReason: 'cache keep-alive' })
);
}
});
it("a keep-alive turn's idle prompt ends the keep-alive window, its stop does not", async () => {
const sessionId = harness.ctx._sessionId;
const hook = (event: string) =>
harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } });
harness.ctx._session.keepAliveTurnActive = true;
await hook('stop');
expect(harness.ctx._session.keepAliveTurnActive).toBe(true);
await hook('idle_prompt');
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(harness.ctx._session.keepAliveTurnActive).toBe(false);
});
it("a keep-alive turn's stop still resolves a pending permission dialog", async () => {
const sessionId = harness.ctx._sessionId;
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'permission_prompt', sessionId, data: null },
});
expect(approvalInbox.getForSession(sessionId)?.kind).toBe('permission');
harness.ctx._session.keepAliveTurnActive = true;
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'stop', sessionId, data: null },
});
expect(approvalInbox.getForSession(sessionId)).toBeUndefined();
});
it('sends push notifications for hook events', async () => {
const res = await harness.app.inject({
method: 'POST',
@@ -144,6 +242,27 @@ describe('hook-event-routes', () => {
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
});
/**
* The hook's prompt text is what tells a person typing in an attached tmux client apart
* from the prompt-cache keep-alive (Session.markPromptSubmitted ends the keep-alive window
* for anything but the nudge), so the route must hand it over, and nothing else.
*/
it("hands the prompt_submitted hook's prompt text to the session", async () => {
const session = harness.ctx._session;
for (const data of [
{ session_id: 'conv-1', prompt: 'fix the login bug' },
{ session_id: 'conv-1', prompt: 42 },
{ session_id: 'conv-1' },
]) {
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'prompt_submitted', sessionId: harness.ctx._sessionId, data },
});
}
expect(session.submittedPrompts).toEqual(['fix the login bug', undefined, undefined]);
});
it('records a /clear successor in the chain and persists it, without duplicating a repeat', async () => {
const session = harness.ctx._session;
const submit = async (conversationId: string) =>
@@ -308,6 +427,36 @@ describe('hook-event-routes', () => {
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
/**
* The run summary files every `stop` as a warning, and the Away digest lists warnings under
* Needs Attention, so a keep-alive turn's hooks would add a row there (and to the session's
* Summary timeline) for every nudge. They are Codeman's own turn, not the agent's.
*/
it("keeps a keep-alive turn's stop and idle prompt out of the run summary", async () => {
const sessionId = harness.ctx._sessionId;
const mockTracker = { recordHookEvent: vi.fn() };
harness.ctx.runSummaryTrackers.set(sessionId, mockTracker as never);
harness.ctx._session.keepAliveTurnActive = true;
for (const event of ['stop', 'idle_prompt']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId, data: null },
});
expect(res.statusCode).toBe(200);
}
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(mockTracker.recordHookEvent).not.toHaveBeenCalled();
// The next real turn is recorded as before.
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'stop', sessionId, data: null },
});
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
it('starts transcript watcher when transcript_path is provided', async () => {
const res = await harness.app.inject({
method: 'POST',
+340
View File
@@ -0,0 +1,340 @@
/**
* @fileoverview Tests for html-view-routes: rendering a local HTML file as a page.
*
* Real files in a temp dir (no fs mocks): the point of the route is its path
* policy, and a mocked realpath would make every containment check pass.
* Port: N/A (app.inject doesn't open ports)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import Fastify from 'fastify';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { HTML_VIEW_CSP, isBroadHtmlViewRoot, registerHtmlViewRoutes } from '../../src/web/routes/html-view-routes.js';
import { registerSecurityHeaders } from '../../src/web/middleware/auth.js';
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../src/html-view-capabilities.js';
describe('html-view routes', () => {
let harness: RouteTestHarness;
let sid: string;
let root: string;
let workspace: string;
beforeEach(async () => {
root = mkdtempSync(join(tmpdir(), 'html-view-'));
workspace = join(root, 'ws');
mkdirSync(join(workspace, 'assets'), { recursive: true });
writeFileSync(join(workspace, 'index.html'), '<h1>hi</h1>');
writeFileSync(join(workspace, 'assets', 'app.css'), 'h1{}');
writeFileSync(join(workspace, 'data.json'), '{}');
writeFileSync(join(workspace, '.env'), 'SECRET=1');
writeFileSync(join(workspace, 'notes.sh'), 'echo');
writeFileSync(join(root, 'outside.txt'), 'outside');
symlinkSync(join(root, 'outside.txt'), join(workspace, 'escape.txt'));
harness = await createRouteTestHarness(registerHtmlViewRoutes);
sid = harness.ctx._sessionId as string;
(harness.ctx.sessions.get(sid) as { workingDir: string }).workingDir = workspace;
});
afterEach(async () => {
await harness.app.close();
htmlViewCapabilities.revokeOwner(undefined);
rmSync(root, { recursive: true, force: true });
});
async function open(path: string) {
return harness.app.inject({ method: 'POST', url: `/api/sessions/${sid}/html-view`, payload: { path } });
}
async function openUrl(): Promise<string> {
const res = await open(join(workspace, 'index.html'));
expect(res.statusCode).toBe(200);
return JSON.parse(res.body).data.url as string;
}
it('mints a capability URL for an HTML file in the workspace', async () => {
const url = await openUrl();
expect(url).toMatch(/^\/html-view\/[A-Za-z0-9_-]{16,}\/index\.html$/);
expect(capabilityFromHtmlViewPath(url)).not.toBeNull();
});
it('accepts a workspace-relative path', async () => {
const res = await open('index.html');
expect(res.statusCode).toBe(200);
});
it('expands a ~/ path against this host home, as the attachment route does', async () => {
// test/setup.ts points HOME at a temp dir, so this never touches the real home.
// The terminal passes a `~/` link through unchanged; only the server knows the home.
const site = join(homedir(), 'html-view-tilde', 'site');
mkdirSync(site, { recursive: true });
writeFileSync(join(site, 'page.html'), '<h1>home</h1>');
try {
const res = await open('~/html-view-tilde/site/page.html');
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.url).toMatch(/^\/html-view\/[A-Za-z0-9_-]{16,}\/page\.html$/);
// A Docker case's `~` is the container's home, never this host's.
(harness.ctx.sessions.get(sid) as { docker?: unknown }).docker = { caseName: 'c' };
expect((await open('~/html-view-tilde/site/page.html')).statusCode).toBe(400);
} finally {
rmSync(join(homedir(), 'html-view-tilde'), { recursive: true, force: true });
}
});
it('validates the body', async () => {
const post = (payload: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${sid}/html-view`, payload: payload as object });
for (const payload of [{}, { path: '' }, { path: 42 }, { path: 'a\nb.html' }, { path: 'index.html', extra: 1 }]) {
const res = await post(payload);
expect(res.statusCode, JSON.stringify(payload)).toBe(400);
expect(JSON.parse(res.body).success).toBe(false);
}
expect(JSON.parse((await post({})).body).error).toBe('Missing path parameter');
});
it('refuses non-HTML files and missing files', async () => {
expect((await open(join(workspace, 'data.json'))).statusCode).toBe(400);
expect((await open(join(workspace, 'missing.html'))).statusCode).toBe(404);
// An .html name that links to something else is judged by its target.
symlinkSync(join(workspace, 'data.json'), join(workspace, 'data.html'));
expect((await open(join(workspace, 'data.html'))).statusCode).toBe(400);
});
it('serves the page sandboxed, never same-origin', async () => {
const url = await openUrl();
const res = await harness.app.inject({ method: 'GET', url });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
const csp = String(res.headers['content-security-policy']);
expect(csp).toMatch(/^sandbox /);
expect(csp).not.toContain('allow-same-origin');
expect(res.body).toBe('<h1>hi</h1>');
});
it("serves exactly the sandbox CSP with open network, replacing Codeman's own policy (owner decision)", async () => {
// Owner decision 2026-10-13 (#547): a rendered page reaches the network like a
// file opened from disk, so the policy is the `sandbox` directive alone. Built
// with Codeman's own security headers registered first, as in production, to
// prove their `default-src 'self'` policy does not survive onto the page.
const app = Fastify({ logger: false });
registerSecurityHeaders(app, false);
registerHtmlViewRoutes(app, harness.ctx as unknown as Parameters<typeof registerHtmlViewRoutes>[1]);
await app.ready();
try {
const cap = htmlViewCapabilities.mint(realpathSync(workspace));
for (const file of ['index.html', 'data.json']) {
const res = await app.inject({ method: 'GET', url: `/html-view/${cap}/${file}` });
expect(res.statusCode, file).toBe(200);
const csp = res.headers['content-security-policy'];
// One header, this exact value.
expect(csp, file).toBe(HTML_VIEW_CSP);
expect(HTML_VIEW_CSP).toBe('sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads');
const directives = String(csp)
.split(';')
.map((d) => d.trim().split(/\s+/)[0])
.filter(Boolean);
// Sandboxed, never same-origin...
expect(directives).toEqual(['sandbox']);
expect(String(csp)).not.toContain('allow-same-origin');
// ...and no fetch directive anywhere: the network is not restricted.
expect(String(csp)).not.toMatch(/-src\b/);
}
} finally {
await app.close();
}
});
it('answers Range requests, so a page can seek its own media', async () => {
writeFileSync(join(workspace, 'clip.mp4'), '0123456789');
const base = (await openUrl()).replace(/index\.html$/, '');
const full = await harness.app.inject({ method: 'GET', url: `${base}clip.mp4` });
expect(full.statusCode).toBe(200);
expect(full.headers['accept-ranges']).toBe('bytes');
expect(full.headers['content-length']).toBe('10');
const part = await harness.app.inject({ method: 'GET', url: `${base}clip.mp4`, headers: { range: 'bytes=2-5' } });
expect(part.statusCode).toBe(206);
expect(part.headers['content-range']).toBe('bytes 2-5/10');
expect(part.body).toBe('2345');
// Every header the route sets survives the hijacked body sender.
expect(part.headers['content-type']).toBe('video/mp4');
expect(String(part.headers['content-security-policy'])).toMatch(/^sandbox /);
expect(part.headers['access-control-allow-origin']).toBe('*');
expect(part.headers['cache-control']).toBe('no-store');
const beyond = await harness.app.inject({ method: 'GET', url: `${base}clip.mp4`, headers: { range: 'bytes=50-' } });
expect(beyond.statusCode).toBe(416);
});
it('serves sibling assets, including subdirectories', async () => {
const base = (await openUrl()).replace(/index\.html$/, '');
expect((await harness.app.inject({ method: 'GET', url: `${base}assets/app.css` })).statusCode).toBe(200);
expect((await harness.app.inject({ method: 'GET', url: `${base}data.json` })).statusCode).toBe(200);
});
it('never serves dotfiles, non-asset types, escapes or symlinks out of the directory', async () => {
const base = (await openUrl()).replace(/index\.html$/, '');
for (const p of ['.env', 'assets/../.env', '%2e%2e/outside.txt', '..%2foutside.txt', 'notes.sh', 'escape.txt']) {
const res = await harness.app.inject({ method: 'GET', url: `${base}${p}` });
expect(res.statusCode, p).toBe(404);
}
});
it('applies the dot and extension rules to where a symlink lands, not only to its name', async () => {
mkdirSync(join(workspace, '.git'));
writeFileSync(join(workspace, '.git', 'config'), '[remote "origin"]');
symlinkSync(join(workspace, '.git', 'config'), join(workspace, 'cfg.json'));
symlinkSync(join(workspace, 'notes.sh'), join(workspace, 'notes.css'));
// A link that stays inside the folder and lands on an asset still works.
symlinkSync(join(workspace, 'data.json'), join(workspace, 'latest.json'));
const base = (await openUrl()).replace(/index\.html$/, '');
expect((await harness.app.inject({ method: 'GET', url: `${base}cfg.json` })).statusCode).toBe(404);
expect((await harness.app.inject({ method: 'GET', url: `${base}notes.css` })).statusCode).toBe(404);
const ok = await harness.app.inject({ method: 'GET', url: `${base}latest.json` });
expect(ok.statusCode).toBe(200);
expect(ok.headers['content-type']).toContain('application/json');
});
it('refuses an unknown or revoked capability', async () => {
const url = await openUrl();
expect(
(await harness.app.inject({ method: 'GET', url: '/html-view/AAAAAAAAAAAAAAAAAAAAAAAA/index.html' })).statusCode
).toBe(404);
htmlViewCapabilities.revokeOwner(undefined);
expect((await harness.app.inject({ method: 'GET', url })).statusCode).toBe(404);
});
it('reuses one capability per directory', async () => {
expect(await openUrl()).toBe(await openUrl());
});
describe('outside the workspace', () => {
let outsideDir: string;
const saved = {
confine: process.env.CODEMAN_ATTACHMENT_CONFINE,
blocked: process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS,
};
beforeEach(() => {
outsideDir = join(root, 'reports');
mkdirSync(outsideDir);
writeFileSync(join(outsideDir, 'report.html'), '<p>report</p>');
});
afterEach(() => {
for (const [key, value] of [
['CODEMAN_ATTACHMENT_CONFINE', saved.confine],
['CODEMAN_ATTACHMENT_BLOCKED_PATHS', saved.blocked],
] as const) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it('mints for a page under the attachment guard', async () => {
expect((await open(join(outsideDir, 'report.html'))).statusCode).toBe(200);
});
it('refuses a page in a blocked tree', async () => {
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = outsideDir;
expect((await open(join(outsideDir, 'report.html'))).statusCode).toBe(403);
});
it('stops serving an out-of-workspace page once confinement is switched on', async () => {
const res = await open(join(outsideDir, 'report.html'));
expect(res.statusCode).toBe(200);
const url = JSON.parse(res.body).data.url as string;
expect((await harness.app.inject({ method: 'GET', url })).statusCode).toBe(200);
const inside = await openUrl();
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
expect((await harness.app.inject({ method: 'GET', url })).statusCode).toBe(404);
// A page minted from inside the workspace is not affected.
expect((await harness.app.inject({ method: 'GET', url: inside })).statusCode).toBe(200);
});
it('never reuses an in-workspace capability for the same folder minted from outside', () => {
const inside = htmlViewCapabilities.mint(outsideDir, undefined, false);
const outside = htmlViewCapabilities.mint(outsideDir, undefined, true);
expect(outside).not.toBe(inside);
expect(htmlViewCapabilities.resolve(outside)?.outsideWorkspace).toBe(true);
});
it('refuses every out-of-workspace page when attachments are confined to the workspace', async () => {
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
expect((await open(join(outsideDir, 'report.html'))).statusCode).toBe(403);
// The workspace itself is still fine.
expect((await open(join(workspace, 'index.html'))).statusCode).toBe(200);
});
});
it('refuses an HTML file inside a hidden directory, in or out of the workspace', async () => {
mkdirSync(join(workspace, '.cache'));
writeFileSync(join(workspace, '.cache', 'r.html'), '<p>r</p>');
writeFileSync(join(workspace, '.cache', 'token.json'), '{}');
expect((await open(join(workspace, '.cache', 'r.html'))).statusCode).toBe(403);
mkdirSync(join(root, '.hidden'));
writeFileSync(join(root, '.hidden', 'r.html'), '<p>r</p>');
expect((await open(join(root, '.hidden', 'r.html'))).statusCode).toBe(403);
});
it('renders a page in a workspace that itself lives under a hidden folder', async () => {
const hiddenWorkspace = join(root, '.local', 'src', 'app');
mkdirSync(join(hiddenWorkspace, 'report'), { recursive: true });
writeFileSync(join(hiddenWorkspace, 'report', 'index.html'), '<p>r</p>');
mkdirSync(join(hiddenWorkspace, '.cache'));
writeFileSync(join(hiddenWorkspace, '.cache', 'r.html'), '<p>r</p>');
(harness.ctx.sessions.get(sid) as { workingDir: string }).workingDir = hiddenWorkspace;
const res = await open(join(hiddenWorkspace, 'report', 'index.html'));
expect(res.statusCode).toBe(200);
expect((await harness.app.inject({ method: 'GET', url: JSON.parse(res.body).data.url })).statusCode).toBe(200);
// A hidden directory BELOW the workspace root is still refused.
expect((await open(join(hiddenWorkspace, '.cache', 'r.html'))).statusCode).toBe(403);
});
it('treats the filesystem root, home and the temp dir as too broad to serve', () => {
expect(isBroadHtmlViewRoot('/')).toBe(true);
expect(isBroadHtmlViewRoot(homedir())).toBe(true);
expect(isBroadHtmlViewRoot(tmpdir())).toBe(true);
expect(isBroadHtmlViewRoot(workspace)).toBe(false);
});
it('treats /tmp, /var/tmp and every directory above a broad root as broad, whatever TMPDIR says', async () => {
// macOS sets TMPDIR to a per-user /var/folders dir in every terminal, which
// os.tmpdir() returns, so the literal shared temp dirs must not depend on it.
const savedTmpdir = process.env.TMPDIR;
process.env.TMPDIR = join(root, 'private-tmp');
mkdirSync(process.env.TMPDIR);
const page = join('/tmp', `html-view-broad-literal-${process.pid}.html`);
writeFileSync(page, '<p>broad</p>');
try {
expect(tmpdir()).toBe(process.env.TMPDIR);
expect(isBroadHtmlViewRoot('/tmp')).toBe(true);
expect(isBroadHtmlViewRoot('/var/tmp')).toBe(true);
expect((await open(page)).statusCode).toBe(403);
} finally {
rmSync(page, { force: true });
if (savedTmpdir === undefined) delete process.env.TMPDIR;
else process.env.TMPDIR = savedTmpdir;
}
// Above the home directory (`/home`, `/Users`): serving it would serve home too.
expect(isBroadHtmlViewRoot(join(homedir(), '..'))).toBe(true);
// A drive's mount point and the mount parent itself.
expect(isBroadHtmlViewRoot('/mnt')).toBe(true);
expect(isBroadHtmlViewRoot('/mnt/d')).toBe(true);
expect(isBroadHtmlViewRoot('/mnt/d/projects/report')).toBe(false);
});
it('refuses a page sitting directly in the temp dir', async () => {
const page = join(tmpdir(), `html-view-broad-${process.pid}.html`);
writeFileSync(page, '<p>broad</p>');
try {
expect((await open(page)).statusCode).toBe(403);
} finally {
rmSync(page, { force: true });
}
});
});
+281
View File
@@ -0,0 +1,281 @@
/**
* @fileoverview Agent inbox routes (src/web/routes/inbox-routes.ts) via app.inject().
*
* The caller is whoever `X-Codeman-Parent-Session` resolves to (live session, same
* owner as the receiver). These tests pin what that decides: who a post is from,
* whether a read marks seen or is a peek, and who may ack. Plus the wait rule (parks
* while nothing is UNSEEN), the full-inbox 409 and the owner-scoped summary.
*
* The routes use the process-wide `agentInbox` and `sessionWaits`; every test resets
* the inbox and cancels its waiters.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { registerInboxRoutes } from '../../src/web/routes/inbox-routes.js';
import { agentInbox, MAX_MESSAGES_PER_INBOX } from '../../src/web/agent-inbox.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { httpStatusForErrorCode, type ApiErrorCode, type AuthUser } from '../../src/types.js';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
const RX = 'aaaaaaaa-1111-4111-8111-111111111111';
const TX = 'bbbbbbbb-2222-4222-8222-222222222222';
const OTHER = 'cccccccc-3333-4333-8333-333333333333';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
let currentUser: AuthUser | undefined;
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: RX });
for (const id of [TX, OTHER]) ctx.sessions.set(id, createMockSession(id) as never);
app.decorateRequest('authUser', undefined);
app.addHook('onRequest', async (req) => {
req.authUser = currentUser;
});
registerInboxRoutes(app, ctx as never);
// server.ts's envelope hook: an error envelope carries its 4xx.
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
const p = payload as { success?: unknown; errorCode?: unknown } | null;
if (p && p.success === false && reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
done(null, payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
const as = (caller?: string) => (caller ? { 'x-codeman-parent-session': caller } : {});
describe('agent inbox routes', () => {
let h: Harness;
const post = (to: string, text: string, caller?: string) =>
h.app.inject({ method: 'POST', url: `/api/sessions/${to}/inbox`, payload: { text }, headers: as(caller) });
const read = (of: string, caller?: string, query = '') =>
h.app.inject({ method: 'GET', url: `/api/sessions/${of}/inbox${query}`, headers: as(caller) });
const ack = (of: string, ids: string[], caller?: string) =>
h.app.inject({ method: 'POST', url: `/api/sessions/${of}/inbox/ack`, payload: { ids }, headers: as(caller) });
const data = (res: { body: string }) => JSON.parse(res.body).data;
beforeEach(async () => {
agentInbox.resetForTests();
currentUser = undefined;
delete process.env.CODEMAN_MULTIUSER;
h = await createHarness();
});
afterEach(async () => {
for (const id of [RX, TX, OTHER]) sessionWaits.cancelAll(id);
await h.app.close();
agentInbox.resetForTests();
delete process.env.CODEMAN_MULTIUSER;
});
describe('from', () => {
it('is the resolved caller session (exact id or 8-char prefix)', async () => {
expect(data(await post(RX, 'hi', TX)).message.from).toBe(TX);
expect(data(await post(RX, 'hi', TX.slice(0, 8))).message.from).toBe(TX);
});
it('is null (the user) without a header, for an unknown id, or for a short prefix', async () => {
expect(data(await post(RX, 'a')).message.from).toBeNull();
expect(data(await post(RX, 'b', 'dddddddd-0000-4000-8000-000000000000')).message.from).toBeNull();
expect(data(await post(RX, 'c', TX.slice(0, 7))).message.from).toBeNull();
});
it('a body field cannot set it: the schema rejects unknown keys', async () => {
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${RX}/inbox`,
payload: { text: 'x', from: TX },
});
expect(res.statusCode).toBe(400);
});
it('multi-user, non-admin: a session the caller cannot see never resolves as the sender', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'alice', role: 'user' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'alice';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
(h.ctx.sessions.get(OTHER) as unknown as { owner: string }).owner = 'alice';
expect(data(await post(RX, 'x', TX)).message.from).toBeNull();
expect(data(await post(RX, 'y', OTHER)).message.from).toBe(OTHER);
// Nor can alice read as bob's session or ack for it: bob's inbox is a 404 to her.
expect((await read(TX, TX)).statusCode).toBe(404);
expect((await ack(TX, ['m'], TX)).statusCode).toBe(404);
});
it('multi-user, admin: may act as any session of the receiver’s owner (admins see everything)', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'root', role: 'admin' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'bob';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
expect(data(await post(RX, 'x', TX)).message.from).toBe(TX);
expect(data(await read(RX, RX)).receiver).toBe(true);
});
it('multi-user: a session of another owner never resolves as the sender', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'alice', role: 'admin' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'alice';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
(h.ctx.sessions.get(OTHER) as unknown as { owner: string }).owner = 'alice';
expect(data(await post(RX, 'x', TX)).message.from).toBeNull();
expect(data(await post(RX, 'y', OTHER)).message.from).toBe(OTHER);
});
});
describe('post', () => {
it('keeps multi-line text as data', async () => {
await post(RX, 'one\ntwo\n', TX);
expect(data(await read(RX, RX)).messages[0].text).toBe('one\ntwo\n');
});
it('a full inbox is 409 CONFLICT with a clear message, and the oldest stays', async () => {
const first = data(await post(RX, 'first')).message.id;
for (let i = 1; i < MAX_MESSAGES_PER_INBOX; i++) agentInbox.post(RX, null, String(i));
const res = await post(RX, 'one too many');
expect(res.statusCode).toBe(409);
expect(JSON.parse(res.body)).toMatchObject({ errorCode: 'CONFLICT' });
expect(JSON.parse(res.body).error).toMatch(/full .*Nothing was stored/);
expect(data(await read(RX)).messages[0].id).toBe(first);
});
it('a message over the byte cap is 400 with the store’s hint, multi-byte or not', async () => {
for (const text of ['é'.repeat(8193), 'x'.repeat(16_385)]) {
const res = await post(RX, text);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/Write it to a file in the workspace and post the path/);
}
});
it('404 for an unknown session', async () => {
expect((await post('nope', 'x')).statusCode).toBe(404);
});
});
describe('read', () => {
it('only the receiver marks seen; a coordinator read is a peek', async () => {
await post(RX, 'task', TX);
const peek = data(await read(RX, TX));
expect(peek).toMatchObject({ receiver: false, pending: 1, unseen: 1 });
expect(data(await read(RX)).unseen).toBe(1);
const own = data(await read(RX, RX));
expect(own).toMatchObject({ receiver: true, pending: 1, unseen: 0 });
expect(own.messages[0].seen).toBe(false);
expect(data(await read(RX, RX)).messages[0].seen).toBe(true);
});
it('?wait= parks while nothing is unseen, even with a seen-but-unacked message', async () => {
await post(RX, 'already seen', TX);
await read(RX, RX);
const parked = read(RX, RX, '?wait=5000');
await new Promise((r) => setTimeout(r, 30));
expect(sessionWaits.inboxWaiterCount(RX, true)).toBe(1);
await post(RX, 'new', TX);
const res = data(await parked);
expect(res.wait).toMatchObject({ posted: true, timedOut: false });
expect(res.messages.map((m: { text: string; seen: boolean }) => [m.text, m.seen])).toEqual([
['already seen', true],
['new', false],
]);
});
it('?wait= answers at once when something is unseen', async () => {
await post(RX, 'm', TX);
const res = data(await read(RX, RX, '?wait=60000'));
expect(res.wait).toMatchObject({ immediate: true, waitedMs: 0, timeoutMs: 60000 });
});
it('?wait= runs out as a 200 with timedOut, clamped to the wait floor', async () => {
const res = await read(RX, RX, '?wait=1');
expect(res.statusCode).toBe(200);
expect(data(res).wait).toMatchObject({ timedOut: true, timeoutMs: 1000 });
expect(res.headers['cache-control']).toBe('no-store');
});
it('?wait= rejects junk', async () => {
expect((await read(RX, RX, '?wait=-5')).statusCode).toBe(400);
expect((await read(RX, RX, '?wait=abc')).statusCode).toBe(400);
});
it('a parked read on a session that goes away answers ended, with an empty inbox', async () => {
await post(RX, 'x');
await read(RX, RX);
const parked = read(RX, RX, '?wait=5000');
await new Promise((r) => setTimeout(r, 30));
// What server.ts does on delete: drop the inbox, then cancel the waiters.
agentInbox.drop(RX);
sessionWaits.cancelAll(RX);
const res = data(await parked);
expect(res.wait).toMatchObject({ ended: true });
expect(res.messages).toEqual([]);
});
});
describe('ack', () => {
it('removes by id for the receiver, idempotently', async () => {
const id = data(await post(RX, 'm', TX)).message.id;
expect(data(await ack(RX, [id], RX))).toMatchObject({ removed: 1, pending: 0 });
const again = await ack(RX, [id, 'never-existed'], RX);
expect(again.statusCode).toBe(200);
expect(data(again)).toMatchObject({ removed: 0, pending: 0 });
});
it('is 403 for anyone but the receiver, and removes nothing', async () => {
const id = data(await post(RX, 'm', TX)).message.id;
for (const caller of [TX, undefined]) {
const res = await ack(RX, [id], caller);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
}
expect(agentInbox.pendingCount(RX)).toBe(1);
});
it('rejects an empty id list', async () => {
expect((await ack(RX, [], RX)).statusCode).toBe(400);
});
});
describe('summary', () => {
it('reports counts and parked per session, never text', async () => {
await post(RX, 'secret', TX);
await post(TX, 'other secret', RX);
await read(TX, TX);
const parked = read(OTHER, OTHER, '?wait=5000');
// A coordinator peeking with ?wait= is not the receiver: not "parked".
const peeking = read(RX, TX, '?wait=5000');
await new Promise((r) => setTimeout(r, 30));
const res = await h.app.inject({ method: 'GET', url: '/api/agent-inbox/summary' });
expect(res.body).not.toContain('secret');
expect(data(res).sessions).toEqual({
[RX]: { pending: 1, unseen: 1, oldestUnseenAt: expect.any(Number), parked: false },
[TX]: { pending: 1, unseen: 0, oldestUnseenAt: null, parked: false },
[OTHER]: { pending: 0, unseen: 0, oldestUnseenAt: null, parked: true },
});
sessionWaits.cancelAll(OTHER);
sessionWaits.cancelAll(RX);
await Promise.all([parked, peeking]);
});
it('multi-user: lists only sessions the caller can see', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'alice', role: 'user' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'alice';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
agentInbox.post(RX, null, 'mine');
agentInbox.post(TX, null, 'bob’s');
const res = await h.app.inject({ method: 'GET', url: '/api/agent-inbox/summary' });
expect(Object.keys(data(res).sessions)).toEqual([RX]);
});
});
});
+274
View File
@@ -0,0 +1,274 @@
/**
* @fileoverview Route tests for /api/mcp-sync. The feature is OPT-IN (`mcpSyncEnabled`, default
* OFF): both verbs answer 403 until it is on. Only CLIs that are ENABLED in the registry take
* part, and only if installed or already configured; enabled agent CLIs with no known MCP
* config are reported as unsupported.
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, so each test wipes the config files it
* creates. Port: N/A (app.inject()).
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerMcpSyncRoutes } from '../../src/web/routes/mcp-sync-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
import { MCP_SYNC_ONLY_TOOLS } from '../../src/mcp-sync-targets.js';
// Which CLIs are installed on the machine running the tests must not decide the outcome: nothing
// is installed, so only a CLI whose config file exists takes part.
// Lets a test hold the module's real apply lock open: the first apply parks inside the mock
// (after taking the lock) until released, so a second POST deterministically overlaps it.
const hold = vi.hoisted(() => ({ release: null as null | (() => void), entered: null as null | (() => void) }));
vi.mock('../../src/mcp-sync.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/mcp-sync.js')>();
return {
...actual,
syncMcpServers: async (...args: Parameters<typeof actual.syncMcpServers>) => {
if (!hold.release || !args[1].apply) return actual.syncMcpServers(...args);
const parked = new Promise<void>((resolve) => (hold.release = resolve));
hold.entered?.();
const pending = actual.syncMcpServers(...args); // takes the lock synchronously
await parked;
return pending;
},
};
});
// Nothing is installed unless a test adds the id here.
const installed = vi.hoisted(() => new Set<string>());
vi.mock('../../src/utils/cli-installed-probes.js', () => ({
probeStockCliAvailability: async () => ({}),
isCliEntryInstalled: (e: { id: string }) => installed.has(e.id),
}));
// The sync-only tools (Copilot) probe the real PATH for their binary; route that through the same set.
vi.mock('../../src/mcp-sync-targets.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/mcp-sync-targets.js')>();
return {
...actual,
mcpSyncOnlyTargets: (taken: ReadonlySet<string>) =>
actual.mcpSyncOnlyTargets(taken, (binary) => installed.has(binary)),
};
});
// The route follows each CLI's relocation env var (CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME,
// ...) from process.env, so the runner's own values (CI images set XDG_CONFIG_HOME) must never
// aim a test write outside the temp HOME. Cleared before every test, restored after the file.
const RELOCATION_VARS = [
...STOCK_CLIS.flatMap((e) => {
const envVar = e.capabilities.mcpConfig?.relocation?.envVar;
return envVar ? [envVar] : [];
}),
...MCP_SYNC_ONLY_TOOLS.flatMap((t) => (t.relocation ? [t.relocation.envVar] : [])),
];
const savedEnv = Object.fromEntries(RELOCATION_VARS.map((k) => [k, process.env[k]]));
afterAll(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
const home = () => homedir();
const write = (rel: string, text: string) => {
const f = join(home(), rel);
mkdirSync(dirname(f), { recursive: true });
writeFileSync(f, text);
};
const setEnabled = (on: boolean | undefined) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { mcpSyncEnabled: on }));
};
const disable = (...ids: string[]) => {
const file = registryFilePath();
mkdirSync(dirname(file), { recursive: true });
const clis = Object.fromEntries(ids.map((id) => [id, { enabled: false }]));
writeFileSync(file, JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
reloadCliRegistry();
};
const CLAUDE = '.claude.json';
const CODEX = '.codex/config.toml';
const GEMINI = '.gemini/settings.json';
beforeEach(() => {
for (const k of RELOCATION_VARS) delete process.env[k];
installed.clear();
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
for (const d of ['.claude.json', '.codex', '.gemini', '.config', 'relocated'])
rmSync(join(home(), d), { recursive: true, force: true });
write(CLAUDE, JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
// Codex and Gemini have been set up on this machine (their config files exist).
write(CODEX, 'model = "gpt-5"\n');
write(GEMINI, '{}');
setEnabled(true);
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(registryFilePath(), { force: true });
rmSync(SETTINGS_PATH, { force: true });
reloadCliRegistry();
});
describe('/api/mcp-sync — opt-in', () => {
it.each([
['absent', undefined],
['false', false],
])('answers 403 on both verbs and writes nothing while the setting is %s', async (_label, value) => {
setEnabled(value);
const before = readFileSync(join(home(), CODEX), 'utf8');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
for (const method of ['GET', 'POST'] as const) {
const res = await app.inject({ method, url: '/api/mcp-sync' });
expect(res.statusCode, method).toBe(403);
expect(res.json().error).toMatch(/disabled/i);
}
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('applies the toggle on the next request, with no restart', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
setEnabled(false);
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(403);
setEnabled(true);
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(200);
});
});
describe('/api/mcp-sync', () => {
it('GET previews without writing', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const before = readFileSync(join(home(), CODEX), 'utf8');
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.applied).toBe(false);
expect(body.data.targets.find((t: { id: string }) => t.id === 'codex').added).toEqual(['fs']);
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('POST adds the server to every enabled, set-up CLI', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
expect(res.json().data.applied).toBe(true);
expect(readFileSync(join(home(), CODEX), 'utf8')).toContain('[mcp_servers.fs]');
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
});
it('never creates config for an enabled CLI that is not installed and has no config file', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const opencode = res.json().data.targets.find((t: { id: string }) => t.id === 'opencode');
// Nothing is installed (mocked) and opencode has no config under the temp HOME.
expect(opencode.status).toBe('absent');
expect(existsSync(join(home(), '.config'))).toBe(false);
});
it('never touches a CLI that is disabled in the registry', async () => {
disable('codex');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const before = readFileSync(join(home(), CODEX), 'utf8');
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const ids = res.json().data.targets.map((t: { id: string }) => t.id);
expect(ids).not.toContain('codex');
expect(ids).toContain('gemini');
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
});
it('lists installed, enabled agent CLIs without MCP support, and omits disabled, uninstalled ones and the shell', async () => {
installed.add('grok').add('pi');
disable('pi');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const { unsupported } = (await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().data;
expect(unsupported).toEqual(['Grok']);
});
it('follows CODEX_HOME from the server env instead of writing the default ~/.codex', async () => {
const codexHome = join(home(), 'relocated/codex');
process.env.CODEX_HOME = codexHome;
write('relocated/codex/config.toml', 'model = "gpt-5"\n');
const before = readFileSync(join(home(), CODEX), 'utf8');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
expect(codex.file).toBe(join(codexHome, 'config.toml'));
expect(codex.added).toEqual(['fs']);
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('reports a relative CODEX_HOME as skipped and writes no codex file', async () => {
process.env.CODEX_HOME = 'relative/codex';
installed.add('codex');
const before = readFileSync(join(home(), CODEX), 'utf8');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
expect(codex.status).toBe('skipped');
expect(codex.error).toMatch(/CODEX_HOME/);
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('never echoes the text of a config file it cannot parse', async () => {
write(CODEX, 'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
expect(codex.status).toBe('unreadable');
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
expect(res.body).not.toContain('lin_SECRET_abc');
});
it('never returns env values or headers', async () => {
write(
CLAUDE,
JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sekrit-value' } } } })
);
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
expect(res.body).not.toContain('sekrit-value');
});
it('answers 409 to an apply that overlaps another, and a later apply succeeds', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
hold.release = () => undefined;
const entered = new Promise<void>((resolve) => (hold.entered = resolve));
const first = app.inject({ method: 'POST', url: '/api/mcp-sync' });
await entered; // the first apply now holds the lock
const release = hold.release;
hold.release = null; // the overlapping request goes straight to the real function
const second = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
expect(second.statusCode).toBe(409);
expect(second.json().errorCode).toBe('CONFLICT');
release?.();
expect((await first).statusCode).toBe(200);
expect((await app.inject({ method: 'POST', url: '/api/mcp-sync' })).statusCode).toBe(200);
});
it('multi-user: a non-admin is refused on both verbs and nothing is written', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const before = readFileSync(join(home(), CODEX), 'utf8');
for (const method of ['GET', 'POST'] as const) {
const res = await app.inject({ method, url: '/api/mcp-sync' });
expect(res.statusCode, method).toBe(403);
}
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('multi-user: an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().success).toBe(true);
});
});
+108 -2
View File
@@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { rm, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
@@ -122,6 +122,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
const { sessionId } = res.json();
const session = ctx.sessions.get(sessionId) as unknown as Session;
expect(session.customModel?.endpointId).toBe('ep1');
// #750, pinned the same way as the pi/grok/omp one-shot paths below: the FIRST
// launch already carries the selected id, not just the file on disk.
expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3');
expect(restartSpy).not.toHaveBeenCalled();
const configDir = customModelConfigDir(sessionId);
@@ -192,7 +195,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(res.json().errorCode).toBe('OPERATION_FAILED');
});
it('refuses a model id the CLI cannot carry on its command line, cleaning up any written config dir', async () => {
it('refuses a model id the CLI cannot carry on its command line, before writing anything', async () => {
const dirs = join(getDataDir(), 'custom-model-configs');
const before = existsSync(dirs) ? readdirSync(dirs).length : 0;
const res = await quickStart({
caseName: 'cm-badmodel',
mode: 'pi',
@@ -200,6 +205,7 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(existsSync(dirs) ? readdirSync(dirs).length : 0).toBe(before);
});
it('refuses customModel for a remote case', async () => {
@@ -226,6 +232,21 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
});
}
it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => {
const other = ctx.sessions.get('test-session-1')!;
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
mockRunning([{ model: 'llama3', state: 'ready' }]);
const res = await quickStart({
caseName: 'cm-badmodel-swap',
mode: 'codex',
customModel: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
expect(res.json().requiresConfirmation).toBeUndefined();
expect(res.json().errorCode).toBe('INVALID_INPUT');
});
it('asks for confirmation instead of launching when another live session is using the currently loaded model', async () => {
const other = ctx.sessions.get('test-session-1')!;
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
@@ -316,6 +337,47 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(ctx.sessions.size).toBe(2);
});
describe('parallel slots share the context (the quick-start path has its own slot branch)', () => {
const slotEndpoint = (contextLength: number, slots: number): CustomModelHost => ({
id: 'ep-slots',
label: 'multi-slot box',
baseUrl: 'http://192.168.1.52:8080',
apiKey: 'k',
modelContextLengths: { m: contextLength },
modelSlotCounts: { m: slots },
});
it('warns when the context divided across the slots is below the floor, and reports the slot count', async () => {
await writeCustomModelHosts(getDataDir(), [ENDPOINT, slotEndpoint(131072, 4)]); // ~33k per slot
const res = await quickStart({
caseName: 'cm-slots-warn',
mode: 'claude',
customModel: { endpointId: 'ep-slots', modelId: 'm' },
});
const body = res.json();
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(131072);
expect(body.slotCount).toBe(4);
expect(ctx.sessions.size).toBe(1); // nothing was created
});
it('launches when every slot can hold the floor', async () => {
await writeCustomModelHosts(getDataDir(), [ENDPOINT, slotEndpoint(262144, 4)]); // 65,536 per slot
const res = await quickStart({
caseName: 'cm-slots-ok',
mode: 'claude',
customModel: { endpointId: 'ep-slots', modelId: 'm' },
});
expect(res.statusCode).toBe(200);
expect(res.json().requiresContextWarning).toBeUndefined();
expect(ctx.sessions.size).toBe(2);
});
});
it('does not warn when nothing about context was discovered', async () => {
const res = await quickStart({
caseName: 'cm-no-ctx-data',
@@ -326,6 +388,50 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(res.statusCode).toBe(200);
expect(res.json().requiresContextWarning).toBeUndefined();
});
it('warns using the endpoint defaultContextLength when discovery recorded no per-model figure', async () => {
const endpoint: CustomModelHost = {
id: 'ep-default',
label: 'silent gateway',
baseUrl: 'http://192.168.1.53:8080',
apiKey: 'k',
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]);
const res = await quickStart({
caseName: 'cm-default-ctx',
mode: 'claude',
customModel: { endpointId: 'ep-default', modelId: 'some-unknown-model' },
});
const body = res.json();
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(16384);
expect(ctx.sessions.size).toBe(1); // nothing was created
});
it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => {
const endpoint: CustomModelHost = {
id: 'ep-precedence',
label: 'mixed box',
baseUrl: 'http://192.168.1.54:8080',
apiKey: 'k',
modelContextLengths: { m: 131072 },
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]);
const res = await quickStart({
caseName: 'cm-precedence',
mode: 'claude',
customModel: { endpointId: 'ep-precedence', modelId: 'm' },
});
expect(res.statusCode).toBe(200);
expect(res.json().requiresContextWarning).toBeUndefined();
expect(ctx.sessions.size).toBe(2);
});
});
describe('triggering the actual llama-swap load (not just watching for it)', () => {
+184
View File
@@ -0,0 +1,184 @@
/**
* @fileoverview Ralph file routes for a REMOTE (SSH) session.
*
* A remote session's `workingDir` is a path on the remote host. `fix-plan/read`
* used to read `@fix_plan.md` with local `fs`, and `fix-plan/write` and
* `ralph-prompt/write` wrote their files with it, so with a same-named local folder
* (an sshfs mount, a shared home layout) they read and wrote the LOCAL twin. Now:
* - `fix-plan/read` reads the file on the remote host through `src/remote-files.ts`
* (the File Viewer's ssh path), and a missing remote file is the same 404 as a
* missing local one;
* - both writes are refused (remote writes are out of scope, like the File Viewer's
* remote edit).
*
* The workspace path EXISTS locally here, holding a different `@fix_plan.md`, so a
* route that fell back to local `fs` would visibly import or overwrite the wrong file.
* The ssh layer is mocked (no connection is opened); the pure builders and
* `RemoteFileAccessError` stay real.
*
* Port: N/A (app.inject doesn't open ports)
*/
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { safeRmHomeTree } from '../mocks/test-helpers.js';
import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js';
import type { RemoteProbe } from '../../src/remote-files.js';
import type { SessionRemote } from '../../src/types/session.js';
vi.mock('../../src/remote-files.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/remote-files.js')>();
return { ...actual, remoteProbePaths: vi.fn(), remoteReadFile: vi.fn() };
});
import { remoteProbePaths, remoteReadFile, RemoteFileAccessError } from '../../src/remote-files.js';
const mockedProbePaths = vi.mocked(remoteProbePaths);
const mockedReadFile = vi.mocked(remoteReadFile);
// The local twin of the remote workspace: same absolute path, different file.
const TWIN = join(homedir(), '.codeman-test-ralph-remote-twin');
const LOCAL_PLAN = '# Fix Plan\n\n- [ ] LOCAL task\n';
const REMOTE_PLAN = '# Fix Plan\n\n- [ ] REMOTE task\n';
const remote: SessionRemote = {
hostId: 'host-1',
label: 'testhost',
host: '192.0.2.10',
username: 'j',
remotePath: TWIN,
};
function fileProbe(size = REMOTE_PLAN.length): RemoteProbe {
return { realPath: join(TWIN, '@fix_plan.md'), kind: 'file', size, mtimeMs: 1_700_000_000_000 };
}
const rootProbe: RemoteProbe = { realPath: TWIN, kind: 'directory', size: 0, mtimeMs: 0 };
describe('ralph file routes for a remote (SSH) session', () => {
let harness: RouteTestHarness;
let importFixPlanMarkdown: ReturnType<typeof vi.fn>;
beforeAll(() => {
mkdirSync(TWIN, { recursive: true });
});
afterAll(() => {
safeRmHomeTree(TWIN);
});
beforeEach(async () => {
writeFileSync(join(TWIN, '@fix_plan.md'), LOCAL_PLAN);
harness = await createRouteTestHarness(registerRalphRoutes);
importFixPlanMarkdown = vi.fn(() => 1);
const session = harness.ctx._session as unknown as Record<string, unknown>;
session.workingDir = TWIN;
session.remote = { ...remote };
session.ralphTracker = {
generateFixPlanMarkdown: vi.fn(() => '# Fix Plan\n\n- [ ] generated\n'),
importFixPlanMarkdown,
todos: [],
};
mockedProbePaths.mockResolvedValue([fileProbe(), rootProbe]);
mockedReadFile.mockResolvedValue(Buffer.from(REMOTE_PLAN));
});
afterEach(async () => {
await harness.app.close();
vi.clearAllMocks();
});
const post = (route: string, payload?: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/${route}`, payload });
describe('POST fix-plan/read', () => {
it('imports the file from the remote host, not the local twin', async () => {
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: true, data: { importedCount: 1 } });
expect(mockedProbePaths).toHaveBeenCalledWith(remote, [join(TWIN, '@fix_plan.md'), TWIN]);
expect(mockedReadFile).toHaveBeenCalledWith(remote, join(TWIN, '@fix_plan.md'), expect.any(Number));
expect(importFixPlanMarkdown).toHaveBeenCalledWith(REMOTE_PLAN);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
});
it('answers the same not-found as a local read when the remote file is missing', async () => {
mockedProbePaths.mockResolvedValue([null, rootProbe]);
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(mockedReadFile).not.toHaveBeenCalled();
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
it('treats a @fix_plan.md that resolves outside the workspace as missing', async () => {
// e.g. a symlink to ~/.ssh/id_rsa on the remote host: the File Viewer's confinement applies.
mockedProbePaths.mockResolvedValue([{ ...fileProbe(), realPath: '/home/j/.ssh/id_rsa' }, rootProbe]);
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(mockedReadFile).not.toHaveBeenCalled();
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
it('refuses a remote path that is not a regular file, or one over the cap, without reading it', async () => {
for (const probe of [
{ ...fileProbe(), kind: 'directory' as const },
{ ...fileProbe(), kind: 'other' as const },
fileProbe(64 * 1024 * 1024),
]) {
mockedProbePaths.mockResolvedValue([probe, rootProbe]);
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
}
expect(mockedReadFile).not.toHaveBeenCalled();
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
it('reports an unreachable host as a failed read, never a local fallback', async () => {
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timeout'));
const res = await post('fix-plan/read');
const body = JSON.parse(res.body);
expect(body).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
expect(body.error).toContain('unreachable');
expect(body.error).not.toContain('RemoteFileAccessError');
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
});
describe('writes', () => {
it('refuses fix-plan/write and leaves the local twin alone', async () => {
const res = await post('fix-plan/write');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(readFileSync(join(TWIN, '@fix_plan.md'), 'utf-8')).toBe(LOCAL_PLAN);
});
it('refuses ralph-prompt/write and creates nothing locally', async () => {
const res = await post('ralph-prompt/write', { content: 'do the thing' });
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(existsSync(join(TWIN, '@ralph_prompt.md'))).toBe(false);
});
});
describe('a local session is unchanged', () => {
beforeEach(() => {
delete (harness.ctx._session as unknown as Record<string, unknown>).remote;
});
it('reads the local file without touching ssh', async () => {
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: true });
expect(importFixPlanMarkdown).toHaveBeenCalledWith(LOCAL_PLAN);
expect(mockedProbePaths).not.toHaveBeenCalled();
expect(mockedReadFile).not.toHaveBeenCalled();
});
it('writes the local files', async () => {
expect(JSON.parse((await post('fix-plan/write')).body)).toMatchObject({ success: true });
expect(readFileSync(join(TWIN, '@fix_plan.md'), 'utf-8')).toContain('generated');
expect(JSON.parse((await post('ralph-prompt/write', { content: 'do the thing' })).body)).toMatchObject({
success: true,
});
expect(readFileSync(join(TWIN, '@ralph_prompt.md'), 'utf-8')).toBe('do the thing');
});
});
});
+2
View File
@@ -476,6 +476,8 @@ describe('ralph-routes', () => {
payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' },
});
await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1));
// The case rides the registration, so the session lands in the case's default session group.
expect(harness.ctx.addSession).toHaveBeenCalledWith(expect.anything(), { caseName: 'registration-order' });
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
@@ -144,7 +144,7 @@ describe('a rebuild that fails after the session is registered', () => {
expect(ctx.sessions.has('a')).toBe(false);
// NOT the user-initiated delete: that would bank this session's historical
// tokens into the lifetime totals, demote a pinned record to `stopped`, and
// delete the workspace's .claude-images.
// delete the workspace's .codeman-uploads.
expect(ctx.cleanupSession).not.toHaveBeenCalled();
await app.close();
});
@@ -0,0 +1,203 @@
/**
* @fileoverview Request paths that touch a session's workspace must not freeze the
* server when its network mount has gone away: `POST /api/sessions` with a
* `workingDir` on it, `POST /api/quick-start` for a linked case that lives there,
* and `POST /api/sessions/:id/paste-image` for a live session whose mount went away
* after creation. Creation must also not treat "did not answer" as "does not exist"
* (quick-start would scaffold a fresh case over the top of where the real one is
* mounted).
*
* A hard mount that stopped answering is simulated two ways, matching how each
* API behaves on one: a synchronous probe (`existsSync`/`statSync`/`mkdirSync`)
* busy-waits, freezing the event loop, and an async `stat()` never settles.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
const dead = vi.hoisted(() => {
// Short probe timeout so a stalled stat costs ~200 ms here. Read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '200';
return {
root: '/mnt/codeman-test-dead-mount',
blockMs: 3_000,
syncTouches: [] as string[],
releases: [] as Array<() => void>,
};
});
function onDeadMount(path: unknown): boolean {
const p = String(path);
return p === dead.root || p.startsWith(dead.root + '/');
}
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const freezeOn =
<T extends (...args: never[]) => unknown>(fn: T) =>
(...args: Parameters<T>): ReturnType<T> => {
if (onDeadMount(args[0])) {
dead.syncTouches.push(String(args[0]));
const until = Date.now() + dead.blockMs;
while (Date.now() < until) {
// spin: the event loop is frozen for as long as the mount does not answer
}
throw Object.assign(new Error('EIO'), { code: 'EIO' });
}
return fn(...args) as ReturnType<T>;
};
const existsSync = freezeOn(actual.existsSync);
const statSync = freezeOn(actual.statSync as (...args: never[]) => unknown);
const mkdirSync = freezeOn(actual.mkdirSync as (...args: never[]) => unknown);
return {
...actual,
existsSync,
statSync,
mkdirSync,
default: { ...actual, existsSync, statSync, mkdirSync },
};
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
if (onDeadMount(path)) {
return new Promise((_resolve, reject) => {
dead.releases.push(() => reject(Object.assign(new Error('EIO'), { code: 'EIO' })));
});
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { dataPath } from '../../src/config/instance.js';
describe('session creation on an unreachable mount', () => {
let app: FastifyInstance;
let ctx: ReturnType<typeof createMockRouteContext>;
let scratch: string;
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
dead.syncTouches.length = 0;
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
scratch = await mkdtemp(join(tmpdir(), 'codeman-unreachable-create-'));
app = Fastify({ logger: false });
await app.register(fastifyCookie);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
ctx = createMockRouteContext();
registerSessionRoutes(app, ctx as never);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
dead.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
await rm(scratch, { recursive: true, force: true });
await rm(dataPath('linked-cases.json'), { force: true });
warn.mockRestore();
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
it('POST /api/sessions answers promptly, and not as "does not exist", for a workingDir on a dead mount', async () => {
const started = Date.now();
const res = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'dead-mount', mode: 'shell', workingDir: `${dead.root}/project` },
});
const elapsed = Date.now() - started;
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
expect(dead.syncTouches).toEqual([]);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(body.error).toMatch(/not responding/i);
});
it('POST /api/sessions keeps INVALID_INPUT for a missing workingDir and for a file', async () => {
const file = join(scratch, 'a-file.txt');
await writeFile(file, 'x');
const missing = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'missing', mode: 'shell', workingDir: join(scratch, 'nope') },
});
expect(JSON.parse(missing.body)).toMatchObject({
success: false,
errorCode: 'INVALID_INPUT',
error: 'workingDir does not exist',
});
const notDir = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'file', mode: 'shell', workingDir: file },
});
expect(JSON.parse(notDir.body)).toMatchObject({
success: false,
errorCode: 'INVALID_INPUT',
error: 'workingDir is not a directory',
});
});
it('POST /api/quick-start refuses, promptly and without scaffolding, a linked case on a dead mount', async () => {
await writeFile(dataPath('linked-cases.json'), JSON.stringify({ 'nas-linked': `${dead.root}/linked` }));
const started = Date.now();
const res = await app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'nas-linked', mode: 'shell' },
});
const elapsed = Date.now() - started;
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
// Neither probed nor created synchronously on the dead mount.
expect(dead.syncTouches).toEqual([]);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(body.error).toMatch(/not responding/i);
});
it('POST /api/sessions/:id/paste-image answers promptly, not frozen, for a workspace on a dead mount', async () => {
// A live session can be created while its workspace is reachable and later have
// its mount go away. The probe runs before the multipart body is read, so the
// refusal lands without buffering the upload.
ctx._session.workingDir = `${dead.root}/project`;
const started = Date.now();
const res = await app.inject({
method: 'POST',
url: `/api/sessions/${ctx._sessionId}/paste-image`,
headers: { 'x-codeman-csrf': '1', 'content-type': 'multipart/form-data; boundary=b' },
payload: '--b--\r\n',
});
const elapsed = Date.now() - started;
expect(elapsed).toBeLessThan(dead.blockMs - 1_000);
expect(dead.syncTouches).toEqual([]);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('OPERATION_FAILED');
expect(body.error).toMatch(/not responding/i);
});
});
+190 -3
View File
@@ -9,7 +9,7 @@ import { createRouteTestHarness } from './_route-test-utils.js';
import { createMockSession } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { webviewFetch } from '../../src/web/webview-egress.js';
@@ -203,6 +203,10 @@ describe('POST /api/sessions/:id/custom-model', () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'pi';
// Start from no dir at all (a sibling test's successful pi apply may have left one),
// so the existsSync below says something about THIS request.
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
rmSync(dir, { recursive: true, force: true });
const res = await app.inject({
method: 'POST',
@@ -214,8 +218,45 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).not.toHaveBeenCalled();
expect(session.restartCli).not.toHaveBeenCalled();
// The config dir written before the check is cleaned up again.
expect(existsSync(join(getDataDir(), 'custom-model-configs', 'test-session-1'))).toBe(false);
// The pattern check runs on the pure build BEFORE any write, so a refused id leaves
// no directory behind at all, nothing to clean up.
expect(existsSync(dir)).toBe(false);
});
it('codex: a refused forced launch id never touches the live config dir the session runs on as CODEX_HOME', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'codex';
// First apply succeeds: the session now runs codex on qwen3:32b, whose config.toml
// lives in the per-session dir the running CLI itself uses as CODEX_HOME.
const ok = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'qwen3:32b' },
});
expect(ok.statusCode).toBe(200);
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
const written = join(dir, 'config.toml');
expect(existsSync(written)).toBe(true);
// `@` is outside codex's `model-plain-colon` pattern. The pattern check now runs on
// the pure build BEFORE any write, so the refusal must leave the dir, and the file
// the live CLI reads, exactly as they were, rather than replacing and deleting the
// CODEX_HOME of a session that stays on qwen3:32b.
const before = readFileSync(written, 'utf8');
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
expect(session.restartCli).toHaveBeenCalledTimes(1);
expect(existsSync(dir)).toBe(true);
expect(readFileSync(written, 'utf8')).toBe(before);
});
it('clear removes the previous config dir the session reports', async () => {
@@ -298,6 +339,28 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(session.restartCli).not.toHaveBeenCalled();
});
it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'codex';
const other = createMockSession('other-session');
other.name = 'w2-otherbox';
other.customModel = { endpointId: 'ep1', modelId: 'llama3' };
ctx.sessions.set('other-session', other);
mockRunning([{ model: 'llama3', state: 'ready' }]);
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
// Agreeing to the swap first would only lead to this same 400.
expect(res.json().requiresConfirmation).toBeUndefined();
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).not.toHaveBeenCalled();
});
describe('multi-user: the confirm dialog must not name a session the caller cannot access', () => {
const saved: Record<string, string | undefined> = {};
@@ -449,6 +512,130 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(session.restartCli).not.toHaveBeenCalled();
});
it('the endpoint defaultContextLength stands in when discovery recorded no per-model figure', async () => {
const { app, ctx } = await setup();
// A gateway that states nothing per model: only the user's default is known.
const endpoint: CustomModelHost = {
id: 'ep-default',
label: 'silent gateway',
baseUrl: 'http://192.168.1.53:8080',
apiKey: 'k',
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep-default', modelId: 'some-unknown-model' },
});
const body = res.json();
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(16384);
expect(session.setCustomModel).not.toHaveBeenCalled();
});
it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => {
const { app, ctx } = await setup();
// The default is small enough to warn on; the discovered figure for THIS model is
// not, so the fallback must not leak into the per-model path.
const endpoint: CustomModelHost = {
id: 'ep-precedence',
label: 'mixed box',
baseUrl: 'http://192.168.1.54:8080',
apiKey: 'k',
modelContextLengths: { 'qwen3.8-27b-ud-q4_k_xl': 131072 },
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep-precedence', modelId: 'qwen3.8-27b-ud-q4_k_xl' },
});
expect(res.json().requiresContextWarning).toBeUndefined();
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
// llama-server splits its context across --parallel slots, and Claude Code sends several
// requests at once, so a context that clears the floor as a whole can still fail per slot.
describe('parallel slots share the context', () => {
const slotEndpoint = (contextLength: number, slots?: number): CustomModelHost => ({
id: 'ep-slots',
label: 'multi-slot box',
baseUrl: 'http://192.168.1.52:8080',
apiKey: 'k',
modelContextLengths: { m: contextLength },
...(slots === undefined ? {} : { modelSlotCounts: { m: slots } }),
});
async function apply(endpoint: CustomModelHost) {
const { app, ctx } = await setup();
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: endpoint.id, modelId: 'm' },
});
return { body: res.json(), session };
}
it('warns when the context divided across the slots is below the floor, and reports the slot count', async () => {
// the measured case: ~131k shared by 4 slots is ~33k each, under the 40k a request needs
const { body, session } = await apply(slotEndpoint(131072, 4));
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(131072);
expect(body.slotCount).toBe(4);
expect(session.setCustomModel).not.toHaveBeenCalled();
});
it('divides the discovered context across the slots even when it clears the floor as a whole', async () => {
// 65,536 over 4 slots is 16,384 each: nothing the server reports can widen this pool
const { body } = await apply(slotEndpoint(65536, 4));
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(65536);
expect(body.slotCount).toBe(4);
});
it('does not warn once every slot can hold the floor', async () => {
const { body, session } = await apply(slotEndpoint(262144, 4)); // 65,536 each
expect(body.requiresContextWarning).toBeUndefined();
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
it('does not warn for a single slot with the same context that failed across four', async () => {
const { body, session } = await apply(slotEndpoint(131072, 1));
expect(body.requiresContextWarning).toBeUndefined();
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
it('an unknown slot count is treated as one slot, never guessed', async () => {
const { body } = await apply(slotEndpoint(131072));
expect(body.requiresContextWarning).toBeUndefined();
});
it('omits slotCount from the warning when the context is too small on its own with a single slot', async () => {
const { body } = await apply(slotEndpoint(16384, 1));
expect(body.requiresContextWarning).toBe(true);
expect(body.slotCount).toBeUndefined();
});
});
it('applies once confirmed, skipping the context check the second time', async () => {
const { app, ctx } = await setup();
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, SMALL_CTX_ENDPOINT]);
+36 -4
View File
@@ -24,7 +24,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_WAIT_MS } from '../../src/config/agent-wait.js';
import { MAX_WAIT_MS, MIN_WAIT_MS } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
@@ -315,6 +315,39 @@ describe('POST /api/sessions/:id/input with wait', () => {
expect(body.data.status).toBe('busy');
});
it('a duplicate on a codex pane showing a dialog answers blocked, as GET .../wait does', async () => {
// A screen-read dialog is a level, not an edge: the original delivery may have raised
// it before the caller's connection dropped, and the retry must not wait out its
// timeout for an edge that already passed.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
mode: string;
status: string;
screenBlocked?: boolean;
writeBuffer: string[];
};
session.mode = 'codex';
await send(app, { input: 'first', clientId: 'c-codex', seq: 1 });
session.status = 'idle';
session.screenBlocked = true;
const replay = await send(app, {
input: 'first',
clientId: 'c-codex',
seq: 1,
wait: 'blocked',
waitTimeout: MIN_WAIT_MS,
});
const body = replay.json();
expect(body.data.duplicate).toBe(true);
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: true, timedOut: false });
// ...and only to a caller that asked for it: such a pane is idle too.
const idle = await send(app, { input: 'first', clientId: 'c-codex', seq: 1, wait: 'idle' });
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
});
it('gives the dedup seq back when a full waiter pool rejects the request', async () => {
// Otherwise the caller's retry is refused as a duplicate and the input vanishes.
const { app, ctx } = await harness();
@@ -451,13 +484,12 @@ describe('POST /api/sessions/:id/input: delivered reports the write, not just th
* exactly why this regression needs a POST and a real socket to catch.
*/
describe('POST /api/sessions/:id/input over real HTTP: hang-up handling', () => {
const PORT = 3181;
const base = `http://127.0.0.1:${PORT}`;
let base: string;
let app: FastifyInstance;
beforeAll(async () => {
app = (await harness()).app;
await app.listen({ port: PORT, host: '127.0.0.1' });
base = await app.listen({ port: 0, host: '127.0.0.1' });
});
afterAll(async () => {
+63 -2
View File
@@ -10,16 +10,25 @@
* Uses app.inject(), so no real HTTP port is needed.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { resolveCliLaunchError } from '../../src/utils/cli-launcher.js';
// External CLIs are pre-flighted (is the binary there?) before anything touches the
// workspace; CI has none installed, so the pre-flight is stubbed to "can start" and one
// test flips it to a refusal.
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
@@ -104,6 +113,58 @@ describe('POST /api/sessions agent-skill injection', () => {
expect(existsSync(skillDir())).toBe(false);
});
it('gives a codex session the skill in .agents/skills, where codex looks, and none in .claude', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-codex', mode: 'codex', workingDir },
});
expect(res.statusCode).toBe(200);
const skillMd = await readFile(join(workingDir, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(workingDir, '.agents', 'skills', 'codeman', 'reference', 'endpoints.md'))).toBe(true);
expect(existsSync(skillDir())).toBe(false);
// The preamble cache is seeded for it too, so the skill's §0 is the two-line loader
// in codex as in claude (nothing in the preamble is claude-specific).
const sessionId = (JSON.parse(res.body) as { session: { id: string } }).session.id;
const preamble = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), `codeman-agent-${sessionId}.sh`);
expect(existsSync(preamble)).toBe(true);
});
it('gives a pi session nothing: a project skill would put up pi\'s trust prompt', async () => {
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-pi', mode: 'pi', workingDir },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
expect(existsSync(join(workingDir, '.pi'))).toBe(false);
expect(harness.ctx.getAgentSkillEnabled).not.toHaveBeenCalled();
});
it('writes nothing when the CLI cannot start (pre-flight refusal)', async () => {
vi.mocked(resolveCliLaunchError).mockResolvedValueOnce('codex not found');
harness = await createHarness(true);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'skill-codex-missing', mode: 'codex', workingDir },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(existsSync(join(workingDir, '.agents'))).toBe(false);
});
it('defaults an omitted mode to claude and injects', async () => {
// The frontend omits `mode` for a plain claude create, so the default branch
// is the common path, not an edge case.
@@ -0,0 +1,162 @@
/**
* @fileoverview `model` on POST /api/sessions — a Claude model for one session only.
*
* Claude's model reaches disk only through `modelOverride`, which writes it into the
* case's `.claude/settings.local.json` for every later run there. `model` is the
* per-session counterpart: it goes out as `claude --model <id>`, wins over the app-wide
* default, and writes nothing. What the tests read is the model the session hands the
* mux when it starts, which is what becomes the `--model` flag.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions model', () => {
let workingDir: string;
let harness: Harness;
beforeEach(async () => {
workingDir = await mkdtemp(join(tmpdir(), 'codeman-session-model-'));
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
await rm(workingDir, { recursive: true, force: true });
});
/** Creates a session and starts it, then returns the model it handed the mux. */
async function launchedModel(payload: Record<string, unknown>): Promise<unknown> {
const res = await harness.app.inject({ method: 'POST', url: '/api/sessions', payload: { workingDir, ...payload } });
expect(res.statusCode).toBe(200);
const parsed = JSON.parse(res.body);
const id = (parsed.data?.session ?? parsed.session).id as string;
await harness.app.inject({ method: 'POST', url: `/api/sessions/${id}/interactive`, payload: {} });
const calls = harness.ctx.mux.createSession.mock.calls;
expect(calls.length).toBeGreaterThan(0);
return (calls[calls.length - 1][0] as { model?: string }).model;
}
it('launches a Claude session on the model the caller names', async () => {
expect(await launchedModel({ mode: 'claude', model: 'claude-fable-5-1' })).toBe('claude-fable-5-1');
});
it('wins over the app-wide default model', async () => {
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
expect(await launchedModel({ mode: 'claude', model: 'opus' })).toBe('opus');
});
it('leaves the app-wide default in charge when the caller names none', async () => {
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
expect(await launchedModel({ mode: 'claude' })).toBe('sonnet');
});
it('launches on `model` while `modelOverride` alone reaches the case file', async () => {
// Sent together, each lands where it belongs: the persistent default in the case's
// settings.local.json, and this session's model on its launch line. A route that wrote
// `model` to disk would put 'opus' in the file; one that ignored it would launch 'sonnet'.
expect(await launchedModel({ mode: 'claude', model: 'opus', modelOverride: 'sonnet' })).toBe('opus');
const settings = JSON.parse(await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8'));
expect(settings.model).toBe('sonnet');
});
it('reads an empty model as no model, as modelOverride does', async () => {
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
expect(await launchedModel({ mode: 'claude', model: '' })).toBe('sonnet');
});
it('refuses a model for a CLI that takes its model in its own config object', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir, mode: 'codex', model: 'gpt-5' },
});
const parsed = JSON.parse(res.body);
expect(parsed.success).toBe(false);
expect(parsed.errorCode).toBe('INVALID_INPUT');
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
});
it('rejects a model with characters the launch pattern refuses', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir, mode: 'claude', model: 'opus; rm -rf ~' },
});
expect(res.statusCode).toBe(400);
});
it('rejects a flag-shaped model, since the value lands in argv', async () => {
for (const model of ['--dangerously-skip-permissions', '-p', '.hidden', '[1m]']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir, mode: 'claude', model },
});
expect(res.statusCode, model).toBe(400);
}
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
});
it('still accepts real model ids, aliases and the [1m] suffix', async () => {
for (const model of ['claude-fable-5-1', 'opus', 'opus[1m]', 'claude-opus-5-5[1m]']) {
expect(await launchedModel({ mode: 'claude', model })).toBe(model);
}
});
it.each([
['model', { model: 'opus' }],
['advisorModel', { advisorModel: 'opus' }],
])('refuses %s on a remote attach, which launches nothing', async (_field, extra) => {
// Refused before the host is looked up, so no remote host needs to exist.
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: {
mode: 'claude',
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
...extra,
},
});
const parsed = JSON.parse(res.body);
expect(parsed.success).toBe(false);
expect(parsed.errorCode).toBe('INVALID_INPUT');
expect(harness.ctx.sessions.size).toBe(1);
});
it('publishes the launch model on the created claude session', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir, mode: 'claude', model: 'claude-fable-5-1', advisorModel: 'opus' },
});
const parsed = JSON.parse(res.body);
const session = parsed.data?.session ?? parsed.session;
expect(session.model).toBe('claude-fable-5-1');
expect(session.advisorModel).toBe('opus');
});
});
@@ -0,0 +1,219 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { applyLaunchDefaults } from '../../src/web/launch-defaults.js';
import { buildCodexCommand } from '../../src/tmux-manager.js';
import { Session } from '../../src/session.js';
import { safeRmHomeTree } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js';
import { SettingsUpdateSchema } from '../../src/web/schemas.js';
import { getCli } from '../../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
import type { CodexConfig } from '../../src/types.js';
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
/** The codexConfig a launch gets from the synced defaults alone (the caller sent `config`). */
async function resolveCodexDefaults(config: CodexConfig | undefined, customEndpoint = false) {
return (await applyLaunchDefaults('codex', { codexConfig: config }, customEndpoint)).codexConfig;
}
describe('Codex launch defaults', () => {
let harness: RouteTestHarness;
const workingDir = join(homedir(), 'codex-default-test');
beforeEach(async () => {
for (const name of ['docker-hosts.json', 'docker-cases.json', 'remote-hosts.json', 'remote-cases.json']) {
await rm(join(getDataDir(), name), { force: true });
}
await mkdir(workingDir, { recursive: true });
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
await writeFile(SETTINGS_PATH, JSON.stringify({ codexModel: 'gpt-6.1', codexReasoningEffort: 'high' }));
harness = await createRouteTestHarness(registerSessionRoutes);
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
});
afterEach(async () => {
await harness.app.close();
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(workingDir);
vi.restoreAllMocks();
});
async function createdConfig(url: string, overrides: Record<string, unknown> = {}) {
const payload =
url === '/api/sessions'
? { workingDir, mode: 'codex', ...overrides }
: { caseName: 'codex-default-test', mode: 'codex', ...overrides };
const res = await harness.app.inject({ method: 'POST', url, payload });
expect(res.statusCode).toBe(200);
expect(res.json().success, res.body).not.toBe(false);
const session = [...harness.ctx.sessions.values()].at(-1) as Session | undefined;
expect(session).toBeDefined();
return session!.codexConfig;
}
for (const url of ['/api/sessions', '/api/quick-start']) {
it(`applies persisted defaults at ${url} and emits the CLI options`, async () => {
const config = await createdConfig(url);
expect(config).toMatchObject({ model: 'gpt-6.1', reasoningEffort: 'high' });
expect(buildCodexCommand(config)).toContain('--model gpt-6.1');
expect(buildCodexCommand(config)).toContain('--config model_reasoning_effort=high');
});
it(`preserves explicit model and effort at ${url}`, async () => {
expect(await createdConfig(url, { codexConfig: { model: 'gpt-other', reasoningEffort: 'low' } })).toMatchObject({
model: 'gpt-other',
reasoningEffort: 'low',
});
});
}
it('leaves empty defaults to Codex and ignores malformed persisted values', async () => {
for (const settings of [
{ codexModel: '', codexReasoningEffort: '' },
{ codexModel: 'bad;command', codexReasoningEffort: 'invalid' },
]) {
await writeFile(SETTINGS_PATH, JSON.stringify(settings));
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
expect(buildCodexCommand(await resolveCodexDefaults(undefined))).toBe('codex');
}
});
it('keeps defaults out of custom endpoint launches', async () => {
const config = { model: 'local-model', animations: false };
expect(await resolveCodexDefaults(config, true)).toBe(config);
});
it('is driven by the registry: codex declares the defaults, and a CLI without them is untouched', async () => {
// The routes call applyLaunchDefaults for every mode and never ask which CLI it is, so
// what codex gets is exactly what its entry declares.
expect(getCli('codex')!.capabilities.launchDefaults).toEqual({
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
});
for (const entry of STOCK_CLIS) {
if ((entry.id as string) === 'codex') continue;
expect(entry.capabilities.launchDefaults, entry.id).toBeUndefined();
const configs = { codexConfig: undefined, geminiConfig: { model: 'g' }, piConfig: undefined };
expect(await applyLaunchDefaults(entry.id, configs), entry.id).toBe(configs);
}
// Only the entry's own config object is filled; the rest of the bag passes through.
const bag = { codexConfig: { animations: false }, geminiConfig: { model: 'g' }, name: 'n' };
const filled = await applyLaunchDefaults('codex', bag);
expect(filled).toEqual({
codexConfig: { animations: false, model: 'gpt-6.1', reasoningEffort: 'high' },
geminiConfig: { model: 'g' },
name: 'n',
});
expect(filled.geminiConfig).toBe(bag.geminiConfig);
expect(bag.codexConfig).toEqual({ animations: false });
// A field the caller sent is never overwritten, one at a time.
expect(await resolveCodexDefaults({ model: 'mine' })).toEqual({ model: 'mine', reasoningEffort: 'high' });
expect(await resolveCodexDefaults({ reasoningEffort: 'low' })).toEqual({
model: 'gpt-6.1',
reasoningEffort: 'low',
});
});
it('does not record unused defaults for a Docker quick-start', async () => {
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
JSON.stringify([{ id: 'docker1', label: 'Docker', image: 'codeman/agent:base' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([
{
name: 'codex-default-test',
type: 'docker',
hostId: 'docker1',
hostWorkspacePath: workingDir,
container: 'codeman-default-test',
},
])
);
expect(await createdConfig('/api/quick-start')).toBeUndefined();
const session = [...harness.ctx.sessions.values()].at(-1) as Session;
expect(session.docker?.containerName).toBe('codeman-default-test');
expect(session.toState().model).toBeUndefined();
});
it('does not record unused defaults for a remote attach', async () => {
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
);
expect(
await createdConfig('/api/sessions', {
workingDir: undefined,
attachRemoteSession: { hostId: 'remote1', remoteSessionName: 'codeman-existing' },
})
).toBeUndefined();
const session = [...harness.ctx.sessions.values()].at(-1) as Session;
expect(session.toState().remote?.hostId).toBe('remote1');
expect(session.toState().model).toBeUndefined();
});
it('does not record unused defaults for a remote quick-start', async () => {
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'codex-default-test', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }])
);
expect(await createdConfig('/api/quick-start')).toBeUndefined();
const session = [...harness.ctx.sessions.values()].at(-1) as Session;
expect(session.toState().remote?.hostId).toBe('remote1');
expect(session.toState().model).toBeUndefined();
});
it('accepts, persists, clears and validates synced settings via HTTP', async () => {
const system = await createRouteTestHarness(registerSystemRoutes);
try {
const put = (payload: unknown) => system.app.inject({ method: 'PUT', url: '/api/settings', payload });
expect((await put({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' })).statusCode).toBe(200);
const read = await system.app.inject({ method: 'GET', url: '/api/settings' });
expect(read.json()).toMatchObject({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' });
expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400);
expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400);
expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200);
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
} finally {
await system.app.close();
}
});
it('the App Settings client check mirrors SettingsUpdateSchema.codexModel and runs before the local write', () => {
// SettingsUpdateSchema is .strict(), so a codexModel the schema refuses 400s the
// WHOLE settings PUT while the toast still says "Settings saved". saveAppSettings()
// refuses it client-side with a copy of the schema's pattern; a looser copy brings
// that silent 400 back, a stricter one refuses valid model ids. Length is left
// out on purpose: the input's maxlength="100" covers .max(100).
const src = readFileSync(resolve(import.meta.dirname, '../../src/web/public/settings-ui.js'), 'utf8');
const start = src.indexOf('async saveAppSettings() {');
expect(start).toBeGreaterThan(-1);
const body = src.slice(start);
const m = body.match(/if \(!\/(\^\[[^\]\n]+\]\*\$)\/\.test\(settings\.codexModel\)\)/);
expect(m).not.toBeNull();
const client = new RegExp(m![1]);
for (const v of ['', 'gpt-5.1', 'org/model_1-x', 'gpt-oss:20b', 'a b', 'bad;cmd', '-x', 'é']) {
expect(client.test(v), v).toBe(SettingsUpdateSchema.safeParse({ codexModel: v }).success);
}
const guardAt = body.indexOf(m![0]);
const writeAt = body.indexOf('this.saveAppSettingsToStorage(settings);');
expect(writeAt).toBeGreaterThan(-1);
expect(guardAt).toBeLessThan(writeAt);
});
});
@@ -14,9 +14,11 @@
* block without it lost the "You" badge on prompts
* - a pane that has produced no output reports hasContext: false rather than 404ing
* - Claude mode still takes the Claude path (regression guard)
* - copilot (a repainting full-screen TUI) is read off the pane SCREEN via
* capturePaneText, and falls back to the buffer only when there is no screen
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
@@ -154,6 +156,65 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
expect(body.data.hasContext).toBe(false);
});
describe('copilot reads the pane screen, not the stream', () => {
const rule = '─'.repeat(80);
const screenOf = (answer: string) =>
[
' ❯ what changed 11:30',
` ● ${answer}`,
' /tmp/work Session: 0 AIC used',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help',
].join('\n');
function stubCapture(result: string | null) {
const capturePaneText = vi.fn((_muxName: string) => result);
(harness.ctx.mux as unknown as { capturePaneText: typeof capturePaneText }).capturePaneText = capturePaneText;
return capturePaneText;
}
it('segments the captured screen of the session pane', async () => {
session.mode = 'copilot';
// What the stream looks like for a repainting TUI once escapes are stripped: rows fused
// together, so a reader of the buffer would return this run of text.
session.terminalBuffer = 'STREAM-ONLY fused rows that are not the answer';
const capture = stubCapture(screenOf('The screen answer.'));
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(capture).toHaveBeenCalledWith(session.muxName);
expect(body.data.text).toBe('The screen answer.');
expect(body.data.text).not.toContain('STREAM-ONLY');
expect(body.data.hasContext).toBe(true);
});
it('falls back to the buffer when the pane has no screen to capture', async () => {
session.mode = 'copilot';
session.terminalBuffer = screenOf('The buffer answer.');
const capture = stubCapture(null);
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(capture).toHaveBeenCalledWith(session.muxName);
expect(body.data.text).toBe('The buffer answer.');
});
it('never captures the screen for a mode that streams lines', async () => {
session.mode = 'opencode';
session.terminalBuffer = PANE;
const capture = stubCapture(screenOf('must not be read'));
const { body } = await lastResponse();
expect(capture).not.toHaveBeenCalled();
expect(body.data.text).toContain('Documented the helper in docs/retry.md');
});
});
it('leaves Claude mode on the Claude transcript path', async () => {
// Regression guard: a claude pane must NOT be segmented off its terminal
// buffer, or a real transcript would be shadowed by scraped pane text.
@@ -0,0 +1,104 @@
/**
* `POST /api/sessions/:id/git-fetch-settings` (session-routes.ts): the per-case half of the fetch interval
* (src/git-fetch-scheduler.ts): the case's own switch and its own interval. `null` on either half clears
* that half so the app-wide setting applies again, and a body that names one row leaves the other as it was,
* because the Session Options rows save on their own. Refused for remote and Docker cases, the same rule the
* Git panel and the scheduler use. app.inject(), no port.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions/:id/git-fetch-settings', () => {
let harness: Harness;
beforeEach(async () => {
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
});
const post = (payload: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/git-fetch-settings`, payload });
it('stores the case’s own choice, persists and broadcasts it', async () => {
const res = await post({ enabled: true, intervalSeconds: 120 });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 120 });
expect(harness.ctx._session.gitFetchEnabled).toBe(true);
expect(harness.ctx._session.gitFetchIntervalSeconds).toBe(120);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
});
it('answers `null` for a case that follows the app, which is what the Session Options row shows', async () => {
const data = JSON.parse((await post({})).body).data.gitFetchSettings;
expect(data).toEqual({ enabled: null, intervalSeconds: null });
});
it('clears the override on null so the app-wide setting applies again', async () => {
await post({ enabled: true, intervalSeconds: 120 });
const res = await post({ enabled: null, intervalSeconds: null });
expect(res.statusCode).toBe(200);
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined();
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: null, intervalSeconds: null });
});
it('leaves the other half as it was when only one row was saved', async () => {
await post({ enabled: true });
expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined(); // still following the app on that half
const res = await post({ intervalSeconds: 90 });
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 90 });
});
it('rejects an interval outside the schema bounds, storing nothing, because that is what the scheduler acts on', async () => {
// `null` is NOT on this list: it is the schema's way of clearing the override, tested above.
for (const raw of [29, 3601, 12.5, '120', true]) {
const res = await post({ enabled: true, intervalSeconds: raw });
expect(res.statusCode, String(raw)).toBe(400);
}
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
it.each([
['remote', { host: 'h', user: 'u' }],
['docker', { container: 'c' }],
])('refuses a %s case, storing nothing', async (kind, value) => {
(harness.ctx._session as unknown as Record<string, unknown>)[kind] = value;
const res = await post({ enabled: true, intervalSeconds: 120 });
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
it('404s an unknown session, storing nothing', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/nope/git-fetch-settings',
payload: { enabled: true },
});
expect(res.statusCode).toBe(404);
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
/**
* `POST /api/sessions/:id/keep-warm` (session-routes.ts): the per-session opt-in for the
* prompt-cache keep-warm. Stores the choice, persists and broadcasts the session, asks the
* scheduler to re-evaluate, clears the choice on `null`, and refuses a CLI with no cache
* telemetry. app.inject(), no port.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions/:id/keep-warm', () => {
let harness: Harness;
beforeEach(async () => {
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
});
const post = (payload: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/keep-warm`, payload });
it('stores the choice, persists, broadcasts and re-evaluates', async () => {
const res = await post({ enabled: true });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.keepWarm).toMatchObject({ enabled: true });
expect(harness.ctx._session.keepWarmEnabled).toBe(true);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
expect(harness.ctx.keepWarm.schedule).toHaveBeenCalledWith(harness.ctx._sessionId);
// The re-evaluation runs first, so the broadcast state and the response carry the new status.
expect(harness.ctx.keepWarm.schedule.mock.invocationCallOrder[0]).toBeLessThan(
harness.ctx.persistSessionState.mock.invocationCallOrder[0]
);
});
it('clears the choice on null so the app-wide default applies again', async () => {
await post({ enabled: false });
const res = await post({ enabled: null });
expect(res.statusCode).toBe(200);
expect(harness.ctx._session.keepWarmEnabled).toBeUndefined();
expect(JSON.parse(res.body).data.keepWarm.enabled).toBeNull();
});
it('rejects a body without enabled', async () => {
expect((await post({})).statusCode).toBe(400);
});
it('refuses a CLI that reports no cache telemetry', async () => {
harness.ctx._session.mode = 'shell';
const res = await post({ enabled: true });
// This harness has no envelope hook, so the refusal shows in the body, not the status.
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(harness.ctx._session.keepWarmEnabled).toBeUndefined();
expect(harness.ctx.keepWarm.schedule).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,228 @@
/**
* @fileoverview `tabGroupId` on the create routes: a session created from a
* group's "New session" action asks to be placed in that group.
*
* The routes only validate the field and hand it to `ctx.addSession()`; the
* placement itself (and the rule that an unknown or foreign group is ignored)
* lives in TabLayoutService, covered by test/tab-layout-service.test.ts. What is
* pinned here is the wire contract: the field is validated (a null or an empty
* string is a 400, never a silent drop), it reaches addSession with the new
* session, and the committed layout comes back on the response so the browser
* can draw the tab in its group without waiting for a re-read. Requests without
* the field keep their exact response shape.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { Session } from '../../src/session.js';
import { TabLayoutService } from '../../src/tab-layout-service.js';
import { ownerLayoutKey } from '../../src/tab-layout-persistence.js';
import type { TabLayout } from '../../src/tab-layout.js';
const LAYOUT = {
version: 4,
updatedAt: '2026-10-01T00:00:00.000Z',
groups: [{ id: 'g1', name: 'Core', refs: [] as Array<{ kind: 'session'; id: string }> }],
ungrouped: [] as Array<{ kind: 'session'; id: string }>,
};
describe('create routes: tabGroupId', () => {
let app: FastifyInstance;
let ctx: MockRouteContext;
let workingDir: string;
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
workingDir = await mkdtemp(join(tmpdir(), 'codeman-tab-group-'));
app = Fastify({ logger: false });
await app.register(fastifyCookie);
ctx = createMockRouteContext();
ctx.addSession.mockImplementation(async (session: { id: string }, placement?: { tabGroupId?: string }) => {
ctx.sessions.set(session.id, session as never);
const refs = [{ kind: 'session' as const, id: session.id }];
return placement?.tabGroupId === 'g1'
? { ...LAYOUT, version: 5, groups: [{ ...LAYOUT.groups[0], refs }] }
: { ...LAYOUT, version: 5, ungrouped: refs };
});
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
await rm(workingDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
const body = (raw: string) => {
const parsed = JSON.parse(raw);
return parsed.data ?? parsed;
};
it('POST /api/sessions hands the group to addSession and returns the committed layout', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-x', mode: 'shell', workingDir, tabGroupId: 'g1' },
});
expect(res.statusCode).toBe(200);
const data = body(res.body);
expect(ctx.addSession).toHaveBeenCalledTimes(1);
expect(ctx.addSession.mock.calls[0][0].id).toBe(data.session.id);
expect(ctx.addSession.mock.calls[0][1]).toEqual({ tabGroupId: 'g1' });
expect(data.tabLayout.version).toBe(5);
expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.session.id }]);
});
it('POST /api/sessions without a group keeps its response shape', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-x', mode: 'shell', workingDir },
});
expect(res.statusCode).toBe(200);
expect(Object.keys(body(res.body))).toEqual(['session']);
expect(ctx.addSession.mock.calls[0][1]).toBeUndefined();
});
it('POST /api/quick-start carries the group the same way', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'tabgroupcase', mode: 'shell', tabGroupId: 'g1' },
});
expect(res.statusCode).toBe(200);
const data = body(res.body);
// quick-start always names its case too, for the case's default group (the
// explicit group still wins inside TabLayoutService).
expect(ctx.addSession.mock.calls[0][1]).toEqual({ caseName: 'tabgroupcase', tabGroupId: 'g1' });
expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.sessionId }]);
const plain = await app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'tabgroupcase', mode: 'shell' },
});
expect(body(plain.body).tabLayout).toBeUndefined();
});
it.each([
['null', null],
['an empty string', ''],
['a number', 7],
['an over-long id', 'g'.repeat(101)],
])('rejects %s as a tabGroupId with a 400 and creates nothing', async (_label, tabGroupId) => {
for (const [url, payload] of [
['/api/sessions', { name: 'w1-x', mode: 'shell', workingDir, tabGroupId }],
['/api/quick-start', { caseName: 'tabgroupcase', mode: 'shell', tabGroupId }],
] as const) {
const res = await app.inject({ method: 'POST', url, payload });
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).errorCode).toBe('INVALID_INPUT');
}
expect(ctx.addSession).not.toHaveBeenCalled();
});
});
describe('create routes: tabGroupId through the real layout service', () => {
let app: FastifyInstance;
let ctx: MockRouteContext;
let workingDir: string;
const layouts: Record<string, TabLayout> = {};
let order: string[] = [];
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
workingDir = await mkdtemp(join(tmpdir(), 'codeman-tab-group-real-'));
app = Fastify({ logger: false });
await app.register(fastifyCookie);
ctx = createMockRouteContext();
ctx.sessions.clear(); // only the sessions these requests create
for (const key of Object.keys(layouts)) delete layouts[key];
order = [];
// The real service over an in-memory store, wired the way server.ts wires it.
const service = new TabLayoutService({
store: {
getTabLayout: (owner: string) => layouts[owner] ?? null,
getTabLayouts: () => ({ ...layouts }),
getSessions: () => ({}),
getSessionOrder: () => [...order],
commitTabLayoutProjection: (
updates: Readonly<Record<string, TabLayout>>,
project: (latest: readonly string[]) => readonly string[]
) => {
order = [...project(order)];
Object.assign(layouts, structuredClone(updates));
return { layouts: structuredClone(updates), sessionOrder: [...order] };
},
} as never,
sessions: ctx.sessions as never,
readWebviews: async () => [],
broadcast: () => {},
broadcastSessionOrder: () => {},
});
service.markRestorationSkipped();
ctx.addSession.mockImplementation((session: Session, placement?: { tabGroupId?: string }) =>
service.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
ctx.sessions.set(session.id, session as never);
return () => ctx.sessions.delete(session.id);
})
);
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
await rm(workingDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
const unwrap = (raw: string) => {
const parsed = JSON.parse(raw);
return parsed.data ?? parsed;
};
it('places the created session at the end of an existing group and returns the stored layout', async () => {
const first = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-x', mode: 'shell', workingDir },
});
const firstId = unwrap(first.body).session.id as string;
const owner = Object.keys(layouts)[0];
layouts[owner] = {
...layouts[owner],
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: firstId }] }],
ungrouped: [],
};
const res = await app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w2-x', mode: 'shell', workingDir, tabGroupId: 'g1' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.tabLayout).toEqual(layouts[owner]);
expect(data.tabLayout.version).toBe(layouts[owner].version);
expect(data.tabLayout.groups[0].refs).toEqual([
{ kind: 'session', id: firstId },
{ kind: 'session', id: data.session.id },
]);
expect(data.tabLayout.ungrouped).toEqual([]);
expect(order).toEqual([firstId, data.session.id]);
});
});
File diff suppressed because it is too large. Load diff
+75
View File
@@ -34,6 +34,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { MAX_WAIT_MS, MIN_WAIT_MS, MAX_WAITERS_TOTAL, MAX_WAITERS_PER_OWNER } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
@@ -463,6 +464,80 @@ describe('GET /api/sessions/:id/wait: modes that install no hooks', () => {
});
});
describe('GET /api/sessions/:id/wait: blocked read off a codex screen', () => {
/** The listener deps the screen-blocked raise reaches; nothing else matters here. */
function stubDeps() {
return {
broadcast: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
getRunSummaryTracker: vi.fn(() => undefined),
};
}
afterEach(() => {
approvalInbox.resolveForSession(SESSION_ID, 'session_ended');
});
it('accepts until=blocked for codex, whose dialogs are read off its screen', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=1000` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['blocked']);
});
it('answers blocked at once for a dialog already on screen, and only to a caller who asked', async () => {
// A hook-raised blocked is an edge; this one is a level the server is still reading,
// so a caller who asks after the dialog appeared does not wait out its timeout.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
mode: string;
status: string;
screenBlocked?: boolean;
};
session.mode = 'codex';
session.status = 'idle';
session.screenBlocked = true;
const asked = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle,blocked` });
expect(asked.json().data.wait).toMatchObject({ signal: 'blocked', immediate: true });
// Such a pane is idle too, and a caller that asked for idle alone keeps getting idle.
const idle = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
});
it('wakes a parked until=blocked waiter when the session raises a screen-read dialog', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.mode = 'codex';
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
session.emit('blockedChanged', true, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
const body = (await pending).json();
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: false, timedOut: false });
expect(approvalInbox.getForSession(SESSION_ID)?.answerInTerminal).toBe(true);
// ...and the dialog leaving the screen resolves the item it opened.
session.emit('blockedChanged', false, null);
expect(approvalInbox.getForSession(SESSION_ID)).toBeUndefined();
});
it('still refuses until=stop for codex: nothing on a screen says a turn ended', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop,blocked` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Signal(s) stop never fire for codex');
});
});
describe('GET /api/sessions/:id/wait: liveness beats the reported status', () => {
it('answers exit for a session whose PTY is gone, not the idle its status claims', async () => {
// Session parks a dead PTY at status 'idle' and the object survives in the map,
+185 -3
View File
@@ -6,12 +6,15 @@
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
* and 400 on a malformed body.
* the prompt-cache group of the footer (warm and cold shapes of `prompt_cache`),
* and 400 on a malformed body. Also the session's model (`displayModel`) the route
* records off the same payload.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
import { SessionPromptCache, SessionStatusTelemetry } from '../../src/web/sse-events.js';
import { Session } from '../../src/session.js';
const SID = 'test-session-1'; // default id created by createMockRouteContext
@@ -95,8 +98,187 @@ describe('POST /api/status-telemetry', () => {
expect(payload.sevenDay).toBeUndefined();
});
it('prints the prompt-cache state in the footer: a clock time while warm, cold once Claude says so', async () => {
const expiresAtS = Math.floor(Date.now() / 1000) + 3600;
const warm = await post({
sessionId: SID,
data: {
...REAL,
prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, recache_tokens_if_cold: 109833 },
},
});
expect(warm.statusCode).toBe(200);
expect(warm.body).toMatch(/^Opus 4\.8 \(1M context\) in:562,411 out:1,188 ctx:56% cache:until \d\d:\d\d$/);
// The cold flip Claude sends when expires_at passes: null expiry, and a last_miss_cause
// that carries numeric detail keys beside `causes` (must not 400, which would blank the footer).
const cold = await post({
sessionId: SID,
data: {
...REAL,
prompt_cache: {
warm: false,
ttl: '1h',
expires_at: null,
misses: 1,
last_miss_cause: { causes: ['ttl_expired_1h'], system_char_delta: 0 },
},
},
});
expect(cold.statusCode).toBe(200);
expect(cold.body.endsWith(' cache:cold')).toBe(true);
// The report is kept on the session (toState / Session Options readout) and each CHANGE
// is broadcast on its own event; the plan chip did not change, so it broadcast once.
const session = h.ctx.sessions.get(SID) as unknown as { promptCache: { warm: boolean } | null };
expect(session.promptCache).toMatchObject({
warm: false,
ttl: '1h',
misses: 1,
lastMissCauses: ['ttl_expired_1h'],
});
const events = h.ctx.broadcast.mock.calls.map(([name]) => name);
expect(events.filter((e) => e === SessionStatusTelemetry)).toHaveLength(1);
expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(2);
expect(h.ctx.broadcast).toHaveBeenCalledWith(
SessionPromptCache,
expect.objectContaining({ sessionId: SID, warm: true, ttl: '1h', recacheTokensIfCold: 109833 })
);
});
it('does not rebroadcast an unchanged prompt-cache report', async () => {
const pc = { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 };
await post({ sessionId: SID, data: { ...REAL, prompt_cache: pc } });
await post({ sessionId: SID, data: { ...REAL, prompt_cache: pc } });
const events = h.ctx.broadcast.mock.calls.map(([name]) => name);
expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(1);
});
it('keeps the first 20 miss causes instead of refusing a post that carries more', async () => {
const causes = Array.from({ length: 25 }, (_, i) => `cause_${i}`);
const res = await post({
sessionId: SID,
data: { ...REAL, prompt_cache: { warm: false, ttl: '1h', last_miss_cause: { causes } } },
});
// Accepted, not 400: an over-long causes array must not blank the whole statusline.
expect(res.statusCode).toBe(200);
const session = h.ctx.sessions.get(SID) as unknown as { promptCache: { lastMissCauses?: string[] } | null };
expect(session.promptCache?.lastMissCauses).toHaveLength(20);
expect(session.promptCache?.lastMissCauses?.[0]).toBe('cause_0');
expect(session.promptCache?.lastMissCauses?.[19]).toBe('cause_19');
});
it('rejects a malformed body (missing sessionId) with 400', async () => {
const res = await post({ data: REAL });
expect(res.statusCode).toBe(400);
});
});
describe('POST /api/status-telemetry: the session model (displayModel)', () => {
let h: RouteTestHarness;
beforeEach(async () => {
h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID });
});
const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body });
/** A real claude session in the route's map, so displayModel is the shipped resolver's. */
function realSession(mode: 'claude' | 'codex', id: string): Session {
const session = new Session({ id, workingDir: '/tmp', mode } as ConstructorParameters<typeof Session>[0]);
(h.ctx.sessions as unknown as Map<string, unknown>).set(id, session);
return session;
}
it('records the model the statusline names, from the first render (no rate limits yet)', async () => {
const session = realSession('claude', 'claude-1');
const changed = vi.fn();
session.on('displayModelChanged', changed);
const res = await post({ sessionId: 'claude-1', data: { model: { display_name: 'Haiku 4.5' } } });
expect(res.statusCode).toBe(200);
expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' });
expect(changed).toHaveBeenCalledTimes(1);
});
it('follows a model switch, and says nothing for an unchanged one', async () => {
const session = realSession('claude', 'claude-2');
const changed = vi.fn();
session.on('displayModelChanged', changed);
await post({ sessionId: 'claude-2', data: REAL });
await post({ sessionId: 'claude-2', data: REAL });
expect(changed).toHaveBeenCalledTimes(1);
await post({ sessionId: 'claude-2', data: { ...REAL, model: { display_name: 'Sonnet 4.6' } } });
expect(session.toState().displayModel).toEqual({ model: 'Sonnet 4.6', source: 'statusline' });
expect(changed).toHaveBeenCalledTimes(2);
});
it('keeps a model name with markup as text, and a null name changes nothing', async () => {
const session = realSession('claude', 'claude-3');
await post({ sessionId: 'claude-3', data: { model: { display_name: '<b>Opus</b>' } } });
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
await post({ sessionId: 'claude-3', data: { model: { display_name: null } } });
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
});
it('takes no model from a CLI that has no statusline exporter', async () => {
const session = realSession('codex', 'codex-1');
await post({ sessionId: 'codex-1', data: { model: { display_name: 'Opus 4.8' } } });
expect(session.toState().displayModel).toBeUndefined();
});
it('stores no prompt-cache from a CLI with no statusline exporter, but still broadcasts plan usage', async () => {
const session = realSession('codex', 'codex-cache');
const res = await post({
sessionId: 'codex-cache',
data: { ...REAL, prompt_cache: { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 } },
});
expect(res.statusCode).toBe(200);
// Prompt cache is gated like the model report; the rest of the post still lands.
expect(session.toState().promptCache).toBeUndefined();
const events = h.ctx.broadcast.mock.calls.map(([name]) => name);
expect(events).not.toContain(SessionPromptCache);
expect(events).toContain(SessionStatusTelemetry);
});
it('broadcasts the STORED prompt-cache object, so a compaction stamp reaches the live readout', async () => {
const session = realSession('claude', 'claude-compact');
const expiresAtS = Math.floor(Date.now() / 1000) + 3600;
// The order Claude Code really reports in: a warm report with 500k at stake, then
// `/compact` finishes (recache null, expected_rebuilds unchanged), and the session stamps
// compactedAt in setPromptCache on entering that window.
await post({
sessionId: 'claude-compact',
data: {
prompt_cache: {
warm: true,
ttl: '1h',
expires_at: expiresAtS,
expected_rebuilds: 0,
recache_tokens_if_cold: 500000,
},
},
});
await post({
sessionId: 'claude-compact',
data: { prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, expected_rebuilds: 0 } },
});
expect(typeof session.toState().promptCache?.compactedAt).toBe('number');
// The broadcast must carry the stored object, not just the freshly parsed one, or the
// frontend's live update would never see the stamp (only a modal reopen would).
const last = (h.ctx.broadcast.mock.calls as [string, Record<string, unknown>][])
.filter(([name]) => name === SessionPromptCache)
.at(-1)?.[1];
expect(last).toMatchObject({ sessionId: 'claude-compact', warm: true });
expect(typeof last?.compactedAt).toBe('number');
});
it('persists a changed prompt-cache report, and only a changed one', async () => {
const session = realSession('claude', 'claude-persist');
const data = { prompt_cache: { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 } };
await post({ sessionId: 'claude-persist', data });
expect(h.ctx.persistSessionState).toHaveBeenCalledWith(session);
h.ctx.persistSessionState.mockClear();
await post({ sessionId: 'claude-persist', data });
expect(h.ctx.persistSessionState).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,297 @@
/**
* @fileoverview Multi-user mode: server-wide settings writes are admin-only.
*
* `~/.codeman/settings.json` is ONE file for the whole server (feature switches, the
* plan-usage collection switch, run mode, respawn presets, the tunnel). Before this,
* any signed-in NON-ADMIN could rewrite it through PUT /api/settings, although
* docs/multi-user-plan.md makes settings writes admin-only. Pinned here:
* - a non-admin PUT /api/settings is 403 FORBIDDEN before anything is parsed (an
* invalid body is still 403, not 400), written, toggled or started (watchers, the
* tunnel, the tmux history limit);
* - the same gate on the other server-wide writes: PUT /api/execution/model-config,
* PUT /api/config and PUT /api/cases/order;
* - an admin, and single-user mode, write as before; GET /api/settings stays readable,
* but a non-admin reads it without the admin's Deepgram key and case names
* (settingsForNonAdmin).
*
* Uses app.inject(), so no real HTTP port is needed. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes, settingsForNonAdmin } from '../../src/web/routes/system-routes.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
const { STORED, subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
const makeWatcher = (running: boolean) => {
let isOn = running;
return {
isRunning: vi.fn(() => isOn),
start: vi.fn(() => {
isOn = true;
}),
stop: vi.fn(() => {
isOn = false;
}),
getStats: vi.fn(() => ({})),
watchSession: vi.fn(),
getRecentRunSummaries: vi.fn(() => []),
__reset: () => {
isOn = running;
},
};
};
return {
// The persisted settings.json these tests start from.
STORED: { subagentTrackingEnabled: false, imageWatcherEnabled: true, cliManagementEnabled: false },
subagentWatcher: makeWatcher(false),
imageWatcher: makeWatcher(true),
workflowRunWatcher: makeWatcher(false),
};
});
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify(STORED)),
writeFile: vi.fn(async () => undefined),
// settings.json is written to a temp file and renamed over the real one (settings-writer.ts).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
});
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
import fs from 'node:fs/promises';
const writeFile = vi.mocked(fs.writeFile);
const readFile = vi.mocked(fs.readFile);
const NON_ADMIN = { username: 'bob', role: 'user' } as const;
const ADMIN = { username: 'root', role: 'admin' } as const;
function makeTunnelManager() {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => false),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running: false })),
};
}
/** A body that would touch everything PUT /api/settings can act on. */
const EVERYTHING = {
tunnelEnabled: true,
subagentTrackingEnabled: true,
imageWatcherEnabled: false,
showUltracodeAgents: true,
tmuxHistoryLimit: 50000,
cliManagementEnabled: true,
runMode: 'shell',
};
describe('server-wide settings writes are admin-only in multi-user mode', () => {
const savedMultiUser = process.env.CODEMAN_MULTIUSER;
let harness: RouteTestHarness | undefined;
let tunnel: ReturnType<typeof makeTunnelManager>;
async function as(user: typeof NON_ADMIN | typeof ADMIN | null, register = registerSystemRoutes) {
if (user) process.env.CODEMAN_MULTIUSER = '1';
else delete process.env.CODEMAN_MULTIUSER;
harness = await createRouteTestHarness(register, user ? { authUser: user } : undefined);
tunnel = makeTunnelManager();
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
return harness;
}
beforeEach(() => {
vi.clearAllMocks();
for (const w of [subagentWatcher, imageWatcher, workflowRunWatcher]) w.__reset();
});
afterEach(async () => {
await harness?.app.close();
harness = undefined;
if (savedMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMultiUser;
});
describe('PUT /api/settings', () => {
it('refuses a non-admin with 403 FORBIDDEN, and nothing is written, toggled or started', async () => {
const { app, ctx } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload: EVERYTHING });
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
for (const w of [subagentWatcher, imageWatcher, workflowRunWatcher]) {
expect(w.start).not.toHaveBeenCalled();
expect(w.stop).not.toHaveBeenCalled();
}
expect(tunnel.start).not.toHaveBeenCalled();
expect(ctx.mux.setHistoryLimit).not.toHaveBeenCalled();
});
it('refuses a non-admin before the body is even parsed (an invalid body is 403, not 400)', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload: { notASetting: 1 } });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
});
it('still lets a non-admin READ the settings', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual(STORED);
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`writes, reconciles watchers and starts the tunnel for ${label}`, async () => {
const { app, ctx } = await as(user);
// Single-user mode with no CODEMAN_PASSWORD needs the COD-55 tunnel acknowledgement
// (multi-user mode counts as authenticated); that guard is not what this file tests.
const payload = user ? EVERYTHING : { ...EVERYTHING, acknowledgeUnauthTunnel: true };
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload });
expect(res.statusCode).toBe(200);
expect(writeFile).toHaveBeenCalledTimes(1);
const written = JSON.parse(String(writeFile.mock.calls[0][1]));
expect(written).toMatchObject({ ...STORED, ...EVERYTHING });
expect(subagentWatcher.start).toHaveBeenCalledTimes(1);
expect(imageWatcher.stop).toHaveBeenCalledTimes(1);
expect(workflowRunWatcher.start).toHaveBeenCalledTimes(1);
expect(tunnel.start).toHaveBeenCalledTimes(1);
expect(ctx.mux.setHistoryLimit).toHaveBeenCalledTimes(1);
});
}
});
describe("GET /api/settings: what is the admin's stays the admin's", () => {
// An admin's App Settings save stores the Deepgram key (voiceSettings.apiKey) here, and
// the admin's own cases are named in caseOrder / lastUsedCase.
const WITH_SECRETS = {
autoNameSessions: true,
runMode: 'shell',
caseOrder: ['rootcase', 'secret-project'],
lastUsedCase: 'secret-project',
voiceSettings: { provider: 'deepgram', apiKey: 'dg-SECRET-probe-123', language: 'en' },
};
it('gives a non-admin the settings without the Deepgram key and the case names', async () => {
const { app } = await as(NON_ADMIN);
readFile.mockResolvedValueOnce(JSON.stringify(WITH_SECRETS));
const res = await app.inject({ method: 'GET', url: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(res.body).not.toContain('dg-SECRET-probe-123');
expect(res.body).not.toContain('secret-project');
expect(JSON.parse(res.body)).toEqual({
autoNameSessions: true,
runMode: 'shell',
voiceSettings: { provider: 'deepgram', language: 'en' },
});
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`gives ${label} the file as it is`, async () => {
const { app } = await as(user);
readFile.mockResolvedValueOnce(JSON.stringify(WITH_SECRETS));
const res = await app.inject({ method: 'GET', url: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual(WITH_SECRETS);
});
}
it('settingsForNonAdmin leaves its input alone and passes a non-object through', () => {
const input = structuredClone(WITH_SECRETS);
settingsForNonAdmin(input);
expect(input).toEqual(WITH_SECRETS);
expect(settingsForNonAdmin([1, 2])).toEqual([1, 2]);
expect(settingsForNonAdmin(null)).toBe(null);
expect(settingsForNonAdmin({ voiceSettings: { language: 'de' } })).toEqual({ voiceSettings: { language: 'de' } });
});
});
describe('PUT /api/execution/model-config', () => {
const config = { defaultModel: 'opus', agentTypeOverrides: { explore: 'sonnet' } };
it('refuses a non-admin with 403 FORBIDDEN and writes nothing', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/execution/model-config', payload: config });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
});
it('writes it for an admin', async () => {
const { app } = await as(ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/execution/model-config', payload: config });
expect(res.statusCode).toBe(200);
expect(JSON.parse(String(writeFile.mock.calls[0][1])).modelConfig).toEqual(config);
});
it('stays readable by a non-admin', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/execution/model-config' });
expect(res.statusCode).toBe(200);
});
});
describe('PUT /api/config', () => {
it('refuses a non-admin with 403 FORBIDDEN and changes nothing', async () => {
const { app, ctx } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/config', payload: { maxConcurrentSessions: 50 } });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(ctx.store.setConfig).not.toHaveBeenCalled();
});
it('updates it for an admin', async () => {
const { app, ctx } = await as(ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/config', payload: { maxConcurrentSessions: 10 } });
expect(res.statusCode).toBe(200);
expect(ctx.store.setConfig).toHaveBeenCalledTimes(1);
});
});
describe('PUT /api/cases/order (the one global caseOrder in settings.json)', () => {
it('refuses a non-admin with 403 FORBIDDEN and writes nothing', async () => {
const { app, ctx } = await as(NON_ADMIN, registerCaseRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/cases/order', payload: { order: ['b', 'a'] } });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
expect(ctx.broadcast).not.toHaveBeenCalled();
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`writes it for ${label}`, async () => {
const { app } = await as(user, registerCaseRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/cases/order', payload: { order: ['b', 'a'] } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(String(writeFile.mock.calls[0][1])).caseOrder).toEqual(['b', 'a']);
});
}
});
});
@@ -4,8 +4,8 @@
*
* PR #361 once made it reconcile `showPlanUsageLimits` on first read: if the
* key was absent, the route persisted `true`. But readJsonConfig() answers
* `{}` for ANY read failure (parse error, EACCES, EMFILE, a read landing inside
* PUT's non-atomic write), not only ENOENT, and every page load calls this
* `{}` for ANY read failure (parse error, EACCES, EMFILE, and at the time a read
* landing inside PUT's non-atomic write), not only ENOENT, and every page load calls this
* route, so one unlucky read replaced the whole settings file with a one-key
* file. The default now lives in the readers instead: an absent key means ON
* to readPlanUsageTelemetryEnabled() (pinned in test/hooks-config.test.ts) and
@@ -47,18 +47,34 @@ const { EXISTING_SETTINGS, subagentWatcher, imageWatcher, workflowRunWatcher } =
};
};
return {
// Persisted settings.json for these tests: two watchers ON, subagent tracking OFF.
EXISTING_SETTINGS: { subagentTrackingEnabled: false, imageWatcherEnabled: true, showUltracodeAgents: true },
// Persisted settings.json for these tests: two watchers ON, subagent tracking OFF,
// agent inbox snapshot ON.
EXISTING_SETTINGS: {
subagentTrackingEnabled: false,
imageWatcherEnabled: true,
showUltracodeAgents: true,
promptCacheKeepWarm: false,
promptCacheKeepWarmMaxHours: 2,
agentInboxPersistEnabled: true,
},
subagentWatcher: makeWatcher(false),
imageWatcher: makeWatcher(true),
workflowRunWatcher: makeWatcher(true),
};
});
// setEnabled(false) deletes the snapshot file, so a wrong default here loses mail.
const { agentInboxPersistence } = vi.hoisted(() => ({ agentInboxPersistence: { setEnabled: vi.fn() } }));
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify(EXISTING_SETTINGS)),
writeFile: vi.fn(async () => undefined),
// settings.json is written to a temp file and renamed over the real one (settings-writer.ts).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
@@ -70,6 +86,7 @@ vi.mock('node:fs', async (importOriginal) => {
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
vi.mock('../../src/web/agent-inbox-persistence.js', () => ({ agentInboxPersistence }));
describe('PUT /api/settings — partial body must not reset service toggles', () => {
let harness: RouteTestHarness;
@@ -81,6 +98,7 @@ describe('PUT /api/settings — partial body must not reset service toggles', ()
w.stop.mockClear();
w.__resetRunning(); // running state, not just call records — see makeWatcher
}
agentInboxPersistence.setEnabled.mockClear();
});
afterEach(async () => {
@@ -143,4 +161,43 @@ describe('PUT /api/settings — partial body must not reset service toggles', ()
// showUltracodeAgents is still true in settings.json, so it stays up.
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
});
/**
* The browser sends both keep-warm keys on every App Settings save, so testing for the keys in
* the body re-read the settings and re-evaluated every session on each save of anything.
*/
it('re-evaluates the prompt-cache keep-warm only when its default or cap changes', async () => {
const put = (payload: Record<string, unknown>) =>
harness.app.inject({ method: 'PUT', url: '/api/settings', payload });
expect((await put({ promptCacheKeepWarm: false, promptCacheKeepWarmMaxHours: 2 })).statusCode).toBe(200);
expect(harness.ctx.keepWarm.rescheduleAll).not.toHaveBeenCalled();
expect((await put({ promptCacheKeepWarm: false, promptCacheKeepWarmMaxHours: 3 })).statusCode).toBe(200);
expect(harness.ctx.keepWarm.rescheduleAll).toHaveBeenCalledTimes(1);
expect((await put({ promptCacheKeepWarm: true })).statusCode).toBe(200);
expect(harness.ctx.keepWarm.rescheduleAll).toHaveBeenCalledTimes(2);
});
it('keeps the agent inbox snapshot on when the body omits its key', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { showPlanUsageLimits: true },
});
expect(res.statusCode).toBe(200);
expect(agentInboxPersistence.setEnabled).toHaveBeenCalledWith(true);
expect(agentInboxPersistence.setEnabled).not.toHaveBeenCalledWith(false);
});
it('switches the agent inbox snapshot off when the body says so', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { agentInboxPersistEnabled: false },
});
expect(res.statusCode).toBe(200);
expect(agentInboxPersistence.setEnabled).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,215 @@
/**
* @fileoverview Multi-user mode: the server-wide system operations are admin-only.
*
* docs/multi-user-plan.md lists the system operations (self-update, the tunnel,
* span-displays) as admin-only, but these routes had no role check, so any signed-in
* NON-ADMIN could run them. Pinned here:
* - POST /api/system/update, POST /api/system/span-displays, POST /api/tunnel/qr/regenerate
* and POST /api/cleanup-state answer 403 FORBIDDEN to a non-admin, and nothing runs;
* - POST /api/auth/revoke: a non-admin revokes only their OWN login sessions (the bare
* form used to clear every session on the server, every admin's included), and a named
* token only when it is one of theirs;
* - GET /api/tunnel/info lists only the caller's own logins for a non-admin;
* - an admin, and single-user mode, behave as before.
*
* Uses app.inject(), so no real HTTP port is needed. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { StaleExpirationMap } from '../../src/utils/stale-expiration-map.js';
import type { AuthSessionRecord } from '../../src/web/ports/auth-port.js';
const { startUpdate } = vi.hoisted(() => ({
startUpdate: vi.fn(async () => ({ ok: true, updateId: 'u1', toTag: 'v9.9.9', toVersion: '9.9.9' })),
}));
vi.mock('../../src/web/self-update.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/web/self-update.js')>();
return { ...actual, startUpdate };
});
const NON_ADMIN = { username: 'bob', role: 'user' } as const;
const ADMIN = { username: 'root', role: 'admin' } as const;
function makeTunnelManager() {
return {
regenerateQrToken: vi.fn(),
isRunning: vi.fn(() => true),
getUrl: vi.fn(() => 'https://example.trycloudflare.com'),
getStatus: vi.fn(() => ({ running: true, url: 'https://example.trycloudflare.com' })),
};
}
function record(username: string | undefined, ip: string): AuthSessionRecord {
return {
ip,
ua: 'test-agent',
createdAt: 1,
method: 'basic',
username,
role: username === 'root' ? 'admin' : 'user',
};
}
describe('server-wide system operations are admin-only in multi-user mode', () => {
const savedMultiUser = process.env.CODEMAN_MULTIUSER;
let harness: RouteTestHarness | undefined;
let tunnel: ReturnType<typeof makeTunnelManager>;
let sessions: StaleExpirationMap<string, AuthSessionRecord>;
async function as(user: typeof NON_ADMIN | typeof ADMIN | null) {
if (user) process.env.CODEMAN_MULTIUSER = '1';
else delete process.env.CODEMAN_MULTIUSER;
harness = await createRouteTestHarness(registerSystemRoutes, user ? { authUser: user } : undefined);
tunnel = makeTunnelManager();
sessions = new StaleExpirationMap<string, AuthSessionRecord>({ ttlMs: 60_000 });
// Two logins of the non-admin, one of an admin, one single-user (no identity).
sessions.set('bob-laptop', record('bob', '10.0.0.2'));
sessions.set('bob-phone', record('bob', '10.0.0.3'));
sessions.set('root-desk', record('root', '10.0.0.1'));
Object.assign(harness.ctx as unknown as Record<string, unknown>, { tunnelManager: tunnel, authSessions: sessions });
return harness;
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(async () => {
await harness?.app.close();
harness = undefined;
sessions?.dispose();
if (savedMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMultiUser;
});
const forbidden = (res: { statusCode: number; body: string }) => {
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('FORBIDDEN');
};
describe('POST /api/system/update', () => {
it('refuses a non-admin, and no update starts', async () => {
const { app } = await as(NON_ADMIN);
forbidden(await app.inject({ method: 'POST', url: '/api/system/update', payload: {} }));
expect(startUpdate).not.toHaveBeenCalled();
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`starts it for ${label}`, async () => {
const { app } = await as(user);
const res = await app.inject({ method: 'POST', url: '/api/system/update', payload: {} });
expect(res.statusCode).toBe(200);
expect(startUpdate).toHaveBeenCalledTimes(1);
});
}
});
describe('POST /api/system/span-displays', () => {
it('refuses a non-admin before anything else', async () => {
const { app } = await as(NON_ADMIN);
forbidden(await app.inject({ method: 'POST', url: '/api/system/span-displays' }));
});
it('lets an admin through the gate (off macOS the route then answers its own 400)', async () => {
const { app } = await as(ADMIN);
const res = await app.inject({ method: 'POST', url: '/api/system/span-displays' });
if (process.platform === 'darwin') expect(res.statusCode).not.toBe(403);
else expect(res.statusCode).toBe(400);
});
});
describe('POST /api/tunnel/qr/regenerate', () => {
it("refuses a non-admin, and nobody's QR codes are cleared", async () => {
const { app } = await as(NON_ADMIN);
forbidden(await app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' }));
expect(tunnel.regenerateQrToken).not.toHaveBeenCalled();
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`regenerates for ${label}`, async () => {
const { app } = await as(user);
const res = await app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' });
expect(res.statusCode).toBe(200);
expect(tunnel.regenerateQrToken).toHaveBeenCalledTimes(1);
});
}
});
describe('POST /api/cleanup-state', () => {
it("refuses a non-admin, and no user's session records are touched", async () => {
const { app, ctx } = await as(NON_ADMIN);
forbidden(await app.inject({ method: 'POST', url: '/api/cleanup-state' }));
expect(ctx.tabLayouts.runStaleSessionCleanup).not.toHaveBeenCalled();
expect(ctx.store.cleanupSessionsByIds).not.toHaveBeenCalled();
});
it('runs for an admin', async () => {
const { app, ctx } = await as(ADMIN);
const res = await app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(res.statusCode).toBe(200);
expect(ctx.tabLayouts.runStaleSessionCleanup).toHaveBeenCalledTimes(1);
});
});
describe('POST /api/auth/revoke', () => {
const tokens = () => [...sessions.keys()].sort();
it('the bare form signs a non-admin out of their own sessions only, never anyone else', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: {} });
expect(res.statusCode).toBe(200);
expect(tokens()).toEqual(['root-desk']);
});
it("a non-admin's named token is revoked only when it is theirs", async () => {
const { app } = await as(NON_ADMIN);
await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: { sessionToken: 'root-desk' } });
expect(tokens()).toEqual(['bob-laptop', 'bob-phone', 'root-desk']);
await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: { sessionToken: 'bob-phone' } });
expect(tokens()).toEqual(['bob-laptop', 'root-desk']);
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`the bare form still clears every session for ${label}, a named token any session`, async () => {
const { app } = await as(user);
await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: { sessionToken: 'bob-phone' } });
expect(tokens()).toEqual(['bob-laptop', 'root-desk']);
const res = await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: {} });
expect(res.statusCode).toBe(200);
expect(tokens()).toEqual([]);
});
}
});
describe('GET /api/tunnel/info', () => {
const ips = (res: { body: string }) =>
(JSON.parse(res.body).authSessions as { ip: string }[]).map((s) => s.ip).sort();
it("lists only the non-admin's own logins", async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/tunnel/info' });
expect(res.statusCode).toBe(200);
expect(ips(res)).toEqual(['10.0.0.2', '10.0.0.3']);
});
it('lists every login for an admin', async () => {
const { app } = await as(ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/tunnel/info' });
expect(ips(res)).toEqual(['10.0.0.1', '10.0.0.2', '10.0.0.3']);
});
});
});
+296 -98
View File
@@ -1,25 +1,21 @@
/**
* @fileoverview COD-55 — tunnel password guard.
*
* Enabling the Cloudflare tunnel publishes the whole app (full terminal control =
* effectively RCE) to a public *.trycloudflare.com URL. When no CODEMAN_PASSWORD
* is set, requests through that URL are unauthenticated. These tests assert the
* PUT /api/settings tunnel-enable path REFUSES to start the tunnel unless a
* password is set OR the unauthenticated-network opt-in is acknowledged.
*
* Uses app.inject() — no real HTTP ports needed. Port: N/A.
* @fileoverview Issue #706 keeps LAN acknowledgement separate from public tunnel consent. A login, tunnel-specific env opt-in, launch flag or per-request acknowledgement permits enabling. Early refusals persist nothing; late race refusals save unrelated settings without enabling the tunnel. Status reports login configuration independently of exposure opt-ins.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
// Settings are written to disk via fs/promises — stub so the guard test never
// Settings are written to disk via fs/promises; stub so the guard test never
// touches the real settings.json, and so we can assert "not persisted on refusal".
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => undefined),
// settings.json is written to a temp file and renamed over the real one (settings-writer.ts).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
@@ -34,129 +30,331 @@ vi.mock('node:fs', async (importOriginal) => {
});
import fs from 'node:fs/promises';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
const mockedWriteFile = vi.mocked(fs.writeFile);
const mockedRename = vi.mocked(fs.rename);
/** Build a tunnelManager stub the route's ctx can use. */
function makeTunnelManager(running = false) {
/**
* settings.json writes as [path, contents]. The writer (settings-writer.ts) writes each one to a
* temp file and renames it over settings.json, so a write counts once its rename lands. Every
* writeFile must be such a temp file and every one must be renamed into place: a direct write to
* settings.json, or any other write that was never renamed, fails here rather than going unseen.
*/
function settingsWrites(): [string, string][] {
const writes = mockedWriteFile.mock.calls.map(([path, contents]) => [String(path), String(contents)]);
const renames = mockedRename.mock.calls.map(([from, to]) => [String(from), String(to)]);
const isTemp = (path: string) => path.startsWith(`${SETTINGS_PATH}.`) && path.endsWith('.tmp');
const notRenamed = writes.filter(([path]) => !isTemp(path) || !renames.some(([from]) => from === path));
expect(
notRenamed.map(([path]) => path),
'writes that were not a temp file renamed into place'
).toEqual([]);
expect(renames.length, 'one rename per write').toBe(writes.length);
return renames.map(([from, to]) => {
const write = writes.find(([path]) => path === from);
expect(write, `rename of ${from}, which was never written`).toBeDefined();
return [to, String(write?.[1])];
});
}
const STATUS = { running: false, url: null };
function makeTunnelManager() {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => running),
isRunning: vi.fn(() => false),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running })),
getStatus: vi.fn(() => ({ ...STATUS })),
};
}
describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => {
describe('tunnel authentication policy', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
const savedPassword = process.env.CODEMAN_PASSWORD;
const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
const envKeys = [
'CODEMAN_PASSWORD',
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL',
'CODEMAN_MULTIUSER',
] as const;
let savedEnv: (string | undefined)[];
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
savedEnv = envKeys.map((key) => process.env[key]);
for (const key of envKeys) delete process.env[key];
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'root', role: 'admin' },
});
vi.clearAllMocks();
vi.mocked(fs.readFile).mockResolvedValue('{}');
mockedWriteFile.mockResolvedValue(undefined);
tunnel = makeTunnelManager(false);
// tunnelManager is null in the default mock ctx — inject our spy.
tunnel = makeTunnelManager();
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false;
});
afterEach(async () => {
await harness.app.close();
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
else process.env.CODEMAN_PASSWORD = savedPassword;
if (savedOptIn === undefined) delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
else process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = savedOptIn;
try {
await harness?.app.close();
} finally {
envKeys.forEach((key, i) => {
if (savedEnv[i] === undefined) delete process.env[key];
else process.env[key] = savedEnv[i];
});
}
});
it('REFUSES tunnel-enable with no password and no opt-in (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
const put = (payload: Record<string, boolean> = { tunnelEnabled: true }) =>
harness.app.inject({ method: 'PUT', url: '/api/settings', payload });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
// Message should tell the user how to fix it.
expect(body.error).toMatch(/CODEMAN_PASSWORD|CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK/);
// The tunnel must NOT have been started.
expect(tunnel.start).not.toHaveBeenCalled();
// And tunnelEnabled:true must NOT have been persisted.
expect(mockedWriteFile).not.toHaveBeenCalled();
});
it('ALLOWS tunnel-enable when CODEMAN_PASSWORD is set (start called, 200)', async () => {
process.env.CODEMAN_PASSWORD = 'hunter2';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
function expectRefused(res: Awaited<ReturnType<typeof put>>) {
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json()).toEqual({
success: false,
errorCode: 'OPERATION_FAILED',
error: expect.any(String),
});
expect.soft(tunnel.start.mock.calls).toEqual([]);
expect.soft(mockedWriteFile.mock.calls).toEqual([]);
}
function expectEnabled(res: Awaited<ReturnType<typeof put>>) {
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]);
expect(tunnel.stop.mock.calls).toEqual([]);
expect(settingsWrites()).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: true }, null, 2)]]);
}
it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', async () => {
it('refuses the incident: network acknowledgement does not authorize a public tunnel', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
const res = await put();
expectRefused(res);
expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK');
expect.soft(res.json().error).toMatch(/no longer/i);
});
it('ALLOWS tunnel-enable with per-request acknowledgeUnauthTunnel:true (start called, 200, flag not persisted)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
// The action flag must NOT be persisted to settings.json.
expect(mockedWriteFile).toHaveBeenCalled();
const persisted = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(persisted.acknowledgeUnauthTunnel).toBeUndefined();
expect(persisted.tunnelEnabled).toBe(true);
it('refuses without credentials or acknowledgement and names the tunnel opt-in', async () => {
const res = await put();
expectRefused(res);
expect(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
});
it('still REFUSES when acknowledgeUnauthTunnel is false (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: false },
});
it.each(['1', 'true', 'yes', 'on', ' TRUE '])(
'allows tunnel env value %j without an acknowledgement',
async (value) => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value;
expectEnabled(await put());
}
);
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
expect(tunnel.start).not.toHaveBeenCalled();
expect(mockedWriteFile).not.toHaveBeenCalled();
it('allows the route context flag without an env opt-in or acknowledgement', async () => {
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true;
expectEnabled(await put());
});
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
tunnel = makeTunnelManager(true);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
it('allows acknowledgement alone and persists only tunnelEnabled', async () => {
expectEnabled(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }));
});
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: false },
});
it('refuses a false acknowledgement', async () => {
expectRefused(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: false }));
});
it('allows a configured password without acknowledgement', async () => {
process.env.CODEMAN_PASSWORD = 'test-password';
expectEnabled(await put());
});
it('allows a multi-user admin without acknowledgement', async () => {
process.env.CODEMAN_MULTIUSER = '1';
expectEnabled(await put());
});
it.each(['0', 'false', '', 'enabled'])('refuses non-true tunnel env value %j', async (value) => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value;
expectRefused(await put());
});
it('does not guard disabling a running tunnel', async () => {
tunnel.isRunning.mockReturnValue(true);
const res = await put({ tunnelEnabled: false });
expect(res.statusCode).toBe(200);
expect(tunnel.stop).toHaveBeenCalledTimes(1);
expect(tunnel.start.mock.calls).toEqual([]);
expect(tunnel.stop.mock.calls).toEqual([[]]);
expect(settingsWrites()).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: false }, null, 2)]]);
});
it.each([
['password', 'CODEMAN_PASSWORD', 'test-password', false, true],
['multi-user', 'CODEMAN_MULTIUSER', '1', false, true],
['neither', undefined, undefined, false, false],
['empty password', 'CODEMAN_PASSWORD', '', false, false],
['network opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', '1', false, false],
['tunnel opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', '1', false, false],
['tunnel flag only', undefined, undefined, true, false],
] as const)('status reports loginConfigured for %s', async (_label, key, value, flag, loginConfigured) => {
if (key !== undefined) process.env[key] = value;
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = flag;
const res = await harness.app.inject({ method: 'GET', url: '/api/tunnel/status' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ...STATUS, loginConfigured });
expect(tunnel.getStatus.mock.calls).toEqual([[]]);
expect(mockedWriteFile.mock.calls).toEqual([]);
});
});
describe('tunnel enable authorization across a concurrent stop', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
let running: boolean;
let stopDuringWrite: boolean;
let afterWrite: (() => void) | undefined;
let storedSettings: Record<string, boolean>;
const envKeys = [
'CODEMAN_PASSWORD',
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL',
'CODEMAN_MULTIUSER',
] as const;
let savedEnv: (string | undefined)[];
beforeEach(async () => {
savedEnv = envKeys.map((key) => process.env[key]);
for (const key of envKeys) delete process.env[key];
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'root', role: 'admin' },
});
vi.clearAllMocks();
running = true;
stopDuringWrite = true;
afterWrite = undefined;
storedSettings = { tunnelEnabled: false, subagentTrackingEnabled: false };
tunnel = makeTunnelManager();
tunnel.isRunning.mockImplementation(() => running);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false;
vi.mocked(fs.readFile).mockImplementation(async (path) => {
expect(path).toBe(SETTINGS_PATH);
return JSON.stringify(storedSettings);
});
const pending = new Map<string, string>();
const store = (contents: string) => {
storedSettings = JSON.parse(contents);
// The stop becomes visible during the awaited write, regardless of isRunning() call count.
if (stopDuringWrite) running = false;
afterWrite?.();
};
mockedWriteFile.mockImplementation(async (path, contents) => {
// A direct write to settings.json lands at once, so the stored file is what the route really left;
// settingsWrites() then fails it for not going through a temp file and a rename.
if (path === SETTINGS_PATH) store(contents as string);
else pending.set(String(path), contents as string);
});
mockedRename.mockImplementation(async (from, to) => {
expect(to).toBe(SETTINGS_PATH);
store(pending.get(String(from)) as string);
});
});
afterEach(async () => {
try {
await harness?.app.close();
} finally {
envKeys.forEach((key, i) => {
if (savedEnv[i] === undefined) delete process.env[key];
else process.env[key] = savedEnv[i];
});
vi.mocked(fs.readFile).mockResolvedValue('{}');
mockedWriteFile.mockResolvedValue(undefined);
mockedRename.mockResolvedValue(undefined);
}
});
const put = (acknowledgement: Record<string, boolean> = {}) =>
harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, autoNameSessions: false, ...acknowledgement },
});
function expectWritten(tunnelEnabled: boolean) {
const expected = { tunnelEnabled, subagentTrackingEnabled: false, autoNameSessions: false };
// Check every write so a transient unauthorized enable followed by a rollback also fails.
expect
.soft(settingsWrites().map(([path, contents]) => ({ path, settings: JSON.parse(contents) })))
.toEqual([{ path: SETTINGS_PATH, settings: expected }]);
expect.soft(storedSettings).toEqual(expected);
}
function expectLateRefusal(res: Awaited<ReturnType<typeof put>>) {
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json()).toEqual({
success: false,
errorCode: 'OPERATION_FAILED',
error: expect.any(String),
});
expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
expect.soft(res.json().error).toMatch(/not (re)?start/i);
expect.soft(tunnel.start.mock.calls).toEqual([]);
expect.soft(tunnel.stop.mock.calls).toEqual([]);
expectWritten(false);
}
function expectAuthorizedRestart(res: Awaited<ReturnType<typeof put>>) {
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]);
expect(tunnel.stop.mock.calls).toEqual([]);
expectWritten(true);
}
it('refuses an unauthorized restart after a concurrent stop while saving other settings', async () => {
expectLateRefusal(await put());
});
it.each(['password', 'tunnel env', 'route flag', 'per-request acknowledgement', 'multi-user admin'] as const)(
'allows a restart with %s authorization',
async (authorization) => {
if (authorization === 'password') process.env.CODEMAN_PASSWORD = 'test-password';
if (authorization === 'tunnel env') process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1';
if (authorization === 'route flag') {
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true;
}
if (authorization === 'multi-user admin') process.env.CODEMAN_MULTIUSER = '1';
expectAuthorizedRestart(
await put(authorization === 'per-request acknowledgement' ? { acknowledgeUnauthTunnel: true } : {})
);
}
);
it.each([false, true])(
'preserves stored tunnelEnabled=%s when an unauthorized tunnel stays running',
async (tunnelEnabled) => {
stopDuringWrite = false;
storedSettings.tunnelEnabled = tunnelEnabled;
const res = await put();
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(tunnel.start.mock.calls).toEqual([]);
expect(tunnel.stop.mock.calls).toEqual([]);
expectWritten(tunnelEnabled);
}
);
it('does not acquire restart authorization from an env opt-in added during the write', async () => {
afterWrite = () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1';
};
expectLateRefusal(await put());
});
it('retains the request authorization when the env opt-in is removed during the write', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1';
afterWrite = () => {
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL;
};
expectAuthorizedRestart(await put());
});
});
+133
View File
@@ -11,6 +11,10 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { agentInbox } from '../../src/web/agent-inbox.js';
const hostSensors = vi.hoisted(() => ({ readHostSensors: vi.fn(async (_request: unknown) => ({})) }));
vi.mock('../../src/host-sensors.js', () => hostSensors);
// ── Mocks ──────────────────────────────────────────────────────────
@@ -18,6 +22,11 @@ vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => undefined),
// settings.json is written to a temp file and renamed over the real one (settings-writer.ts).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
@@ -326,6 +335,20 @@ describe('system-routes', () => {
expect(body).toHaveProperty('cpu');
expect(body).toHaveProperty('memory');
});
it('adds no GPU or clock readings unless they are asked for', async () => {
const body = JSON.parse((await harness.app.inject({ method: 'GET', url: '/api/system/stats' })).body);
expect(body).not.toHaveProperty('gpu');
expect(body).not.toHaveProperty('cpuMhz');
});
it('passes only the requested readings on to the sensor reader', async () => {
hostSensors.readHostSensors.mockClear();
hostSensors.readHostSensors.mockResolvedValueOnce({ cpuMhz: 3200 });
const res = await harness.app.inject({ method: 'GET', url: '/api/system/stats?cpumhz=1&gpu=0' });
expect(hostSensors.readHostSensors).toHaveBeenCalledWith({ gpu: false, cpuMhz: true });
expect(JSON.parse(res.body).cpuMhz).toBe(3200);
});
});
// ========== POST /api/cleanup-state ==========
@@ -343,6 +366,73 @@ describe('system-routes', () => {
expect(harness.ctx.store.cleanupSessionsByIds).toHaveBeenCalledWith(new Set());
expect(runStaleSessionCleanup).toHaveBeenCalledOnce();
});
it('drops the agent inbox of every record it removes, and only those', async () => {
agentInbox.resetForTests();
agentInbox.post('stale-detached', null, 'mail for a detached session');
agentInbox.post('still-kept', null, 'mail for a session that stays');
harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn(
async (_activeIds: Set<string>, action: (ids: ReadonlySet<string>) => unknown) =>
action(new Set(['stale-detached']))
);
vi.mocked(harness.ctx.store.cleanupSessionsByIds).mockReturnValueOnce({
count: 1,
cleaned: [{ id: 'stale-detached', name: 'w9-gone' }],
});
try {
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).cleanedSessions).toBe(1);
expect(agentInbox.pendingCount('stale-detached')).toBe(0);
expect(agentInbox.pendingCount('still-kept')).toBe(1);
} finally {
agentInbox.resetForTests();
}
});
it('keeps the agent inbox of a removed record whose tmux session the multiplexer still lists', async () => {
// Detach keeps the tmux session and its mux entry; the next boot re-adopts it
// under the same id even without a state.json record, so its mail must survive.
agentInbox.resetForTests();
agentInbox.post('stale-detached', null, 'mail for a session nothing can bring back');
agentInbox.post('pane-still-alive', null, 'mail for a detached pane that boot re-adopts');
harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn(
async (_activeIds: Set<string>, action: (ids: ReadonlySet<string>) => unknown) =>
action(new Set(['stale-detached', 'pane-still-alive']))
);
vi.mocked(harness.ctx.store.cleanupSessionsByIds).mockReturnValueOnce({
count: 2,
cleaned: [
{ id: 'stale-detached', name: 'w9-gone' },
{ id: 'pane-still-alive', name: 'w8-detached' },
],
});
// The harness is rebuilt per test, so this mock does not leak into the next one.
vi.mocked(harness.ctx.mux.getSession).mockImplementation(((id: string) =>
id === 'pane-still-alive' ? { sessionId: id } : null) as unknown as typeof harness.ctx.mux.getSession);
try {
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).cleanedSessions).toBe(2);
expect(agentInbox.pendingCount('stale-detached')).toBe(0);
expect(agentInbox.pendingCount('pane-still-alive')).toBe(1);
} finally {
agentInbox.resetForTests();
}
});
it('deletes the edit history of every session it cleaned (diffs can hold secrets)', async () => {
harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn(async () => ({
count: 2,
cleaned: [
{ id: 'stale-1', name: 'a' },
{ id: 'stale-2', name: 'b' },
],
})) as never;
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(JSON.parse(res.body).cleanedSessions).toBe(2);
expect(vi.mocked(harness.ctx.forgetEditHistory).mock.calls).toEqual([['stale-1'], ['stale-2']]);
});
});
// ========== GET /api/subagents ==========
@@ -680,6 +770,49 @@ describe('system-routes', () => {
});
});
describe('/api/screenshots deprecation', () => {
it('warns once across requests and leaves the response unchanged', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
mockedExistsSync.mockReturnValue(false);
const first = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
const second = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
expect(JSON.parse(first.body)).toEqual({ files: [] });
expect(JSON.parse(second.body)).toEqual({ files: [] });
const deprecations = warn.mock.calls.filter((c) => String(c[0]).includes('/api/screenshots'));
expect(deprecations).toHaveLength(1);
expect(String(deprecations[0][0])).toContain('POST /api/sessions/:id/paste-image');
} finally {
warn.mockRestore();
}
});
// One request per route on the fresh per-test harness, so each warn call is pinned on its own
// (the once-flag would let the first route's warning mask a missing call on the others).
it.each([
{ label: 'GET /api/screenshots', method: 'GET' as const, url: '/api/screenshots' },
{
label: 'POST /api/screenshots',
method: 'POST' as const,
url: '/api/screenshots',
payload: { file: 'data' },
headers: { 'content-type': 'application/json' },
},
{ label: 'GET /api/screenshots/:name', method: 'GET' as const, url: '/api/screenshots/nonexistent.png' },
])('$label warns that it is deprecated', async ({ method, url, payload, headers }) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
mockedExistsSync.mockReturnValue(false);
await harness.app.inject({ method, url, payload, headers });
const deprecations = warn.mock.calls.filter((c) => String(c[0]).includes('/api/screenshots'));
expect(deprecations).toHaveLength(1);
expect(String(deprecations[0][0])).toContain('POST /api/sessions/:id/paste-image');
} finally {
warn.mockRestore();
}
});
});
// ========== GET /api/screenshots/:name ==========
describe('GET /api/screenshots/:name', () => {
+9 -5
View File
@@ -11,7 +11,7 @@
* - the socket refuses exactly what the status endpoint calls unavailable,
* - a cross-site upgrade cannot open a stream on the operator's subscription.
*
* Port: 3230 (routes), 3231 (mock upstream)
* Port: ephemeral (`port: 0` for the routes and the mock upstream)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
@@ -20,6 +20,7 @@ import fastifyWebsocket from '@fastify/websocket';
import WebSocket, { WebSocketServer } from 'ws';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import type { AddressInfo } from 'node:net';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { registerVoiceRoutes, _resetVoiceStreamCountForTesting } from '../../src/web/routes/voice-routes.js';
import { MAX_CONCURRENT_STREAMS } from '../../src/config/voice.js';
@@ -47,8 +48,9 @@ function removeCredentials(): void {
rmSync(join(testHome(), '.claude', '.credentials.json'), { force: true });
}
const PORT = 3230;
const UPSTREAM_PORT = 3231;
/** Both assigned by the OS on every listen (`port: 0`); see beforeEach. */
let PORT = 0;
let UPSTREAM_PORT = 0;
const TOKEN = 'sk-ant-oat01-voice-route-test';
/** State captured by the mock upstream, so tests can assert what Codeman sent. */
@@ -118,7 +120,7 @@ describe('voice-routes', () => {
voiceEnabled = true;
capture = { headers: {}, url: '', binaryFrames: [], textFrames: [], socket: null };
upstream = new WebSocketServer({ port: UPSTREAM_PORT, host: '127.0.0.1' });
upstream = new WebSocketServer({ port: 0, host: '127.0.0.1' });
upstream.on('connection', (socket, req) => {
capture.headers = req.headers;
capture.url = req.url ?? '';
@@ -129,6 +131,7 @@ describe('voice-routes', () => {
});
});
await new Promise<void>((resolve) => upstream.once('listening', resolve));
UPSTREAM_PORT = (upstream.address() as AddressInfo).port;
process.env.CODEMAN_VOICE_STREAM_BASE = `ws://127.0.0.1:${UPSTREAM_PORT}`;
writeCredentials(Date.now() + 3_600_000);
@@ -138,7 +141,8 @@ describe('voice-routes', () => {
ctx = createMockRouteContext();
ctx.getClaudeVoiceEnabled = (async () => voiceEnabled) as typeof ctx.getClaudeVoiceEnabled;
registerVoiceRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
await app.listen({ port: PORT, host: '127.0.0.1' });
await app.listen({ port: 0, host: '127.0.0.1' });
PORT = (app.server.address() as AddressInfo).port;
});
afterEach(async () => {
+170
View File
@@ -0,0 +1,170 @@
/**
* @fileoverview /api/webhook: the webhook-notification config. The URL is a bearer secret, so it
* is never returned and the routes are admin only in multi-user mode.
* Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerWebhookRoutes } from '../../src/web/routes/webhook-routes.js';
import { readWebhookConfig, webhookConfigPath, WebhookNotifier, type WebhookFetch } from '../../src/webhook-notify.js';
const SECRET_URL = 'https://hooks.slack.com/services/T0/B0/SUPERSECRET';
let dir: string;
let fetchImpl: ReturnType<typeof vi.fn<WebhookFetch>>;
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
const notifier = new WebhookNotifier(() => readWebhookConfig(dir), fetchImpl);
const h = await createRouteTestHarness(
(app) => registerWebhookRoutes(app, { notifier, configDir: dir, hostTitle: () => 'codeman:test' }),
authUser ? { authUser } : undefined
);
return { ...h, notifier };
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'webhook-routes-'));
fetchImpl = vi.fn<WebhookFetch>(async () => new Response('', { status: 200 }));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('GET /api/webhook', () => {
it('starts disabled with no URL', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/webhook' });
expect(res.json().data).toEqual({
enabled: false,
kind: 'ntfy',
scope: 'attention',
hasUrl: false,
urlMasked: '',
lastResult: null,
});
});
});
describe('PUT /api/webhook', () => {
it('saves the config, masks the URL in every response, and writes the file 0600', async () => {
const { app } = await harness();
const put = await app.inject({
method: 'PUT',
url: '/api/webhook',
payload: { enabled: true, kind: 'slack', scope: 'all', url: SECRET_URL },
});
expect(put.statusCode).toBe(200);
expect(put.json().data).toMatchObject({ enabled: true, kind: 'slack', scope: 'all', hasUrl: true });
expect(put.json().data.urlMasked).toBe('https://hooks.slack.com/•••');
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
for (const body of [put.body, get.body]) expect(body).not.toMatch(/SUPERSECRET|T0\/B0/);
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
});
it('changing kind or scope keeps the saved URL (the secret is never re-sent)', async () => {
const { app } = await harness();
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'discord' } });
expect(await readWebhookConfig(dir)).toMatchObject({ kind: 'discord', url: SECRET_URL, enabled: true });
});
it('an empty url clears it', async () => {
const { app } = await harness();
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
expect(res.json().data).toMatchObject({ hasUrl: false, urlMasked: '' });
expect((await readWebhookConfig(dir)).url).toBe('');
});
it.each([
['enabling with no URL', { enabled: true }, /Add a webhook URL/],
['a metadata address', { url: 'http://169.254.169.254/latest' }, /metadata|link-local/],
['a non-http scheme', { url: 'file:///etc/passwd' }, /http and https/],
['credentials in the URL', { url: 'https://u:p@example.com/x' }, /credentials/],
['clearing the URL while enabled', null, /Add a webhook URL/],
])('rejects %s with 400 and saves nothing', async (_label, payload, why) => {
const { app } = await harness();
if (payload === null) {
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(why);
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
return;
}
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(why);
expect(await readWebhookConfig(dir)).toMatchObject({ enabled: false, url: '' });
});
it('rejects unknown keys and bad enums (strict schema)', async () => {
const { app } = await harness();
for (const payload of [{ extra: 1 }, { kind: 'telegram' }, { scope: 'everything' }, { enabled: 'yes' }]) {
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
expect(res.statusCode, JSON.stringify(payload)).toBe(400);
}
});
});
describe('POST /api/webhook/test', () => {
it('refuses with 400 until a URL is saved', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
expect(res.statusCode).toBe(400);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('sends one message with the saved config, even while notifications are disabled', async () => {
const { app, notifier } = await harness();
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'generic', url: SECRET_URL } });
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: true, status: 200 });
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetchImpl.mock.calls[0][1].body as string)).toMatchObject({
host: 'codeman:test',
event: 'webhook:test',
});
expect(notifier.lastResult?.ok).toBe(true);
expect(res.body).not.toContain('SUPERSECRET');
});
it('reports a delivery failure in data (HTTP 200) without leaking the URL, and GET shows it as the last result', async () => {
fetchImpl.mockImplementation(async () => new Response('', { status: 404 }));
const { app } = await harness();
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
expect(get.json().data.lastResult).toMatchObject({ ok: false, status: 404 });
expect(get.body).not.toContain('SUPERSECRET');
});
});
describe('multi-user', () => {
it.each([
['GET', '/api/webhook'],
['PUT', '/api/webhook'],
['POST', '/api/webhook/test'],
] as const)('refuses a non-admin on %s %s and touches nothing', async (method, url) => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ username: 'bob', role: 'user' });
const res = await app.inject({ method, url, payload: method === 'PUT' ? { url: SECRET_URL } : undefined });
expect(res.statusCode).toBe(403);
expect((await readWebhookConfig(dir)).url).toBe('');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('allows an admin', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ username: 'root', role: 'admin' });
expect((await app.inject({ method: 'GET', url: '/api/webhook' })).statusCode).toBe(200);
});
});
+440
View File
@@ -0,0 +1,440 @@
/**
* @fileoverview The worktree routes (`/api/cases/:name/worktrees`) and quick-start's `worktree`
* field, against a REAL temp git repository in the per-file temp HOME's case space
* (`test/setup.ts`). Covers the happy path end to end (create, list with live sessions, start a
* session in it, remove, branch delete) and every refusal: Docker and remote cases (git never
* spawned for Docker), a folder that is not a repository, bad names, a symlinked `.claude`, a
* dirty tree, a live session inside, and multi-user ownership. Port: N/A (app.inject()).
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerWorktreeRoutes } from '../../src/web/routes/worktree-routes.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { userCasesDir } from '../../src/config/multiuser.js';
import { Session } from '../../src/session.js';
import { applyAgentSkill } from '../../src/hooks-config.js';
import type { WorktreeGitRunner } from '../../src/git-worktrees.js';
const ENV = {
...process.env,
LC_ALL: 'C',
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
/** A git repository with one commit at `dir`. */
function makeRepo(dir: string): string {
mkdirSync(dir, { recursive: true });
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'README.md'), '# repo\n');
git(dir, 'add', 'README.md');
git(dir, 'commit', '-q', '-m', 'init');
return realpathSync(dir);
}
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
/**
* The worktree routes plus the session routes (quick-start, POST /api/sessions), with the
* production envelope hook mirrored from server.ts: quick-start returns a bare object and relies
* on it for both the `{ success, data }` wrapper and the error-code status, as in
* case-clone-routes.test.ts.
*/
async function harness(
opts: { authUser?: { username: string; role: 'admin' | 'user' }; git?: WorktreeGitRunner } = {}
): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
if (opts.authUser) {
const authUser = opts.authUser;
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
}
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
const ctx = createMockRouteContext();
registerWorktreeRoutes(app, ctx as never, { git: opts.git });
registerSessionRoutes(app, ctx as never);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
let h: Harness;
let repo: string;
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
repo = makeRepo(join(CASES_DIR, 'wtcase'));
h = await harness();
});
afterEach(async () => {
await h.app.close();
vi.restoreAllMocks();
delete process.env.CODEMAN_MULTIUSER;
await rm(join(getDataDir(), 'docker-hosts.json'), { force: true });
await rm(join(getDataDir(), 'docker-cases.json'), { force: true });
await rm(join(getDataDir(), 'remote-hosts.json'), { force: true });
await rm(join(getDataDir(), 'remote-cases.json'), { force: true });
await rm(join(getDataDir(), 'linked-cases.json'), { force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(join(process.env.HOME ?? '/nonexistent', 'codeman-users'));
});
const list = (name = 'wtcase') => h.app.inject({ method: 'GET', url: `/api/cases/${name}/worktrees` });
const create = (payload: Record<string, unknown>, name = 'wtcase') =>
h.app.inject({ method: 'POST', url: `/api/cases/${name}/worktrees`, payload });
const remove = (wt: string, query = '', name = 'wtcase') =>
h.app.inject({ method: 'DELETE', url: `/api/cases/${name}/worktrees/${wt}${query}` });
describe('worktree routes: happy path', () => {
it('creates, lists, starts a session in, and removes a worktree', async () => {
const before = await list();
expect(before.statusCode).toBe(200);
expect(before.json().data).toMatchObject({ caseName: 'wtcase', repoRoot: repo, truncated: false });
expect(before.json().data.worktrees).toHaveLength(1);
const res = await create({ name: 'fix-login' });
expect(res.statusCode).toBe(201);
const wtPath = join(repo, '.claude', 'worktrees', 'fix-login');
expect(res.json().data.worktree).toMatchObject({ name: 'fix-login', path: wtPath, branch: 'worktree-fix-login' });
expect(git(repo, 'status', '--porcelain')).toBe('');
// Start a session in it through the EXISTING quick-start path.
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'claude', worktree: 'fix-login', sessionName: 'w1-fix-login' },
});
expect(qs.statusCode).toBe(200);
const qsData = qs.json().data;
expect(qsData).toMatchObject({ casePath: repo, worktree: 'fix-login', workingDir: wtPath });
const session = h.ctx.sessions.get(qsData.sessionId) as unknown as { workingDir: string };
expect(session.workingDir).toBe(wtPath);
// The session's hooks land in the worktree it runs in, not the case folder.
expect(readFileSync(join(wtPath, '.claude', 'settings.local.json'), 'utf-8')).toContain('/api/hook-event');
// ...and that file does not make the worktree dirty (it is excluded).
expect(git(wtPath, 'status', '--porcelain')).toBe('');
const listed = (await list()).json().data.worktrees;
const row = listed.find((w: { name?: string }) => w.name === 'fix-login');
expect(row).toMatchObject({ managed: true, branch: 'worktree-fix-login', sessionCount: 1 });
expect(row.sessions).toEqual([{ id: qsData.sessionId, name: 'w1-fix-login' }]);
// The main checkout does not count a session that runs in a worktree below it.
expect(listed[0]).toMatchObject({ main: true, isCase: true, managed: false });
// Refused while that session lives.
const busy = await remove('fix-login');
expect(busy.statusCode).toBe(409);
expect(busy.json().error).toContain('live session');
h.ctx.sessions.delete(qsData.sessionId);
const gone = await remove('fix-login', '?deleteBranch=true');
expect(gone.statusCode).toBe(200);
expect(gone.json().data.removed).toMatchObject({
name: 'fix-login',
branch: 'worktree-fix-login',
branchDeleted: true,
});
expect(existsSync(wtPath)).toBe(false);
expect(git(repo, 'branch', '--list', 'worktree-fix-login')).toBe('');
});
it('surfaces git’s refusal for a dirty tree and keeps the files', async () => {
await create({ name: 'dirty', branch: 'feat/dirty' });
const wtPath = join(repo, '.claude', 'worktrees', 'dirty');
writeFileSync(join(wtPath, 'wip.txt'), 'work');
const res = await remove('dirty');
expect(res.statusCode).toBe(409);
expect(res.json().error).toContain('contains modified or untracked files');
expect(readFileSync(join(wtPath, 'wip.txt'), 'utf-8')).toBe('work');
});
it('removes a worktree its sessions got the agent skill in (agentSkillEnabled on)', async () => {
// The agent skill is injected into the folder a session runs in, so a worktree session gets
// an untracked `<skills dir>/codeman/`, which used to make `git worktree remove` refuse forever.
(h.ctx.getAgentSkillEnabled as unknown as { mockResolvedValue(v: boolean): void }).mockResolvedValue(true);
expect((await create({ name: 'skilled' })).statusCode).toBe(201);
const wtPath = join(repo, '.claude', 'worktrees', 'skilled');
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'claude', worktree: 'skilled' },
});
expect(qs.statusCode).toBe(200);
h.ctx.sessions.delete(qs.json().data.sessionId);
expect(existsSync(join(wtPath, '.claude', 'skills', 'codeman', 'SKILL.md'))).toBe(true);
// The other skills directory (codex, gemini and the rest scan `.agents/skills`), written by
// the same function quick-start calls; those CLIs are not installed here.
await applyAgentSkill(wtPath, true, '.agents/skills');
expect(existsSync(join(wtPath, '.agents', 'skills', 'codeman', 'SKILL.md'))).toBe(true);
expect(git(wtPath, 'status', '--porcelain')).toBe('');
expect(git(repo, 'status', '--porcelain')).toBe('');
const res = await remove('skilled');
expect(res.statusCode).toBe(200);
expect(existsSync(wtPath)).toBe(false);
});
it('removes a worktree whose folder was deleted by hand and frees its name', async () => {
await create({ name: 'gone' });
const wtPath = join(repo, '.claude', 'worktrees', 'gone');
await rm(wtPath, { recursive: true, force: true });
const row = (await list()).json().data.worktrees.find((w: { name?: string }) => w.name === 'gone');
expect(row).toMatchObject({ managed: true, prunable: true });
// A session cannot start in it.
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'shell', worktree: 'gone' },
});
expect(qs.statusCode).toBe(404);
const res = await remove('gone', '?deleteBranch=true');
expect(res.statusCode).toBe(200);
expect(res.json().data.removed).toMatchObject({ name: 'gone', branchDeleted: true });
expect(git(repo, 'worktree', 'list')).not.toContain('gone');
expect((await create({ name: 'gone' })).statusCode).toBe(201);
});
it('lets a POST /api/sessions create path use the worktree as workingDir', async () => {
await create({ name: 'viasessions' });
const wtPath = join(repo, '.claude', 'worktrees', 'viasessions');
const res = await h.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir: wtPath, mode: 'shell', name: 's1-viasessions' },
});
expect(res.statusCode).toBe(200);
expect(res.json().data.session.workingDir).toBe(wtPath);
});
});
describe('worktree routes: refusals', () => {
it('rejects bad names before git (schema and the finer rules)', async () => {
for (const name of ['-x', 'a/b', '.hidden', 'x'.repeat(65), 'a..b', 'n.lock']) {
const res = await create({ name });
expect(res.statusCode, name).toBe(400);
}
expect((await remove('a..b')).statusCode).toBe(400);
expect((await create({ name: 'ok', branch: '-D' })).statusCode).toBe(400);
expect((await create({ name: 'ok', base: '--upload-pack=sh' })).statusCode).toBe(400);
expect(git(repo, 'worktree', 'list')).not.toContain('ok');
});
it('answers 404 for a missing case and 400 for a folder that is not a repository', async () => {
expect((await list('nosuchcase')).statusCode).toBe(404);
mkdirSync(join(CASES_DIR, 'plain'), { recursive: true });
const res = await list('plain');
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('not a git repository');
});
it('refuses a Docker case with a 400 and never spawns git', async () => {
let spawned = 0;
const spy: WorktreeGitRunner = async () => {
spawned++;
return { code: 0, stdout: '', stderr: '', timedOut: false };
};
await h.app.close();
h = await harness({ git: spy });
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
JSON.stringify([{ id: 'd1', label: 'box', image: 'codeman/agent:base' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: 'dockcase', type: 'docker', hostId: 'd1', hostWorkspacePath: repo }])
);
for (const res of [
await list('dockcase'),
await create({ name: 'x' }, 'dockcase'),
await remove('x', '', 'dockcase'),
]) {
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Docker');
}
// The local case whose folder IS that Docker workspace is refused too (git would run there).
const viaLocal = await list('wtcase');
expect(viaLocal.statusCode).toBe(400);
expect(viaLocal.json().error).toContain('Docker');
expect(spawned).toBe(0);
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'dockcase', mode: 'shell', worktree: 'x' },
});
expect(qs.statusCode).toBe(400);
expect(qs.json().error).toContain('Docker');
});
it('refuses to remove a worktree a Docker case uses as its workspace', async () => {
expect((await create({ name: 'boxed' })).statusCode).toBe(201);
const wtPath = join(repo, '.claude', 'worktrees', 'boxed');
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: 'boxcase', type: 'docker', hostId: 'd1', hostWorkspacePath: wtPath }])
);
// The local case itself is fine (listing and creating still work)...
expect((await list()).statusCode).toBe(200);
// ...but removing the folder the container mounts is refused, and nothing is deleted.
const res = await remove('boxed');
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Docker case workspace');
expect(existsSync(join(wtPath, 'README.md'))).toBe(true);
expect(git(repo, 'worktree', 'list')).toContain('boxed');
});
it('refuses a remote case with a 400', async () => {
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'r1', label: 'far', host: 'far.example', username: 'u' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'farcase', type: 'remote', hostId: 'r1', remotePath: '/srv/repo' }])
);
const res = await list('farcase');
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('remote');
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'farcase', mode: 'shell', worktree: 'x' },
});
expect(qs.statusCode).toBe(400);
expect(qs.json().error).toContain('remote');
});
it('refuses to create through a symlinked .claude directory', async () => {
const outside = join(CASES_DIR, 'outside');
mkdirSync(join(outside, 'worktrees'), { recursive: true });
symlinkSync(outside, join(repo, '.claude'));
const res = await create({ name: 'evil' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('symlink');
expect(existsSync(join(outside, 'worktrees', 'evil'))).toBe(false);
});
it('quick-start refuses an unregistered worktree and never scaffolds a missing case', async () => {
mkdirSync(join(repo, '.claude', 'worktrees', 'stray'), { recursive: true });
const stray = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'shell', worktree: 'stray' },
});
expect(stray.statusCode).toBe(404);
const missing = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'brandnew', mode: 'shell', worktree: 'x' },
});
expect(missing.statusCode).toBe(404);
expect(existsSync(join(CASES_DIR, 'brandnew'))).toBe(false);
const badName = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'shell', worktree: '../x' },
});
expect(badName.statusCode).toBe(400);
});
});
describe('worktree routes: a case reached through a symlink', () => {
// A linked case's folder can be a symlink (a repository on another disk, macOS's /tmp), and
// POST /api/sessions stores `workingDir` as it was spelled. The worktree list and the removal
// guard compare paths lexically, so a session spelled through the link has to be mapped onto
// the repository's real path first, or it is invisible to both.
it('counts and protects a session whose folder names the worktree through the case link', async () => {
const link = join(CASES_DIR, 'repo-link');
symlinkSync(repo, link);
await mkdir(getDataDir(), { recursive: true });
await writeFile(join(getDataDir(), 'linked-cases.json'), JSON.stringify({ linkedviasym: link }));
const made = await create({ name: 'sym' }, 'linkedviasym');
expect(made.statusCode).toBe(201);
const real = join(repo, '.claude', 'worktrees', 'sym');
expect(made.json().data.worktree.path).toBe(real);
// The same worktree, spelled through the link.
const spelled = join(link, '.claude', 'worktrees', 'sym');
const res = await h.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir: spelled, mode: 'shell', name: 's1-sym' },
});
expect(res.statusCode).toBe(200);
expect(res.json().data.session.workingDir).toBe(spelled);
const busy = await remove('sym', '', 'linkedviasym');
expect(busy.statusCode).toBe(409);
expect(busy.json().error).toContain('live session');
expect(existsSync(real)).toBe(true);
const row = (await list('linkedviasym')).json().data.worktrees.find((w: { name?: string }) => w.name === 'sym');
expect(row).toMatchObject({ managed: true, sessionCount: 1 });
expect(row.sessions).toEqual([{ id: res.json().data.session.id, name: 's1-sym' }]);
});
});
describe('worktree routes: multi-user ownership', () => {
it('resolves a non-admin’s own case space only, never a linked case or another user’s case', async () => {
process.env.CODEMAN_MULTIUSER = '1';
// A linked case is admin-only: a non-admin's lookup falls through to their own space.
await mkdir(getDataDir(), { recursive: true });
await writeFile(join(getDataDir(), 'linked-cases.json'), JSON.stringify({ linkedrepo: repo }));
makeRepo(join(userCasesDir('alice'), 'alicecase'));
await h.app.close();
h = await harness({ authUser: { username: 'bob', role: 'user' } });
expect((await list('linkedrepo')).statusCode).toBe(404);
expect((await list('alicecase')).statusCode).toBe(404);
expect((await create({ name: 'x' }, 'alicecase')).statusCode).toBe(404);
expect(existsSync(join(userCasesDir('alice'), 'alicecase', '.claude'))).toBe(false);
await h.app.close();
h = await harness({ authUser: { username: 'alice', role: 'user' } });
expect((await list('alicecase')).statusCode).toBe(200);
expect((await create({ name: 'mine' }, 'alicecase')).statusCode).toBe(201);
await h.app.close();
h = await harness({ authUser: { username: 'root', role: 'admin' } });
const linked = await list('linkedrepo');
expect(linked.statusCode).toBe(200);
expect(linked.json().data.repoRoot).toBe(repo);
});
});
+6 -3
View File
@@ -7,10 +7,11 @@
*
* @dependency test/mocks/mock-route-context.ts (createMockRouteContext)
* @dependency src/web/routes/ws-routes.ts (registerWsRoutes)
* Port: 3170 (ws-routes tests)
* Port: ephemeral (`listen({ port: 0 })`)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { AddressInfo } from 'node:net';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import WebSocket from 'ws';
@@ -18,7 +19,8 @@ import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js
import { registerWsRoutes } from '../../src/web/routes/ws-routes.js';
import { MAX_INPUT_LENGTH } from '../../src/config/terminal-limits.js';
const PORT = 3170;
/** Assigned by the OS on every listen (`port: 0`); see beforeEach. */
let PORT = 0;
/** Helper: open a WebSocket connection and wait for it to reach OPEN state. */
function connectWs(path: string, timeoutMs = 5000): Promise<WebSocket> {
@@ -86,7 +88,8 @@ describe('ws-routes', () => {
ctx = createMockRouteContext({ sessionId: 'ws-test-session' });
registerWsRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
await app.listen({ port: PORT, host: '127.0.0.1' });
await app.listen({ port: 0, host: '127.0.0.1' });
PORT = (app.server.address() as AddressInfo).port;
});
afterEach(async () => {