Files
Codeman/test/routes/cli-registry-routes.test.ts
T
DevvynandClaude Sonnet 5 8cef31086b fix(docker): persist CLIs installed from Settings across container updates
The image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is image content, so
Update-Codeman.sh discarded every npm-installed CLI (dsh, pi). In the Compose
container, POST /api/clis/:id/install now installs into ~/.local on the
persistent home mount, and ~/.local/bin is appended to the image PATH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-25 08:56:20 +08:00

778 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Route tests for /api/clis (docs/cli-enable-disable-plan.md, Phases 2-5).
* Mirrors the admin-gating test shape used for other admin/settings surfaces (see
* test/routes/search-routes.test.ts's multi-user block).
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, not one per `it()` — a write in
* one test is visible to every test declared after it. Phase 3-5 tests therefore each
* clean up what they create (delete a custom entry, restore a toggled stock flag) so
* later tests, including the Phase 2 "every entry is stock" assumption above, still hold.
*
* Port: N/A (app.inject(), no live server).
*/
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 { 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 { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { CreateSessionSchema } from '../../src/web/schemas.js';
import { buildSpawnCommandFromRegistry } from '../../src/session-cli-registry-bridge.js';
import { defaultRemoteCommandForMode } from '../../src/remote-hosts.js';
import { defaultDockerCommandForMode } from '../../src/docker-hosts.js';
import {
getCli,
registryFilePath,
reloadCliRegistry,
resolveInstallCommandForPlatform,
} from '../../src/config/cli-registry/registry.js';
// The install route spawns a real shell command, so `spawn` is replaced with a fake
// child (every other child_process export stays real). The two cache invalidators are
// wrapped pass-through spies: the real invalidation still runs, and the tests can see
// WHICH binaries and id each route forgot.
const { spawnMock, invalidateBinariesSpy, invalidateIdSpy } = vi.hoisted(() => ({
spawnMock: vi.fn(),
invalidateBinariesSpy: vi.fn(),
invalidateIdSpy: vi.fn(),
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, spawn: spawnMock };
});
vi.mock('../../src/utils/cli-executable-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-executable-resolver.js')>();
return {
...actual,
invalidateCliExecutableResolvers: (binaries: readonly string[]) => {
invalidateBinariesSpy([...binaries]);
actual.invalidateCliExecutableResolvers(binaries);
},
};
});
vi.mock('../../src/utils/cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-resolver.js')>();
return {
...actual,
invalidateCliResolverCache: (id?: string) => {
invalidateIdSpy(id);
actual.invalidateCliResolverCache(id);
},
};
});
/** A spawned install that prints one line and exits with `code` on the next tick. */
function fakeInstallChild(code: number): EventEmitter {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
const stdout = new EventEmitter();
child.stdout = stdout;
child.stderr = new EventEmitter();
child.kill = vi.fn();
setImmediate(() => {
stdout.emit('data', Buffer.from(code === 0 ? 'installed\n' : 'boom\n'));
child.emit('close', code);
});
return child;
}
/** Every write endpoint requires this on; toggled per-test by writing settings.json directly. */
function enableCliManagement(): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: true }));
}
/**
* The inverse, and load-bearing for every "off" test below: settings.json is shared by
* the whole FILE (one temp HOME, not one per `it()`), so a "should be rejected while off"
* test cannot assume the flag started false — an EARLIER test may have called
* `enableCliManagement()` and left it on.
*/
function disableCliManagement(): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: false }));
}
describe('GET /api/clis', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('single-user mode: returns every registry entry, disabled stock CLIs included', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'GET', url: '/api/clis' });
expect(res.statusCode).toBe(200);
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.success).toBe(true);
const ids = body.data.map((c) => c.id);
expect(ids).toContain('claude');
expect(ids).toContain('shell');
expect(ids.length).toBeGreaterThanOrEqual(9);
});
it('every item has the expected shape and excludes spawn-time fields', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'GET', url: '/api/clis' });
const body = res.json() as { success: true; data: CliListItem[] };
for (const cli of body.data) {
expect(typeof cli.id).toBe('string');
expect(typeof cli.label).toBe('string');
expect(typeof cli.shortBadge).toBe('string');
expect(typeof cli.order).toBe('number');
expect(['agent', 'shell']).toContain(cli.kind);
expect(typeof cli.enabled).toBe('boolean');
expect(typeof cli.stock).toBe('boolean');
expect(typeof cli.installed).toBe('boolean');
expect(cli).not.toHaveProperty('launch');
expect(cli).not.toHaveProperty('env');
expect(cli).not.toHaveProperty('capabilities');
expect(cli).not.toHaveProperty('overlays');
expect(cli).not.toHaveProperty('discovery');
}
});
it('every entry is stock: true (no custom entries exist before Phase 5)', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'GET', url: '/api/clis' });
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.data.every((c) => c.stock === true)).toBe(true);
});
it('multi-user mode: an admin sees the full list', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'root', role: 'admin' },
});
const res = await app.inject({ method: 'GET', url: '/api/clis' });
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.data.length).toBeGreaterThanOrEqual(9);
});
it('multi-user mode: a non-admin sees an empty list, not a 403', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'GET', url: '/api/clis' });
expect(res.statusCode).toBe(200);
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.data).toEqual([]);
});
});
describe('PUT /api/clis/:id (Phase 3: enable/disable)', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('rejects when cliManagementEnabled is off — no settings.json write at all', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(res.statusCode).toBe(403);
const body = res.json() as { errorCode: string };
expect(body.errorCode).toBe('FORBIDDEN');
});
it('toggles a stock CLI off then back on, visible with no reload needed', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const off = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(off.statusCode).toBe(200);
const afterOff = await app.inject({ method: 'GET', url: '/api/clis' });
const grokOff = (afterOff.json() as { data: CliListItem[] }).data.find((c) => c.id === 'grok');
expect(grokOff?.enabled).toBe(false);
const on = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
expect(on.statusCode).toBe(200);
const afterOn = await app.inject({ method: 'GET', url: '/api/clis' });
const grokOn = (afterOn.json() as { data: CliListItem[] }).data.find((c) => c.id === 'grok');
expect(grokOn?.enabled).toBe(true);
});
it('rejects disabling shell, changes nothing', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/shell', payload: { enabled: false } });
// errorCode, not statusCode: this branch returns bare createErrorResponse()
// and relies on server.ts's global preSerialization hook to map it to 400,
// which the lightweight test harness does not register — same convention
// as test/routes/custom-model-routes.test.ts's equivalent checks.
expect(res.json().errorCode).toBe('INVALID_INPUT');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'shell');
expect(entry?.enabled).toBe(true);
});
it('allows disabling claude — only shell keeps the hard guarantee (revised 2026-09-23)', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const off = await app.inject({ method: 'PUT', url: '/api/clis/claude', payload: { enabled: false } });
expect(off.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'claude');
expect(entry?.enabled).toBe(false);
// Restore for any later test in this file that assumes claude's stock default.
await app.inject({ method: 'PUT', url: '/api/clis/claude', payload: { enabled: true } });
});
it('404s an id that does not exist, never creating one', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/nonexistent-id', payload: { enabled: true } });
expect(res.json().errorCode).toBe('NOT_FOUND');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'nonexistent-id')).toBe(false);
});
it('multi-user: non-admin is rejected before the write', async () => {
enableCliManagement();
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(res.statusCode).toBe(403);
});
it('preserves an unrelated existing override key on a stock entry when toggling enabled', async () => {
enableCliManagement();
// grok's real accent is #f43f5e (stock.ts); overriding it here first proves
// the enabled-only write is a MERGE, not a replace, of that id's override.
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(registryFilePath(), JSON.stringify({ schemaVersion: 1, clis: { grok: { accent: '#123456' } } }), {
mode: 0o600,
});
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(res.statusCode).toBe(200);
// GET /api/clis deliberately excludes `accent` (Phase 2's own response
// shape), so verify the merge server-side through the registry itself.
const grok = getCli('grok');
expect(grok?.enabled).toBe(false);
expect(grok?.accent).toBe('#123456');
// Restore for any later test in this file that assumes grok's stock default.
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
});
it('writes clis.json mode 0600 on POSIX', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
if (process.platform !== 'win32') {
const mode = statSync(registryFilePath()).mode & 0o777;
expect(mode).toBe(0o600);
}
});
});
describe('POST /api/clis/:id/install (Phase 4)', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('rejects when cliManagementEnabled is off', async () => {
disableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.statusCode).toBe(403);
});
it('rejects a custom entry id — Decision 3: a custom install command is never executed', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-install-guard', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
const res = await app.inject({ method: 'POST', url: '/api/clis/test-install-guard/install' });
expect(res.json().errorCode).toBe('INVALID_INPUT');
await app.inject({ method: 'DELETE', url: '/api/clis/test-install-guard' });
});
it('multi-user: non-admin is rejected before any spawn', async () => {
enableCliManagement();
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.statusCode).toBe(403);
});
});
describe('Custom CLI entries (Phase 5)', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('rejects create when cliManagementEnabled is off', async () => {
disableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-off', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(res.statusCode).toBe(403);
});
it('creates a custom entry, it appears in GET /api/clis with stock:false', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const create = await app.inject({
method: 'POST',
url: '/api/clis',
payload: {
id: 'test-create',
label: 'Test CLI',
shortBadge: 'TC',
binaries: ['test-create-bin'],
argv: ['test-create-bin', '--flag'],
},
});
expect(create.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'test-create');
expect(entry?.stock).toBe(false);
expect(entry?.label).toBe('Test CLI');
await app.inject({ method: 'DELETE', url: '/api/clis/test-create' });
});
it('rejects a create whose id collides with a stock id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'claude', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
});
it('rejects creating the same custom id twice', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const payload = { id: 'test-dup', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] };
const first = await app.inject({ method: 'POST', url: '/api/clis', payload });
expect(first.statusCode).toBe(200);
const second = await app.inject({ method: 'POST', url: '/api/clis', payload });
expect(second.json().errorCode).toBe('ALREADY_EXISTS');
await app.inject({ method: 'DELETE', url: '/api/clis/test-dup' });
});
it('rejects a literal with shell metacharacters (the schema, not a new bypass)', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-unsafe', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x; rm -rf /'] },
});
expect(res.statusCode).toBe(400);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'test-unsafe')).toBe(false);
});
it('updates an existing custom entry via PUT /api/clis/custom/:id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-update', label: 'Before', shortBadge: 'BE', binaries: ['x'], argv: ['x'] },
});
const update = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-update',
payload: { label: 'After', shortBadge: 'AF', binaries: ['y'], argv: ['y', '--z'] },
});
expect(update.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'test-update');
expect(entry?.label).toBe('After');
await app.inject({ method: 'DELETE', url: '/api/clis/test-update' });
});
it('rejects PUT /api/clis/custom/:id against a stock id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'PUT',
url: '/api/clis/custom/claude',
payload: { label: 'Hijack', shortBadge: 'HJ', binaries: ['x'], argv: ['x'] },
});
expect(res.json().errorCode).toBe('INVALID_INPUT');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'claude')?.label).toBe('Claude Code');
});
it('404s an update against a custom id that does not exist', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'PUT',
url: '/api/clis/custom/nonexistent-custom',
payload: { label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(res.json().errorCode).toBe('NOT_FOUND');
});
it('deletes a custom entry; a second delete 404s', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-delete', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
const del = await app.inject({ method: 'DELETE', url: '/api/clis/test-delete' });
expect(del.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'test-delete')).toBe(false);
const again = await app.inject({ method: 'DELETE', url: '/api/clis/test-delete' });
expect(again.json().errorCode).toBe('NOT_FOUND');
});
it('refuses to delete a stock CLI', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'DELETE', url: '/api/clis/claude' });
expect(res.json().errorCode).toBe('INVALID_INPUT');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'claude')).toBe(true);
});
it('multi-user: non-admin is rejected on create/update/delete', async () => {
enableCliManagement();
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const create = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-mu', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(create.statusCode).toBe(403);
const update = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-mu',
payload: { label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(update.statusCode).toBe(403);
const del = await app.inject({ method: 'DELETE', url: '/api/clis/test-mu' });
expect(del.statusCode).toBe(403);
});
it('a custom entry can also be toggled via the simple Phase 3 endpoint', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-toggle', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'], enabled: true },
});
const off = await app.inject({ method: 'PUT', url: '/api/clis/test-toggle', payload: { enabled: false } });
expect(off.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'test-toggle');
expect(entry?.enabled).toBe(false);
// The rest of the entry (binaries/argv/label) must survive the shallow
// enabled-only merge — proven indirectly: a second full update still finds
// the row and changes its label, which would fail if the toggle had
// corrupted the stored shape.
const relabel = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-toggle',
payload: { label: 'Still here', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(relabel.statusCode).toBe(200);
await app.inject({ method: 'DELETE', url: '/api/clis/test-toggle' });
});
});
describe('resolver caches are forgotten when what a CLI resolves to changes', () => {
beforeEach(() => {
spawnMock.mockReset();
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
});
it('GET /api/clis names the install command for a stock entry only (for the confirm dialog)', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-cmd', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
const list = (await app.inject({ method: 'GET', url: '/api/clis' })).json() as { data: CliListItem[] };
const grok = list.data.find((c) => c.id === 'grok');
expect(grok?.installCommand).toBe(resolveInstallCommandForPlatform(getCli('grok')!));
expect(list.data.find((c) => c.id === 'test-cmd')).not.toHaveProperty('installCommand');
await app.inject({ method: 'DELETE', url: '/api/clis/test-cmd' });
});
it('a successful install runs the stock command and forgets that CLI’s cached lookups', async () => {
enableCliManagement();
spawnMock.mockImplementation(() => fakeInstallChild(0));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.statusCode).toBe(200);
const grok = getCli('grok')!;
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe(resolveInstallCommandForPlatform(grok));
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, detached: true });
// Without this, the Run menu and a session spawn replayed the pre-install miss
// for up to the 5-minute negative-cache backoff.
expect(invalidateBinariesSpy).toHaveBeenCalledWith(grok.discovery.binaries);
expect(invalidateIdSpy).toHaveBeenCalledWith('grok');
});
it('a failed install still forgets the cached lookups (it may have left a binary behind)', async () => {
enableCliManagement();
spawnMock.mockImplementation(() => fakeInstallChild(1));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.json().errorCode).toBe('OPERATION_FAILED');
expect(invalidateIdSpy).toHaveBeenCalledWith('grok');
});
it('never spawns anything for a custom entry, even though the route exists', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-nospawn', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
await app.inject({ method: 'POST', url: '/api/clis/test-nospawn/install' });
expect(spawnMock).not.toHaveBeenCalled();
await app.inject({ method: 'DELETE', url: '/api/clis/test-nospawn' });
});
it('editing a custom entry forgets BOTH its old and new binaries, and its id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-rebin', label: 'X', shortBadge: 'X', binaries: ['old-bin'], argv: ['old-bin'] },
});
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
const res = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-rebin',
payload: { label: 'X', shortBadge: 'X', binaries: ['new-bin'], argv: ['new-bin'] },
});
expect(res.statusCode).toBe(200);
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['old-bin', 'new-bin']);
expect(invalidateIdSpy).toHaveBeenCalledWith('test-rebin');
await app.inject({ method: 'DELETE', url: '/api/clis/test-rebin' });
});
it('deleting a custom entry forgets its binaries and id, so a same-named re-create starts clean', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-forget', label: 'X', shortBadge: 'X', binaries: ['gone-bin'], argv: ['gone-bin'] },
});
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
await app.inject({ method: 'DELETE', url: '/api/clis/test-forget' });
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['gone-bin']);
expect(invalidateIdSpy).toHaveBeenCalledWith('test-forget');
});
});
/**
* The #476 review's must-fix items for the writer. Each reproduces the failure it reported:
* a corrupt hand-edit overwritten by one toggle, parallel toggles lost to a shared temp file,
* and a file the reader refuses rewritten as trusted 0600 config.
*/
describe('registry writes are serialized and never clobber a file the reader would refuse', () => {
beforeEach(() => {
spawnMock.mockReset();
});
/** Put the override file back to "absent" so later tests start from stock. */
function clearRegistryFile(): void {
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
}
it('refuses to overwrite a clis.json that does not parse, and leaves it untouched', async () => {
enableCliManagement();
mkdirSync(dirname(registryFilePath()), { recursive: true });
const handEdit = '{ "schemaVersion": 1, "clis": { "grok": { "accent": "#123456" }, }';
writeFileSync(registryFilePath(), handEdit, { mode: 0o600 });
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/pi', payload: { enabled: false } });
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('not valid JSON');
expect(readFileSync(registryFilePath(), 'utf-8')).toBe(handEdit);
clearRegistryFile();
});
it.skipIf(process.platform === 'win32')(
'refuses to rewrite a clis.json with group/world permission bits, naming the chmod fix',
async () => {
enableCliManagement();
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(registryFilePath(), JSON.stringify({ schemaVersion: 1, clis: {} }));
chmodSync(registryFilePath(), 0o644);
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/pi', payload: { enabled: false } });
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('chmod 600');
// Still the refused mode: the write did not turn it into trusted config.
expect(statSync(registryFilePath()).mode & 0o777).toBe(0o644);
clearRegistryFile();
}
);
it('keeps every one of several parallel toggles, with no failures', async () => {
enableCliManagement();
clearRegistryFile();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const ids = ['grok', 'pi', 'omp', 'gemini'];
const results = await Promise.all(
ids.map((id) => app.inject({ method: 'PUT', url: `/api/clis/${id}`, payload: { enabled: false } }))
);
expect(results.map((r) => r.statusCode)).toEqual(ids.map(() => 200));
const onDisk = JSON.parse(readFileSync(registryFilePath(), 'utf-8')) as {
clis: Record<string, { enabled?: boolean }>;
};
for (const id of ids) {
expect(onDisk.clis[id]?.enabled).toBe(false);
expect(getCli(id)?.enabled).toBe(false);
}
clearRegistryFile();
});
it('editing a disabled custom entry keeps it disabled when the body omits enabled', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-keep-off', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
await app.inject({ method: 'PUT', url: '/api/clis/test-keep-off', payload: { enabled: false } });
const update = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-keep-off',
payload: { label: 'Renamed', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(update.statusCode).toBe(200);
expect(getCli('test-keep-off')?.enabled).toBe(false);
expect(getCli('test-keep-off')?.label).toBe('Renamed');
await app.inject({ method: 'DELETE', url: '/api/clis/test-keep-off' });
});
it('answers 409 to a second install of the same CLI while the first is still running', async () => {
enableCliManagement();
let finish: (code: number) => void = () => {};
spawnMock.mockImplementation(() => {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
finish = (code) => child.emit('close', code);
return child;
});
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const first = app.inject({ method: 'POST', url: '/api/clis/grok/install' });
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
const second = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(second.json().errorCode).toBe('CONFLICT');
expect(spawnMock).toHaveBeenCalledTimes(1);
finish(0);
expect((await first).statusCode).toBe(200);
// The guard is released once the first finishes.
spawnMock.mockImplementation(() => fakeInstallChild(0));
const third = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(third.statusCode).toBe(200);
});
it('hands the install script an environment with every CODEMAN_* variable stripped', async () => {
enableCliManagement();
process.env.CODEMAN_TEST_SECRET = 'do-not-leak';
try {
spawnMock.mockImplementation(() => fakeInstallChild(0));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
const env = spawnMock.mock.calls[0][1].env as NodeJS.ProcessEnv;
expect(Object.keys(env).filter((k) => k.startsWith('CODEMAN_'))).toEqual([]);
expect(env.PATH).toBe(process.env.PATH);
} finally {
delete process.env.CODEMAN_TEST_SECRET;
}
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
});
it('redirects npm installs to the persistent HOME inside the Compose container', () => {
expect(
installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/opt/codeman-cli' })
).toEqual({ HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/home/codeman/.local' });
});
});
/**
* #343 review, finding 2: the run-mode allowlist used to be computed once at import, so a
* CLI toggled on in Settings still failed POST /api/sessions with INVALID_INPUT until a
* restart. Drives the real toggle/create routes and then the real session-create schema.
*/
describe('a toggle or new custom CLI reaches session-create validation with no restart', () => {
it('disabling grok rejects mode grok at once, and re-enabling accepts it again', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
expect(CreateSessionSchema.safeParse({ mode: 'grok' }).success).toBe(true);
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(CreateSessionSchema.safeParse({ mode: 'grok' }).success).toBe(false);
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
expect(CreateSessionSchema.safeParse({ mode: 'grok' }).success).toBe(true);
});
it('a newly created custom CLI is a valid mode immediately, and stops being one when deleted', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
expect(CreateSessionSchema.safeParse({ mode: 'test-live-mode' }).success).toBe(false);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-live-mode', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(CreateSessionSchema.safeParse({ mode: 'test-live-mode' }).success).toBe(true);
await app.inject({ method: 'DELETE', url: '/api/clis/test-live-mode' });
expect(CreateSessionSchema.safeParse({ mode: 'test-live-mode' }).success).toBe(false);
});
});
/**
* #347 review, finding 5: a custom CLI was API-acceptable but not survivable downstream (a
* remote pane command came out as `cd <path> && undefined`). #476 makes custom entries
* creatable from Settings, so pin that one created here launches everywhere it can run.
*/
describe('a custom CLI created through the API launches locally, over ssh and in docker', () => {
it('renders its argv locally and its binary for the remote/docker overlays', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-launch', label: 'X', shortBadge: 'X', binaries: ['my-agent'], argv: ['my-agent', '--yolo'] },
});
const entry = getCli('test-launch')!;
const mode = 'test-launch' as Parameters<typeof defaultRemoteCommandForMode>[0];
expect(buildSpawnCommandFromRegistry(entry, { mode, sessionId: 'sid' })).toBe('my-agent --yolo');
expect(defaultRemoteCommandForMode(mode)).toContain('my-agent');
expect(defaultRemoteCommandForMode(mode)).not.toContain('undefined');
expect(defaultDockerCommandForMode(mode)).toBe('exec my-agent');
await app.inject({ method: 'DELETE', url: '/api/clis/test-launch' });
});
});