mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview
# Conflicts: # CLAUDE.md # config/test-suites.ts
This commit is contained in:
@@ -104,6 +104,20 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
|
||||
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
|
||||
});
|
||||
|
||||
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
|
||||
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
|
||||
// fields would drop the effort here without a word.
|
||||
const out = await _clampExternalCliBypassForOwner(
|
||||
'peon',
|
||||
{ dangerouslyBypassApprovals: true, reasoningEffort: 'xhigh' },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' });
|
||||
});
|
||||
|
||||
it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
|
||||
expect(out.codexConfig).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @fileoverview Route tests for /api/mcp-sync. The feature is OPT-IN (`mcpSyncEnabled`, default
|
||||
* OFF): both verbs answer 403 until it is on. Only CLIs that are ENABLED in the registry take
|
||||
* part, and only if installed or already configured; enabled agent CLIs with no known MCP
|
||||
* config are reported as unsupported.
|
||||
*
|
||||
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, so each test wipes the config files it
|
||||
* creates. Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerMcpSyncRoutes } from '../../src/web/routes/mcp-sync-routes.js';
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
|
||||
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
|
||||
|
||||
// Which CLIs are installed on the machine running the tests must not decide the outcome: nothing
|
||||
// is installed, so only a CLI whose config file exists takes part.
|
||||
// Lets a test hold the module's real apply lock open: the first apply parks inside the mock
|
||||
// (after taking the lock) until released, so a second POST deterministically overlaps it.
|
||||
const hold = vi.hoisted(() => ({ release: null as null | (() => void), entered: null as null | (() => void) }));
|
||||
vi.mock('../../src/mcp-sync.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/mcp-sync.js')>();
|
||||
return {
|
||||
...actual,
|
||||
syncMcpServers: async (...args: Parameters<typeof actual.syncMcpServers>) => {
|
||||
if (!hold.release || !args[1].apply) return actual.syncMcpServers(...args);
|
||||
const parked = new Promise<void>((resolve) => (hold.release = resolve));
|
||||
hold.entered?.();
|
||||
const pending = actual.syncMcpServers(...args); // takes the lock synchronously
|
||||
await parked;
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Nothing is installed unless a test adds the id here.
|
||||
const installed = vi.hoisted(() => new Set<string>());
|
||||
vi.mock('../../src/utils/cli-installed-probes.js', () => ({
|
||||
probeStockCliAvailability: async () => ({}),
|
||||
isCliEntryInstalled: (e: { id: string }) => installed.has(e.id),
|
||||
}));
|
||||
|
||||
// The route follows each CLI's relocation env var (CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME,
|
||||
// ...) from process.env, so the runner's own values (CI images set XDG_CONFIG_HOME) must never
|
||||
// aim a test write outside the temp HOME. Cleared before every test, restored after the file.
|
||||
const RELOCATION_VARS = STOCK_CLIS.flatMap((e) => {
|
||||
const envVar = e.capabilities.mcpConfig?.relocation?.envVar;
|
||||
return envVar ? [envVar] : [];
|
||||
});
|
||||
const savedEnv = Object.fromEntries(RELOCATION_VARS.map((k) => [k, process.env[k]]));
|
||||
afterAll(() => {
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
const home = () => homedir();
|
||||
const write = (rel: string, text: string) => {
|
||||
const f = join(home(), rel);
|
||||
mkdirSync(dirname(f), { recursive: true });
|
||||
writeFileSync(f, text);
|
||||
};
|
||||
const setEnabled = (on: boolean | undefined) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { mcpSyncEnabled: on }));
|
||||
};
|
||||
const disable = (...ids: string[]) => {
|
||||
const file = registryFilePath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const clis = Object.fromEntries(ids.map((id) => [id, { enabled: false }]));
|
||||
writeFileSync(file, JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
|
||||
reloadCliRegistry();
|
||||
};
|
||||
|
||||
const CLAUDE = '.claude.json';
|
||||
const CODEX = '.codex/config.toml';
|
||||
const GEMINI = '.gemini/settings.json';
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of RELOCATION_VARS) delete process.env[k];
|
||||
installed.clear();
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
for (const d of ['.claude.json', '.codex', '.gemini', '.config', 'relocated'])
|
||||
rmSync(join(home(), d), { recursive: true, force: true });
|
||||
write(CLAUDE, JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
|
||||
// Codex and Gemini have been set up on this machine (their config files exist).
|
||||
write(CODEX, 'model = "gpt-5"\n');
|
||||
write(GEMINI, '{}');
|
||||
setEnabled(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync — opt-in', () => {
|
||||
it.each([
|
||||
['absent', undefined],
|
||||
['false', false],
|
||||
])('answers 403 on both verbs and writes nothing while the setting is %s', async (_label, value) => {
|
||||
setEnabled(value);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
expect(res.json().error).toMatch(/disabled/i);
|
||||
}
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('applies the toggle on the next request, with no restart', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
setEnabled(false);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(403);
|
||||
setEnabled(true);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/mcp-sync', () => {
|
||||
it('GET previews without writing', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.applied).toBe(false);
|
||||
expect(body.data.targets.find((t: { id: string }) => t.id === 'codex').added).toEqual(['fs']);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('POST adds the server to every enabled, set-up CLI', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(res.json().data.applied).toBe(true);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
|
||||
});
|
||||
|
||||
it('never creates config for an enabled CLI that is not installed and has no config file', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const opencode = res.json().data.targets.find((t: { id: string }) => t.id === 'opencode');
|
||||
// Nothing is installed (mocked) and opencode has no config under the temp HOME.
|
||||
expect(opencode.status).toBe('absent');
|
||||
expect(existsSync(join(home(), '.config'))).toBe(false);
|
||||
});
|
||||
|
||||
it('never touches a CLI that is disabled in the registry', async () => {
|
||||
disable('codex');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const ids = res.json().data.targets.map((t: { id: string }) => t.id);
|
||||
expect(ids).not.toContain('codex');
|
||||
expect(ids).toContain('gemini');
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
|
||||
});
|
||||
|
||||
it('lists installed, enabled agent CLIs without MCP support, and omits disabled, uninstalled ones and the shell', async () => {
|
||||
installed.add('grok').add('pi');
|
||||
disable('pi');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const { unsupported } = (await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().data;
|
||||
expect(unsupported).toEqual(['Grok']);
|
||||
});
|
||||
|
||||
it('follows CODEX_HOME from the server env instead of writing the default ~/.codex', async () => {
|
||||
const codexHome = join(home(), 'relocated/codex');
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
write('relocated/codex/config.toml', 'model = "gpt-5"\n');
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.file).toBe(join(codexHome, 'config.toml'));
|
||||
expect(codex.added).toEqual(['fs']);
|
||||
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('reports a relative CODEX_HOME as skipped and writes no codex file', async () => {
|
||||
process.env.CODEX_HOME = 'relative/codex';
|
||||
installed.add('codex');
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.status).toBe('skipped');
|
||||
expect(codex.error).toMatch(/CODEX_HOME/);
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('never echoes the text of a config file it cannot parse', async () => {
|
||||
write(CODEX, 'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n');
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
|
||||
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
|
||||
expect(codex.status).toBe('unreadable');
|
||||
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
|
||||
expect(res.body).not.toContain('lin_SECRET_abc');
|
||||
});
|
||||
|
||||
it('never returns env values or headers', async () => {
|
||||
write(
|
||||
CLAUDE,
|
||||
JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sekrit-value' } } } })
|
||||
);
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(res.body).not.toContain('sekrit-value');
|
||||
});
|
||||
|
||||
it('answers 409 to an apply that overlaps another, and a later apply succeeds', async () => {
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
|
||||
hold.release = () => undefined;
|
||||
const entered = new Promise<void>((resolve) => (hold.entered = resolve));
|
||||
const first = app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
await entered; // the first apply now holds the lock
|
||||
const release = hold.release;
|
||||
hold.release = null; // the overlapping request goes straight to the real function
|
||||
const second = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
|
||||
expect(second.statusCode).toBe(409);
|
||||
expect(second.json().errorCode).toBe('CONFLICT');
|
||||
release?.();
|
||||
expect((await first).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/mcp-sync' })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('multi-user: a non-admin is refused on both verbs and nothing is written', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
|
||||
authUser: { username: 'bob', role: 'user' },
|
||||
});
|
||||
const before = readFileSync(join(home(), CODEX), 'utf8');
|
||||
for (const method of ['GET', 'POST'] as const) {
|
||||
const res = await app.inject({ method, url: '/api/mcp-sync' });
|
||||
expect(res.statusCode, method).toBe(403);
|
||||
}
|
||||
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('multi-user: an admin is allowed', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
|
||||
authUser: { username: 'root', role: 'admin' },
|
||||
});
|
||||
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @fileoverview `model` on POST /api/sessions — a Claude model for one session only.
|
||||
*
|
||||
* Claude's model reaches disk only through `modelOverride`, which writes it into the
|
||||
* case's `.claude/settings.local.json` for every later run there. `model` is the
|
||||
* per-session counterpart: it goes out as `claude --model <id>`, wins over the app-wide
|
||||
* default, and writes nothing. What the tests read is the model the session hands the
|
||||
* mux when it starts, which is what becomes the `--model` flag.
|
||||
*
|
||||
* Uses app.inject(), so no real HTTP port is needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface Harness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('POST /api/sessions model', () => {
|
||||
let workingDir: string;
|
||||
let harness: Harness;
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDir = await mkdtemp(join(tmpdir(), 'codeman-session-model-'));
|
||||
harness = await createHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Creates a session and starts it, then returns the model it handed the mux. */
|
||||
async function launchedModel(payload: Record<string, unknown>): Promise<unknown> {
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/sessions', payload: { workingDir, ...payload } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const parsed = JSON.parse(res.body);
|
||||
const id = (parsed.data?.session ?? parsed.session).id as string;
|
||||
await harness.app.inject({ method: 'POST', url: `/api/sessions/${id}/interactive`, payload: {} });
|
||||
const calls = harness.ctx.mux.createSession.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
return (calls[calls.length - 1][0] as { model?: string }).model;
|
||||
}
|
||||
|
||||
it('launches a Claude session on the model the caller names', async () => {
|
||||
expect(await launchedModel({ mode: 'claude', model: 'claude-fable-5-1' })).toBe('claude-fable-5-1');
|
||||
});
|
||||
|
||||
it('wins over the app-wide default model', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude', model: 'opus' })).toBe('opus');
|
||||
});
|
||||
|
||||
it('leaves the app-wide default in charge when the caller names none', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude' })).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('launches on `model` while `modelOverride` alone reaches the case file', async () => {
|
||||
// Sent together, each lands where it belongs: the persistent default in the case's
|
||||
// settings.local.json, and this session's model on its launch line. A route that wrote
|
||||
// `model` to disk would put 'opus' in the file; one that ignored it would launch 'sonnet'.
|
||||
expect(await launchedModel({ mode: 'claude', model: 'opus', modelOverride: 'sonnet' })).toBe('opus');
|
||||
const settings = JSON.parse(await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8'));
|
||||
expect(settings.model).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('reads an empty model as no model, as modelOverride does', async () => {
|
||||
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
|
||||
expect(await launchedModel({ mode: 'claude', model: '' })).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('refuses a model for a CLI that takes its model in its own config object', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'codex', model: 'gpt-5' },
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.errorCode).toBe('INVALID_INPUT');
|
||||
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
|
||||
});
|
||||
|
||||
it('rejects a model with characters the launch pattern refuses', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model: 'opus; rm -rf ~' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a flag-shaped model, since the value lands in argv', async () => {
|
||||
for (const model of ['--dangerously-skip-permissions', '-p', '.hidden', '[1m]']) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model },
|
||||
});
|
||||
expect(res.statusCode, model).toBe(400);
|
||||
}
|
||||
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
|
||||
});
|
||||
|
||||
it('still accepts real model ids, aliases and the [1m] suffix', async () => {
|
||||
for (const model of ['claude-fable-5-1', 'opus', 'opus[1m]', 'claude-opus-5-5[1m]']) {
|
||||
expect(await launchedModel({ mode: 'claude', model })).toBe(model);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['model', { model: 'opus' }],
|
||||
['advisorModel', { advisorModel: 'opus' }],
|
||||
])('refuses %s on a remote attach, which launches nothing', async (_field, extra) => {
|
||||
// Refused before the host is looked up, so no remote host needs to exist.
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
mode: 'claude',
|
||||
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.errorCode).toBe('INVALID_INPUT');
|
||||
expect(harness.ctx.sessions.size).toBe(1);
|
||||
});
|
||||
|
||||
it('publishes the launch model on the created claude session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { workingDir, mode: 'claude', model: 'claude-fable-5-1', advisorModel: 'opus' },
|
||||
});
|
||||
const parsed = JSON.parse(res.body);
|
||||
const session = parsed.data?.session ?? parsed.session;
|
||||
expect(session.model).toBe('claude-fable-5-1');
|
||||
expect(session.advisorModel).toBe('opus');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
@@ -165,6 +167,74 @@ describe('session-routes', () => {
|
||||
expect(argv).toContain('-H');
|
||||
});
|
||||
|
||||
describe('newline chord comes from the CLI registry (capabilities.newline)', () => {
|
||||
const sentHex = async (mode: string, key: string): Promise<string[]> => {
|
||||
execFile.mockReset();
|
||||
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
|
||||
cb(null)
|
||||
);
|
||||
const session = harness.ctx._session as unknown as { mode: string };
|
||||
const before = session.mode;
|
||||
session.mode = mode;
|
||||
try {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-session-1/send-key',
|
||||
payload: { key },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
} finally {
|
||||
session.mode = before;
|
||||
}
|
||||
const argv = execFile.mock.calls[0][1] as string[];
|
||||
return argv.slice(argv.indexOf('-H') + 3); // after "-H -t <pane>"
|
||||
};
|
||||
|
||||
it('sends a line feed for Shift+Enter to a CLI that declares nothing', async () => {
|
||||
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
|
||||
expect(await sentHex('opencode', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('sends a line feed to Codex too: no stock CLI declares a chord', async () => {
|
||||
expect(await sentHex('codex', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
describe('a CLI that declares esc-enter (here via a user clis.json override of codex)', () => {
|
||||
beforeEach(() => {
|
||||
const file = registryFilePath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ schemaVersion: 1, clis: { codex: { capabilities: { newline: 'esc-enter' } } } }),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
reloadCliRegistry();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
it('sends Esc+Enter for Shift+Enter, and only to that CLI', async () => {
|
||||
expect(await sentHex('codex', 'S-Enter')).toEqual(['1b', '0d']);
|
||||
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('still sends a line feed for Ctrl+Enter', async () => {
|
||||
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
|
||||
});
|
||||
});
|
||||
|
||||
it('always sends a line feed for Ctrl+Enter', async () => {
|
||||
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
|
||||
expect(await sentHex('claude', 'C-Enter')).toEqual(['0a']);
|
||||
});
|
||||
|
||||
it('falls back to a line feed for a mode the registry does not know', async () => {
|
||||
expect(await sentHex('no-such-cli', 'S-Enter')).toEqual(['0a']);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
|
||||
execFile.mockReset();
|
||||
const res = await harness.app.inject({
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @fileoverview /api/webhook: the webhook-notification config. The URL is a bearer secret, so it
|
||||
* is never returned and the routes are admin only in multi-user mode.
|
||||
* Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerWebhookRoutes } from '../../src/web/routes/webhook-routes.js';
|
||||
import { readWebhookConfig, webhookConfigPath, WebhookNotifier, type WebhookFetch } from '../../src/webhook-notify.js';
|
||||
|
||||
const SECRET_URL = 'https://hooks.slack.com/services/T0/B0/SUPERSECRET';
|
||||
|
||||
let dir: string;
|
||||
let fetchImpl: ReturnType<typeof vi.fn<WebhookFetch>>;
|
||||
|
||||
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
|
||||
const notifier = new WebhookNotifier(() => readWebhookConfig(dir), fetchImpl);
|
||||
const h = await createRouteTestHarness(
|
||||
(app) => registerWebhookRoutes(app, { notifier, configDir: dir, hostTitle: () => 'codeman:test' }),
|
||||
authUser ? { authUser } : undefined
|
||||
);
|
||||
return { ...h, notifier };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'webhook-routes-'));
|
||||
fetchImpl = vi.fn<WebhookFetch>(async () => new Response('', { status: 200 }));
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/webhook', () => {
|
||||
it('starts disabled with no URL', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(res.json().data).toEqual({
|
||||
enabled: false,
|
||||
kind: 'ntfy',
|
||||
scope: 'attention',
|
||||
hasUrl: false,
|
||||
urlMasked: '',
|
||||
lastResult: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/webhook', () => {
|
||||
it('saves the config, masks the URL in every response, and writes the file 0600', async () => {
|
||||
const { app } = await harness();
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/webhook',
|
||||
payload: { enabled: true, kind: 'slack', scope: 'all', url: SECRET_URL },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().data).toMatchObject({ enabled: true, kind: 'slack', scope: 'all', hasUrl: true });
|
||||
expect(put.json().data.urlMasked).toBe('https://hooks.slack.com/•••');
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
for (const body of [put.body, get.body]) expect(body).not.toMatch(/SUPERSECRET|T0\/B0/);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('changing kind or scope keeps the saved URL (the secret is never re-sent)', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'discord' } });
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ kind: 'discord', url: SECRET_URL, enabled: true });
|
||||
});
|
||||
|
||||
it('an empty url clears it', async () => {
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.json().data).toMatchObject({ hasUrl: false, urlMasked: '' });
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['enabling with no URL', { enabled: true }, /Add a webhook URL/],
|
||||
['a metadata address', { url: 'http://169.254.169.254/latest' }, /metadata|link-local/],
|
||||
['a non-http scheme', { url: 'file:///etc/passwd' }, /http and https/],
|
||||
['credentials in the URL', { url: 'https://u:p@example.com/x' }, /credentials/],
|
||||
['clearing the URL while enabled', null, /Add a webhook URL/],
|
||||
])('rejects %s with 400 and saves nothing', async (_label, payload, why) => {
|
||||
const { app } = await harness();
|
||||
if (payload === null) {
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
|
||||
return;
|
||||
}
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(why);
|
||||
expect(await readWebhookConfig(dir)).toMatchObject({ enabled: false, url: '' });
|
||||
});
|
||||
|
||||
it('rejects unknown keys and bad enums (strict schema)', async () => {
|
||||
const { app } = await harness();
|
||||
for (const payload of [{ extra: 1 }, { kind: 'telegram' }, { scope: 'everything' }, { enabled: 'yes' }]) {
|
||||
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
|
||||
expect(res.statusCode, JSON.stringify(payload)).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/test', () => {
|
||||
it('refuses with 400 until a URL is saved', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends one message with the saved config, even while notifications are disabled', async () => {
|
||||
const { app, notifier } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'generic', url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: true, status: 200 });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(fetchImpl.mock.calls[0][1].body as string)).toMatchObject({
|
||||
host: 'codeman:test',
|
||||
event: 'webhook:test',
|
||||
});
|
||||
expect(notifier.lastResult?.ok).toBe(true);
|
||||
expect(res.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
it('reports a delivery failure in data (HTTP 200) without leaking the URL, and GET shows it as the last result', async () => {
|
||||
fetchImpl.mockImplementation(async () => new Response('', { status: 404 }));
|
||||
const { app } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
|
||||
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
|
||||
expect(get.json().data.lastResult).toMatchObject({ ok: false, status: 404 });
|
||||
expect(get.body).not.toContain('SUPERSECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-user', () => {
|
||||
it.each([
|
||||
['GET', '/api/webhook'],
|
||||
['PUT', '/api/webhook'],
|
||||
['POST', '/api/webhook/test'],
|
||||
] as const)('refuses a non-admin on %s %s and touches nothing', async (method, url) => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'bob', role: 'user' });
|
||||
const res = await app.inject({ method, url, payload: method === 'PUT' ? { url: SECRET_URL } : undefined });
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect((await readWebhookConfig(dir)).url).toBe('');
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an admin', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'root', role: 'admin' });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/webhook' })).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user