mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
commit
57b2dcc466
858 files changed
+173626
-7078
No files matched your search
@@ -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>' } });
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' }));
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user