Merge origin/master into relprep/pr-753

Conflict in src/session-cli-registry-bridge.ts: kept the PR's gateVersion() (gates measured against the gated CLI's own version) and added master's Discord startup hint engine value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:50:25 +01:00
commit 35ff4e886c
138 files changed
+17441 -502

No files matched your search

@@ -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 -1
View File
@@ -4,7 +4,7 @@
*
* ⚠️ 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 } from 'vitest';
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';
@@ -120,6 +120,22 @@ describe('/api/config-backups', () => {
}
});
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-'));
+164 -1
View File
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../../src/web/webview-egress.js';
import { createRouteTestHarness } from './_route-test-utils.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
vi.mock('../../src/web/webview-egress.js', async () => {
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
@@ -417,3 +417,166 @@ describe('apiKey is never handed back to the browser', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe('context-length discovery from the /v1/models listing', () => {
// The gateway path (a Bifrost in front of llama-swap, an OpenRouter-style server):
// no /running, no /props, just a per-model `context_length` or `context_window` in the
// model listing. Distinct ports per test keep the file's shared data dir unambiguous.
afterEach(() => {
fetchMock.mockReset();
});
type StoredHost = { id: string; modelContextLengths?: Record<string, number>; defaultContextLength?: number };
type HarnessApp = RouteTestHarness['app'];
async function endpointWith(id: string, port: number, extra: Record<string, unknown> = {}): Promise<HarnessApp> {
const { app } = await setup();
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id, label: 'A', baseUrl: `http://localhost:${port}`, ...extra },
});
expect(res.statusCode).toBe(200);
return app;
}
async function storedHost(app: HarnessApp, id: string): Promise<StoredHost | undefined> {
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
return (list.json() as StoredHost[]).find((h) => h.id === id);
}
it('reads context_length (preferred) then context_window from each entry into modelContextLengths', async () => {
const app = await endpointWith('ep-ctx-listed', 8101);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'both', context_length: 131072, context_window: 32768 },
{ id: 'window-only', context_window: 8192 },
{ id: 'no-figure' },
],
}),
{ status: 200 }
)
);
const discover = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-listed/discover-models' });
expect(discover.json().success).toBe(true);
const stored = await storedHost(app, 'ep-ctx-listed');
expect(stored?.modelContextLengths).toEqual({ both: 131072, 'window-only': 8192 });
});
it('ignores non-numeric, zero, negative and absurd listed values; the schema bound itself is still accepted', async () => {
const app = await endpointWith('ep-ctx-bad', 8102);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'string', context_length: '131072' },
{ id: 'zero', context_window: 0 },
{ id: 'negative', context_length: -10 },
{ id: 'over-bound', context_length: 100_000_001 },
{ id: 'fractional', context_window: 16384.5 },
{ id: 'at-bound', context_length: 100_000_000 },
],
}),
{ status: 200 }
)
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-bad/discover-models' });
const stored = await storedHost(app, 'ep-ctx-bad');
expect(stored?.modelContextLengths).toEqual({ 'at-bound': 100_000_000 });
});
it('prefers the /running launch command over the listed figure for a loaded model', async () => {
const app = await endpointWith('ep-ctx-cmd', 8103);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(
JSON.stringify({ data: [{ id: 'cmd-wins', status: { value: 'loaded' }, context_length: 131072 }] }),
{ status: 200 }
);
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({ running: [{ model: 'cmd-wins', state: 'ready', cmd: 'llama-server --ctx-size 16384' }] }),
{ status: 200 }
);
}
if (url.pathname === '/props') return new Response('nope', { status: 404 });
throw new Error(`unexpected request in this test: ${url.href}`);
});
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-cmd/discover-models' });
const stored = await storedHost(app, 'ep-ctx-cmd');
expect(stored?.modelContextLengths).toEqual({ 'cmd-wins': 16384 });
});
it('keeps a previously learned value the fresh listing says nothing about, and discovery never touches defaultContextLength', async () => {
const app = await endpointWith('ep-ctx-keep', 8104, {
models: ['re-listed', 'untouched'],
modelContextLengths: { 're-listed': 16384, untouched: 4096 },
defaultContextLength: 65536,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }),
{ status: 200 }
)
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-keep/discover-models' });
const stored = await storedHost(app, 'ep-ctx-keep');
expect(stored?.modelContextLengths).toEqual({ 're-listed': 32768, untouched: 4096 });
expect(stored?.defaultContextLength).toBe(65536);
});
});
describe('defaultContextLength (the user-set fallback discovery never overwrites)', () => {
afterEach(() => {
fetchMock.mockReset();
});
it('stores it on create, updates it on PUT, and a PUT without it clears it', async () => {
const { app } = await setup();
const create = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'ep-defctx', label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 131072 },
});
expect(create.json().data.host.defaultContextLength).toBe(131072);
const update = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/ep-defctx',
payload: { label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 32768 },
});
expect(update.json().data.host.defaultContextLength).toBe(32768);
const cleared = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/ep-defctx',
payload: { label: 'A', baseUrl: 'http://localhost:8105' },
});
expect(cleared.json().data.host.defaultContextLength).toBeUndefined();
});
it('rejects zero, negative, fractional, over-the-bound and non-numeric values', async () => {
const { app } = await setup();
for (const defaultContextLength of [0, -1, 1.5, 100_000_001, '131072']) {
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'ep-defctx-bad', label: 'A', baseUrl: 'http://localhost:8106', defaultContextLength },
});
expect(res.json().success, String(defaultContextLength)).toBe(false);
expect(res.json().errorCode, String(defaultContextLength)).toBe('INVALID_INPUT');
}
});
});
@@ -0,0 +1,196 @@
/**
* @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the
* Discord bridge posts from), with a message that asks SEVERAL questions: every question must come
* back whole, in order, on its own line, and survive the split into Discord posts.
*
* Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts.
* Port: N/A
*
* Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode,
* gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own
* transcript formats and are covered by their reader's tests.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { splitForDiscord } from '../../src/discord-bridge.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
const QUESTIONS = [
'Should the sweep run daily or hourly?',
'Do you want archived threads deleted as well?',
'Is one channel per Codeman acceptable?',
];
const INTRO = 'I have three questions before I continue:';
const OUTRO = 'Once you answer, I will apply the change.';
/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */
const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n');
/** Every question present, in order, each on a line of its own. */
function expectWholeQuestions(text: string): void {
let from = 0;
for (const q of QUESTIONS) {
const at = text.indexOf(q, from);
expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from);
from = at + q.length;
}
const lines = text.split('\n').map((l) => l.trim());
for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true);
expect(text).toContain(INTRO);
expect(text).toContain(OUTRO);
// What goes to Discord: the same questions after the split, joined back.
expectWholeQuestionsAfterSplit(text);
}
function expectWholeQuestionsAfterSplit(text: string): void {
const joined = splitForDiscord(text).join('\n');
for (const q of QUESTIONS) expect(joined).toContain(q);
}
async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
app.addHook('preSerialization', (req, _reply, payload: unknown, done) => {
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown };
return done(null, p.success === undefined ? { success: true, data: payload } : payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('the last message with several questions, per CLI', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
let home: string;
let prevHome: string | undefined;
let prevCodexHome: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any;
async function lastText(): Promise<string> {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` });
return JSON.parse(res.body).data.text as string;
}
beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-'));
prevHome = process.env.HOME;
prevCodexHome = process.env.CODEX_HOME;
process.env.HOME = home;
process.env.CODEX_HOME = join(home, '.codex');
harness = await createHarness();
session = harness.ctx._session;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
rmSync(home, { recursive: true, force: true });
await harness.app.close();
});
it('claude: reads the whole message from the JSONL transcript', async () => {
const id = '40568a29-d4eb-4eb6-b671-8401428e4f39';
session.mode = 'claude';
session.claudeSessionId = id;
session.adoptClaudeSessionId = () => undefined;
const dir = join(home, '.claude', 'projects', '-workspace');
mkdirSync(dir, { recursive: true });
const row = (type: string, text: string, ts: string) => ({
type,
timestamp: ts,
message: { content: [{ type: 'text', text }] },
});
writeFileSync(
join(dir, `${id}.jsonl`),
[row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')]
.map((e) => JSON.stringify(e))
.join('\n')
);
const text = await lastText();
expect(text).toBe(MESSAGE);
expectWholeQuestions(text);
});
it('codex: reads the whole message from the rollout', async () => {
const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const workdir = join(home, 'work');
mkdirSync(workdir, { recursive: true });
session.mode = 'codex';
session.workingDir = workdir;
session.codexConfig = { resumeSessionId: uuid };
const dir = join(home, '.codex', 'sessions', '2026', '10', '30');
mkdirSync(dir, { recursive: true });
const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`);
const entries = [
{
timestamp: '2026-10-30T00:00:00Z',
type: 'session_meta',
payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' },
},
{
timestamp: '2026-10-30T00:00:01Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'plan the cleanup' },
},
{
timestamp: '2026-10-30T00:00:02Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] },
},
];
writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
utimesSync(file, 1_750_000_000, 1_750_000_000);
const text = await lastText();
expectWholeQuestions(text);
});
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
it(`${mode}: reads the whole message from the pane`, async () => {
session.mode = mode;
session.terminalBuffer = [
'› plan the cleanup',
'',
'model · project · main · Ready · Context 100% left',
'',
'• Called write_file',
'',
MESSAGE,
].join('\n');
expectWholeQuestions(await lastText());
});
}
it('copilot: reads the whole message from the pane screen', async () => {
session.mode = 'copilot';
const screen = [
' ❯ plan the cleanup 10:01',
'',
` ● ${INTRO}`,
'',
...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`),
'',
` ${OUTRO}`,
'',
' ~/project Session: 1 AIC used',
'────────────────────────────────',
' ❯ ',
'────────────────────────────────',
' ← open sidebar · / commands · ? help',
].join('\n');
harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux;
expectWholeQuestions(await lastText());
});
});
+354
View File
@@ -0,0 +1,354 @@
/**
* @fileoverview /api/discord: the bot token is a bearer secret, so it is never returned and the
* settings routes are admin only in multi-user mode. Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { _resetNotifyBuckets, registerDiscordRoutes } from '../../src/web/routes/discord-routes.js';
import { DiscordBridge, discordConfigPath, readDiscordConfig, type DiscordFetch } from '../../src/discord-bridge.js';
const TOKEN = 'MTA1.secret.token-value-1234567890';
const FULL = {
enabled: true,
token: TOKEN,
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
};
let dir: string;
let fetchImpl: ReturnType<typeof vi.fn<DiscordFetch>>;
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
const bridge = new DiscordBridge({
configDir: dir,
fetchImpl,
sendInput: async () => true,
socketFactory: () => ({ send() {}, close() {}, on() {} }),
});
const h = await createRouteTestHarness(
(app, ctx) => registerDiscordRoutes(app, { bridge, configDir: dir, ctx, hostTitle: () => 'codeman:test' }),
authUser ? { authUser } : undefined
);
return { ...h, bridge };
}
beforeEach(() => {
_resetNotifyBuckets();
dir = mkdtempSync(join(tmpdir(), 'discord-routes-'));
fetchImpl = vi.fn<DiscordFetch>(
async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 })
);
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('/api/discord', () => {
it('starts disabled with no token', async () => {
const { app } = await harness();
const data = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(data).toMatchObject({ enabled: false, hasToken: false, tokenMasked: '', gateway: 'off' });
expect(data).not.toHaveProperty('token');
});
it('saves, masks the token in every response and writes the file 0600', async () => {
const { app, bridge } = await harness();
const put = await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
expect(put.statusCode).toBe(200);
expect(put.body).not.toContain('secret');
expect(put.json().data).toMatchObject({ enabled: true, hasToken: true });
const get = await app.inject({ method: 'GET', url: '/api/discord' });
expect(get.body).not.toContain('secret');
expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600);
expect((await readDiscordConfig(dir)).token).toBe(TOKEN);
bridge.stop();
});
it('keeps the token when only an option changes, and clears it on an empty string', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' },
});
const cfg = await readDiscordConfig(dir);
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' });
const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } });
expect(cleared.json().data.hasToken).toBe(false);
bridge.stop();
});
it('stores startupHint (default on) and returns it', async () => {
const { app, bridge } = await harness();
const first = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(first).toMatchObject({ startupHint: true });
const put = await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { startupHint: false },
});
expect(put.json().data).toMatchObject({ startupHint: false });
bridge.stop();
});
it('refuses a bad id, an incomplete enable, and unknown fields', async () => {
const { app } = await harness();
const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } });
expect(bad.statusCode).toBe(400);
const incomplete = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: true } });
expect(incomplete.statusCode).toBe(400);
const extra = await app.inject({ method: 'PUT', url: '/api/discord', payload: { nope: 1 } });
expect(extra.statusCode).toBe(400);
});
it('POST /test needs a saved token, then posts to the parent channel', async () => {
const { app, bridge } = await harness();
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(400);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const res = await app.inject({ method: 'POST', url: '/api/discord/test' });
expect(res.json().data.ok).toBe(true);
expect(String(fetchImpl.mock.calls.at(-1)![0])).toContain('/channels/222222222222222222/messages');
bridge.stop();
});
it('is admin only in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ username: 'bob', role: 'user' });
expect((await app.inject({ method: 'GET', url: '/api/discord' })).statusCode).toBe(403);
expect((await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL })).statusCode).toBe(403);
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(403);
});
});
describe('POST /api/sessions/:id/discord-notify', () => {
const url = '/api/sessions/test-session-1/discord-notify';
it('is refused while the bridge is off, and posts into the session thread once on', async () => {
const { app, bridge } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
const off = await app.inject({ method: 'POST', url, payload: { message: 'press reset' } });
expect(off.statusCode).toBe(409);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const on = await app.inject({ method: 'POST', url, payload: { message: 'press reset', urgent: true } });
expect(on.json().data.ok).toBe(true);
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.some((b) => b.includes('press reset') && b.includes('<@333333333333333333>'))).toBe(true);
bridge.stop();
});
it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => {
const { app, bridge, ctx } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const ok = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ok.statusCode).toBe(200);
ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!);
const ambiguous = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ambiguous.statusCode).toBe(404);
const short = await app.inject({
method: 'POST',
url: '/api/sessions/test/discord-notify',
payload: { message: 'hi' },
});
expect(short.statusCode).toBe(404);
bridge.stop();
});
it("does not reveal another user's session through its id prefix (multi-user)", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, bridge, ctx } = await harness({ username: 'bob', role: 'user' });
(ctx.sessions.get('test-session-1') as unknown as { owner: string }).owner = 'alice';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(404);
expect(res.json().error).not.toContain('test-session-1');
bridge.stop();
});
it('lets two different deliberate messages from one tab through within 3 s (neither is lost)', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const first = await app.inject({ method: 'POST', url, payload: { message: 'first thing' } });
const second = await app.inject({ method: 'POST', url, payload: { message: 'second thing' } });
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(second.json().data.deduped).toBeUndefined();
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.filter((b) => b.includes('first thing') || b.includes('second thing'))).toHaveLength(2);
bridge.stop();
});
it('answers 200 with data.ok false when Discord refuses the post, which the CLI turns into exit 1', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ code: 0 }), { status: 401 }));
const res = await app.inject({ method: 'POST', url, payload: { message: 'help' } });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: false, status: 401 });
bridge.stop();
});
it('answers 429 RATE_LIMITED once a tab posts more than its share in a minute', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const codes: number[] = [];
for (let i = 0; i < 8; i++) {
codes.push(
(await app.inject({ method: 'POST', url, payload: { message: `ping ${i}`, urgent: true } })).statusCode
);
}
expect(codes.slice(0, 6)).toEqual([200, 200, 200, 200, 200, 200]);
expect(codes.slice(6)).toEqual([429, 429]);
const limited = await app.inject({ method: 'POST', url, payload: { message: 'again' } });
expect(limited.json().errorCode).toBe('RATE_LIMITED');
expect(limited.headers['retry-after']).toBe('60');
bridge.stop();
});
it('does not spend rate-limit tokens on a request refused for its file', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
for (let i = 0; i < 8; i++) {
const bad = await app.inject({ method: 'POST', url, payload: { message: 'x', file: 'no-such-file.txt' } });
expect(bad.statusCode).toBe(400);
}
expect((await app.inject({ method: 'POST', url, payload: { message: 'still allowed' } })).statusCode).toBe(200);
bridge.stop();
});
it('rejects an empty or oversized message', async () => {
const { app } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
expect((await app.inject({ method: 'POST', url, payload: { message: '' } })).statusCode).toBe(400);
expect((await app.inject({ method: 'POST', url, payload: { message: 'x'.repeat(2000) } })).statusCode).toBe(400);
});
});
describe('discord-notify with a file', () => {
let work: string;
let outside: string;
const url = '/api/sessions/test-session-1/discord-notify';
async function ready() {
const h = await harness();
(h.ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = work;
await h.app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
return h;
}
const post = (app: Awaited<ReturnType<typeof harness>>['app'], file: string) =>
app.inject({ method: 'POST', url, payload: { message: 'log attached', file } });
beforeEach(() => {
work = mkdtempSync(join(tmpdir(), 'discord-work-'));
outside = mkdtempSync(join(tmpdir(), 'discord-outside-'));
writeFileSync(join(work, 'run.log'), 'hello');
writeFileSync(join(outside, 'secret.txt'), 'nope');
});
afterEach(() => {
rmSync(work, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
it('uploads a file inside the session folder as multipart', async () => {
const { app, bridge } = await ready();
const res = await post(app, join(work, 'run.log'));
expect(res.json().data.ok).toBe(true);
const form = fetchImpl.mock.calls.map((c) => c[1].body).find((b) => b instanceof FormData) as FormData;
expect((form.get('files[0]') as File).name).toBe('run.log');
bridge.stop();
});
it.each([
['a relative path', () => 'run.log'],
['a file outside the folder', () => join(outside, 'secret.txt')],
['the folder itself', () => work],
['a missing file', () => join(work, 'nope.log')],
])('refuses %s', async (_n, path) => {
const { app, bridge } = await ready();
const res = await post(app, path());
expect(res.statusCode).toBe(400);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it.each([
['a .env file', () => ['.env', 'TOKEN=x']],
['a private key under .ssh', () => ['.ssh/id_ed25519', 'key']],
['Codeman state with the bot token', () => ['.codeman/discord.json', '{}']],
])('refuses %s even inside the session folder, as with a home directory', async (_n, entry) => {
const [rel, body] = entry();
mkdirSync(dirname(join(work, rel)), { recursive: true });
writeFileSync(join(work, rel), body);
const { app, bridge } = await ready();
const res = await post(app, join(work, rel));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/credentials/);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it('refuses a symlink inside the folder that points at a blocked file', async () => {
writeFileSync(join(work, '.env'), 'TOKEN=x');
symlinkSync(join(work, '.env'), join(work, 'notes.txt'));
const { app, bridge } = await ready();
const res = await post(app, join(work, 'notes.txt'));
expect(res.statusCode).toBe(400);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it('refuses to send when the session folder is not a directory it can probe', async () => {
const { app, ctx, bridge } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = join(work, 'run.log');
const res = await post(app, join(work, 'run.log'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/session folder/);
bridge.stop();
});
it('probes the file before realpath: a path that is not a regular file is refused', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'dir'));
expect((await post(app, join(work, 'dir'))).json().error).toMatch(/Not a regular file/);
bridge.stop();
});
it('refuses a symlink that points outside the folder', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'sub'));
symlinkSync(join(outside, 'secret.txt'), join(work, 'sub', 'link.txt'));
expect((await post(app, join(work, 'sub', 'link.txt'))).statusCode).toBe(400);
bridge.stop();
});
it('refuses a file over 8 MB', async () => {
const { app, bridge } = await ready();
writeFileSync(join(work, 'big.bin'), Buffer.alloc(8 * 1024 * 1024 + 1));
const res = await post(app, join(work, 'big.bin'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/8 MB/);
bridge.stop();
});
it('refuses a file for a remote session', async () => {
const { app, bridge, ctx } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { remote: object }).remote = { label: 'box' };
expect((await post(app, join(work, 'run.log'))).statusCode).toBe(400);
bridge.stop();
});
});
+288
View File
@@ -0,0 +1,288 @@
/**
* @fileoverview The edit history panel's API (GET/POST /api/sessions/:id/edits…): a real
* EditHistorySync and store over a fake transcript reader. Port: N/A (app.inject()).
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { hunksToUnified, registerEditHistoryRoutes, relativeTo } from '../../src/web/routes/edit-history-routes.js';
import { EditHistorySync, NO_TRANSCRIPT, type EditHistorySyncDeps } from '../../src/web/edit-history-sync.js';
import { EditHistoryStore } from '../../src/edit-history-store.js';
import type { EditSources } from '../../src/edit-history-local-source.js';
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
let dir: string;
let session: Record<string, unknown>;
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
return (
JSON.stringify({
type: 'user',
timestamp: new Date(ts).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 2, lines: ['-a', '+b', '+c'] }],
},
...extra,
}) + '\n'
);
}
function sources(main: string, sub?: string): EditSources {
const files = [{ relPath: 'main', size: Buffer.byteLength(main), startOffset: 0, chunk: Buffer.from(main) }];
const metas: Record<string, string> = {};
if (sub) {
files.push({
relPath: 'subagents/agent-a1.jsonl',
size: Buffer.byteLength(sub),
startOffset: 0,
chunk: Buffer.from(sub),
});
metas.a1 = JSON.stringify({
description: 'Fix it',
agentType: 'coding',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
});
}
return { transcriptId: 'conv1', files, metas };
}
async function setup(
deps: Partial<EditHistorySyncDeps> = {},
opts: { authUser?: { username: string; role: 'admin' | 'user' } } = {}
) {
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts')));
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: read,
probe: async (p) => p.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir, debounceMs: 0 });
opened.push(store);
return store;
},
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
...deps,
});
const h = await createRouteTestHarness((app, ctx) => registerEditHistoryRoutes(app, ctx, sync), {
authUser: opts.authUser,
});
session = h.ctx._session as unknown as Record<string, unknown>;
session.workingDir = '/w';
session.mode = 'claude';
session.claudeSessionId = 'conv1';
return { ...h, read, sync };
}
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
const opened: EditHistoryStore[] = [];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-routes-'));
});
afterEach(async () => {
delete process.env.CODEMAN_MULTIUSER;
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(dir, { recursive: true, force: true });
});
const URL = '/api/sessions/test-session-1/edits';
describe('GET /api/sessions/:id/edits', () => {
it('syncs on ?sync=1 and lists entries without diffs, paths relative to the workspace', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(200);
const data = res.json().data;
expect(data).toMatchObject({ state: 'ok', backfillTruncated: false, agents: {} });
expect(data.entries).toEqual([
expect.objectContaining({
id: 't1',
path: '/w/src/a.ts',
relPath: 'src/a.ts',
tool: 'Edit',
added: 2,
removed: 1,
}),
]);
expect(data.entries[0]).not.toHaveProperty('hunks');
});
it('starts the sync before it loads the store (a deletion right after the session check stops the sync)', async () => {
const { app, sync } = await setup();
const calls: string[] = [];
const syncFn = sync.sync.bind(sync);
const getStore = sync.getStore.bind(sync);
vi.spyOn(sync, 'sync').mockImplementation((...a) => (calls.push('sync'), syncFn(...a)));
vi.spyOn(sync, 'getStore').mockImplementation((...a) => (calls.push('getStore'), getStore(...a)));
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
await app.inject({ method: 'POST', url: `${URL}/older` });
expect(calls).toEqual(['sync', 'getStore', 'sync', 'getStore']);
});
it('without sync=1 answers from the store and does not read', async () => {
const { app, read } = await setup();
const res = await app.inject({ method: 'GET', url: URL });
expect(res.json().data).toMatchObject({ state: 'ok', entries: [] });
expect(read).not.toHaveBeenCalled();
});
it('labels subagent entries and shows their paths relative to the worktree', async () => {
const sub = editLine('t9', T0 + 5, '/w/.claude/worktrees/agent-a1/lib/x.ts', { isSidechain: true, agentId: 'a1' });
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'), sub));
const { app } = await setup({ readSources: read });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data.agents).toEqual({
a1: { description: 'Fix it', agentType: 'coding', worktreeBranch: 'worktree-agent-a1' },
});
expect(data.entries.find((e: { id: string }) => e.id === 't9')).toMatchObject({
agentId: 'a1',
relPath: 'lib/x.ts',
});
});
it('a read error answers unavailable with the stored entries', async () => {
let fail = false;
const read = vi.fn(async () => {
if (fail) throw new Error('EACCES: permission denied');
return sources(editLine('t1', T0, '/w/src/a.ts'));
});
const { app } = await setup({ readSources: read });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
fail = true;
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: 'EACCES: permission denied' });
expect(data.entries).toHaveLength(1);
});
it('hands the reader the session ids and its own CLAUDE_CONFIG_DIR', async () => {
const seen: unknown[] = [];
const { app } = await setup({
transcriptPath: async (s) => {
seen.push({ id: s.id, claudeSessionId: s.claudeSessionId, claudeConfigDir: s.claudeConfigDir });
return '/p/conv1.jsonl';
},
});
session.claudeConfigDirOverride = '/cfg/alt ';
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(seen).toEqual([{ id: 'test-session-1', claudeSessionId: 'conv1', claudeConfigDir: '/cfg/alt' }]);
});
it('a session without a transcript yet answers unavailable (no-transcript)', async () => {
const { app, read } = await setup({ transcriptPath: async () => null });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: NO_TRANSCRIPT, entries: [] });
expect(read).not.toHaveBeenCalled();
});
it.each([
['remote', { remote: { host: 'huff' } }],
['docker', { docker: { image: 'x' } }],
['mode', { mode: 'shell' }],
])('answers unsupported (%s) without reading or loading a store', async (reason, patch) => {
const loadStore = vi.fn(async (id: string) => EditHistoryStore.load(id, { dir, debounceMs: 0 }));
const { app, read } = await setup({ loadStore });
Object.assign(session, patch);
const empty = { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
for (const url of [URL, `${URL}?sync=1`]) {
expect((await app.inject({ method: 'GET', url })).json().data).toEqual(empty);
}
expect((await app.inject({ method: 'POST', url: `${URL}/older` })).json().data).toEqual(empty);
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
expect(read).not.toHaveBeenCalled();
expect(loadStore).not.toHaveBeenCalled();
});
it('404s an unknown session', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: '/api/sessions/nope/edits' })).statusCode).toBe(404);
});
it('multi-user: another user’s session is not found and nothing is read', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, read } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(404);
expect(read).not.toHaveBeenCalled();
});
});
describe('GET /api/sessions/:id/edits/:entryId', () => {
it('returns the diff as unified text', async () => {
const { app } = await setup();
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
const res = await app.inject({ method: 'GET', url: `${URL}/t1` });
expect(res.json().data).toEqual({ diff: '@@ -4,1 +4,2 @@\n-a\n+b\n+c', truncated: false });
});
it('404s an edit the session does not have', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: `${URL}/missing` })).statusCode).toBe(404);
});
it('multi-user: another user’s diff is not found', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
session.owner = 'alice';
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
});
});
describe('POST /api/sessions/:id/edits/older', () => {
it('reads the next older block and lists the result', async () => {
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
const full = Buffer.from(lines);
const read = vi.fn(
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[], budget: number) => {
const req = requests.find((r) => r.relPath === 'main');
const start = req ? req.offset : full.length - budget;
const len = req?.len ?? full.length - start;
return {
transcriptId: 'conv1',
metas: {},
files: [{ relPath: 'main', size: full.length, startOffset: start, chunk: full.subarray(start, start + len) }],
};
}
);
const { app } = await setup({ readSources: read, budget: 400 });
const first = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(first.backfillTruncated).toBe(true);
const older = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(older.entries.length).toBeGreaterThan(first.entries.length);
});
it('answers unsupported for a remote session without reading', async () => {
const { app, read } = await setup();
session.remote = { host: 'huff' };
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
expect(read).not.toHaveBeenCalled();
});
});
describe('helpers', () => {
it('relativeTo strips only a real directory prefix', () => {
expect(relativeTo('/w/a/b.ts', '/w')).toBe('a/b.ts');
expect(relativeTo('/w/a/b.ts', '/w/')).toBe('a/b.ts');
expect(relativeTo('/wx/b.ts', '/w')).toBe('/wx/b.ts');
expect(relativeTo('/w/b.ts', undefined)).toBe('/w/b.ts');
});
it('hunksToUnified writes one header per hunk', () => {
expect(
hunksToUnified([
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-x', '+y'] },
{ oldStart: 9, oldLines: 0, newStart: 9, newLines: 1, lines: ['+z'] },
])
).toBe('@@ -1,1 +1,1 @@\n-x\n+y\n@@ -9,0 +9,1 @@\n+z');
});
});
+213
View File
@@ -0,0 +1,213 @@
/**
* @fileoverview POST /api/filesystem/mkdir: the path picker's "New folder". Real filesystem under
* test/setup.ts's temp HOME (a root the picker offers), so this proves what is on disk, not a mock.
* Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { LinkCaseSchema } from '../../src/web/schemas.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
describe('POST /api/filesystem/mkdir', () => {
let workDir = '';
beforeEach(() => {
if (!isSandboxedHome()) throw new Error('filesystem-mkdir-routes.test.ts creates folders: run it via npm test');
workDir = realpathSync(mkdtempSync(join(homedir(), 'picker-mkdir-')));
});
afterEach(() => {
if (workDir) safeRmHomeTree(workDir);
workDir = '';
});
const mkdir = async (payload: Record<string, unknown>) => {
const { app } = await createRouteTestHarness(registerFileRoutes);
return app.inject({ method: 'POST', url: '/api/filesystem/mkdir', payload });
};
it('creates one folder inside the folder being viewed and answers its path', async () => {
const res = await mkdir({ path: workDir, name: 'fresh' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toEqual({ path: join(workDir, 'fresh') });
expect(readdirSync(workDir)).toEqual(['fresh']);
});
it.each(['Kay app', 'project_v2', 'v1.2', 'my-folder', 'Café 2026'])(
'accepts %s, and Link Existing then accepts the folder it made',
async (name) => {
const res = await mkdir({ path: workDir, name });
expect(res.statusCode).toBe(200);
expect(LinkCaseSchema.safeParse({ name: 'x', path: res.json().data.path }).success).toBe(true);
}
);
it('refuses the cases directory as the parent (400): a folder there is already a case', async () => {
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
try {
const res = await mkdir({ path: casesDir, name: 'myproj' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Add Case > New Case');
expect(existsSync(join(casesDir, 'myproj'))).toBe(false);
// A folder inside a case is fine.
mkdirSync(join(casesDir, 'real-case'));
expect((await mkdir({ path: join(casesDir, 'real-case'), name: 'sub' })).statusCode).toBe(200);
} finally {
safeRmHomeTree(casesDir);
}
});
it('answers 404 for a parent that is gone, the same answer browse gives', async () => {
const res = await mkdir({ path: join(workDir, 'gone'), name: 'x' });
expect(res.statusCode).toBe(404);
});
it('refuses a parent that is a file (400), like browse', async () => {
writeFileSync(join(workDir, 'afile'), 'x');
const res = await mkdir({ path: join(workDir, 'afile'), name: 'x' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('The folder to create in must be a directory');
});
it('creates inside a hidden folder only when the picker is showing hidden entries', async () => {
mkdirSync(join(workDir, '.hid'));
const denied = await mkdir({ path: join(workDir, '.hid'), name: 'x' });
expect(denied.statusCode).toBe(403);
const ok = await mkdir({ path: join(workDir, '.hid'), name: 'x', showHidden: 'true' });
expect(ok.statusCode).toBe(200);
expect(existsSync(join(workDir, '.hid', 'x'))).toBe(true);
// A dot name is still refused there.
expect((await mkdir({ path: join(workDir, '.hid'), name: '.y', showHidden: 'true' })).statusCode).toBe(400);
});
it('trims the name', async () => {
const res = await mkdir({ path: workDir, name: ' spaced ' });
expect(res.json().data.path).toBe(join(workDir, 'spaced'));
});
it('refuses an existing folder (409) without touching it', async () => {
mkdirSync(join(workDir, 'taken'));
writeFileSync(join(workDir, 'taken', 'keep.txt'), 'mine');
const res = await mkdir({ path: workDir, name: 'taken' });
expect(res.statusCode).toBe(409);
expect(res.json().error).toContain('already exists');
expect(readdirSync(join(workDir, 'taken'))).toEqual(['keep.txt']);
});
it.each([
['a slash', 'a/b'],
['a backslash', 'a\\b'],
['a traversal', '..'],
['a dot name', '.'],
['a hidden name', '.secret'],
['a lone tilde', '~'],
['a leading tilde', '~tilde'],
['an empty name', ' '],
['an apostrophe', "Kay's app"],
['parentheses', 'project (v2)'],
['a plus', 'a+b'],
['a comma', 'notes, 2026'],
['a hash', 'c#'],
['brackets', '[draft]'],
['a double dot inside', 'v1..2'],
['a tab', 'a\tb'],
['an escape', 'a\x1b[31mb'],
['a bell', 'a\x07b'],
['a delete char', 'a\x7fb'],
['a name over 255 bytes', 'é'.repeat(200)],
])('refuses %s in the name (400) and creates nothing', async (_label, name) => {
const res = await mkdir({ path: workDir, name });
expect(res.statusCode).toBe(400);
expect(readdirSync(workDir)).toEqual([]);
});
it('refuses a parent that does not exist (404)', async () => {
const res = await mkdir({ path: join(workDir, 'nope'), name: 'x' });
expect(res.statusCode).toBe(404);
expect(existsSync(join(workDir, 'nope'))).toBe(false);
});
it('refuses a parent outside the browse roots (403)', async () => {
const res = await mkdir({ path: '/etc', name: 'x' });
expect(res.statusCode).toBe(403);
expect(existsSync('/etc/x')).toBe(false);
});
it('refuses a relative or traversing parent (400)', async () => {
expect((await mkdir({ path: 'relative', name: 'x' })).statusCode).toBe(400);
expect((await mkdir({ path: `${workDir}/../x`, name: 'x' })).statusCode).toBe(400);
});
describe('multi-user scoping', () => {
let prevMultiUser: string | undefined;
let prevSpaces: string | undefined;
let spaces = '';
beforeEach(() => {
prevMultiUser = process.env.CODEMAN_MULTIUSER;
prevSpaces = process.env.CODEMAN_USER_SPACES_DIR;
spaces = join(workDir, 'spaces');
mkdirSync(join(spaces, 'alice'), { recursive: true });
mkdirSync(join(spaces, 'bob'));
process.env.CODEMAN_MULTIUSER = '1';
process.env.CODEMAN_USER_SPACES_DIR = spaces;
});
afterEach(() => {
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR;
else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces;
});
it("refuses a non-admin creating inside another user's space (403) and creates nothing", async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'alice'), name: 'x' },
});
expect(res.statusCode).toBe(403);
expect(readdirSync(join(spaces, 'alice'))).toEqual([]);
});
it("refuses a non-admin's own cases folder (400) but not the top of their space", async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const own = join(spaces, 'bob', 'cases');
mkdirSync(own, { recursive: true });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: own, name: 'myproj' },
});
expect(res.statusCode).toBe(400);
expect(existsSync(join(own, 'myproj'))).toBe(false);
});
it('lets a non-admin create inside their own space', async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'bob'), name: 'mine' },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(spaces, 'bob', 'mine'))).toBe(true);
});
it('404s a sessionId owned by another user, and adopts none of its folder', async () => {
const scoped = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
scoped.ctx._session.owner = 'alice';
const res = await scoped.app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'bob'), name: 'x', sessionId: scoped.ctx._sessionId },
});
expect(res.statusCode).toBe(404);
expect(existsSync(join(spaces, 'bob', 'x'))).toBe(false);
});
});
});
@@ -0,0 +1,37 @@
/**
* @fileoverview `POST /api/filesystem/mkdir` probes the folder bounded before the picker's
* resolver runs `realpathSync` on it (#516): a mount that stopped answering is refused, and
* the request never reaches a synchronous call that would freeze the server.
*
* The probe is mocked: a mount that never answers cannot be made here. Port: none (inject).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn(actual.probePathKind) };
});
import { probePathKind } from '../../src/utils/bounded-path-probe.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { createRouteTestHarness } from './_route-test-utils.js';
describe('POST /api/filesystem/mkdir on an unresponsive mount', () => {
afterEach(() => vi.mocked(probePathKind).mockClear());
it('refuses before the picker resolver touches the path', async () => {
vi.mocked(probePathKind).mockResolvedValueOnce('unknown');
const { app } = await createRouteTestHarness(registerFileRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: '/mnt/dead/projects', name: 'fresh' },
});
expect(res.statusCode).toBe(422);
expect(res.json().errorCode).toBe('OPERATION_FAILED');
expect(res.json().error).toMatch(/not responding|not checked/);
expect(probePathKind).toHaveBeenCalledWith('/mnt/dead/projects', { pastCap: true });
});
});
+67 -2
View File
@@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { rm, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
@@ -122,6 +122,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
const { sessionId } = res.json();
const session = ctx.sessions.get(sessionId) as unknown as Session;
expect(session.customModel?.endpointId).toBe('ep1');
// #750, pinned the same way as the pi/grok/omp one-shot paths below: the FIRST
// launch already carries the selected id, not just the file on disk.
expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3');
expect(restartSpy).not.toHaveBeenCalled();
const configDir = customModelConfigDir(sessionId);
@@ -192,7 +195,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(res.json().errorCode).toBe('OPERATION_FAILED');
});
it('refuses a model id the CLI cannot carry on its command line, cleaning up any written config dir', async () => {
it('refuses a model id the CLI cannot carry on its command line, before writing anything', async () => {
const dirs = join(getDataDir(), 'custom-model-configs');
const before = existsSync(dirs) ? readdirSync(dirs).length : 0;
const res = await quickStart({
caseName: 'cm-badmodel',
mode: 'pi',
@@ -200,6 +205,7 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(existsSync(dirs) ? readdirSync(dirs).length : 0).toBe(before);
});
it('refuses customModel for a remote case', async () => {
@@ -226,6 +232,21 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
});
}
it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => {
const other = ctx.sessions.get('test-session-1')!;
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
mockRunning([{ model: 'llama3', state: 'ready' }]);
const res = await quickStart({
caseName: 'cm-badmodel-swap',
mode: 'codex',
customModel: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
expect(res.json().requiresConfirmation).toBeUndefined();
expect(res.json().errorCode).toBe('INVALID_INPUT');
});
it('asks for confirmation instead of launching when another live session is using the currently loaded model', async () => {
const other = ctx.sessions.get('test-session-1')!;
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
@@ -367,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)', () => {
+118 -3
View File
@@ -9,7 +9,7 @@ import { createRouteTestHarness } from './_route-test-utils.js';
import { createMockSession } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { webviewFetch } from '../../src/web/webview-egress.js';
@@ -203,6 +203,10 @@ describe('POST /api/sessions/:id/custom-model', () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'pi';
// Start from no dir at all (a sibling test's successful pi apply may have left one),
// so the existsSync below says something about THIS request.
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
rmSync(dir, { recursive: true, force: true });
const res = await app.inject({
method: 'POST',
@@ -214,8 +218,45 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).not.toHaveBeenCalled();
expect(session.restartCli).not.toHaveBeenCalled();
// The config dir written before the check is cleaned up again.
expect(existsSync(join(getDataDir(), 'custom-model-configs', 'test-session-1'))).toBe(false);
// The pattern check runs on the pure build BEFORE any write, so a refused id leaves
// no directory behind at all, nothing to clean up.
expect(existsSync(dir)).toBe(false);
});
it('codex: a refused forced launch id never touches the live config dir the session runs on as CODEX_HOME', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'codex';
// First apply succeeds: the session now runs codex on qwen3:32b, whose config.toml
// lives in the per-session dir the running CLI itself uses as CODEX_HOME.
const ok = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'qwen3:32b' },
});
expect(ok.statusCode).toBe(200);
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
const written = join(dir, 'config.toml');
expect(existsSync(written)).toBe(true);
// `@` is outside codex's `model-plain-colon` pattern. The pattern check now runs on
// the pure build BEFORE any write, so the refusal must leave the dir, and the file
// the live CLI reads, exactly as they were, rather than replacing and deleting the
// CODEX_HOME of a session that stays on qwen3:32b.
const before = readFileSync(written, 'utf8');
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
expect(session.restartCli).toHaveBeenCalledTimes(1);
expect(existsSync(dir)).toBe(true);
expect(readFileSync(written, 'utf8')).toBe(before);
});
it('clear removes the previous config dir the session reports', async () => {
@@ -298,6 +339,28 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(session.restartCli).not.toHaveBeenCalled();
});
it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'codex';
const other = createMockSession('other-session');
other.name = 'w2-otherbox';
other.customModel = { endpointId: 'ep1', modelId: 'llama3' };
ctx.sessions.set('other-session', other);
mockRunning([{ model: 'llama3', state: 'ready' }]);
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
// Agreeing to the swap first would only lead to this same 400.
expect(res.json().requiresConfirmation).toBeUndefined();
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).not.toHaveBeenCalled();
});
describe('multi-user: the confirm dialog must not name a session the caller cannot access', () => {
const saved: Record<string, string | undefined> = {};
@@ -449,6 +512,58 @@ 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', () => {
+10
View File
@@ -1011,6 +1011,16 @@ describe('session-routes', () => {
// with no broadcast, so other open tabs kept showing the retired row until
// their next unrelated fetch.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
// Its stored edit history (diffs can hold secrets) goes with the record.
expect(harness.ctx.forgetEditHistory).toHaveBeenCalledWith('ghost-session');
});
it('keeps the edit history of a pinned persisted-only session whose record is only demoted', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({ id: 'pinned-ghost', owner: undefined } as never);
vi.mocked(harness.ctx.store.demoteOrRemoveSession).mockReturnValueOnce('preserved');
const res = await harness.app.inject({ method: 'DELETE', url: '/api/sessions/pinned-ghost' });
expect(res.statusCode).toBe(200);
expect(harness.ctx.forgetEditHistory).not.toHaveBeenCalled();
});
it('a persisted-only delete drops the agent inbox the detach kept', async () => {
+13
View File
@@ -420,6 +420,19 @@ describe('system-routes', () => {
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 ==========