mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Measured on 1.47.0 with the same "spawn a shell worker, echo, read, delete" task: DeepSeek Harness and OpenCode already pass end to end; Codex 0.162 failed three ways, fixed here. - Start Codex 0.162+ with --no-daemon (new noDaemonFlag gate, fail-closed). The shared app-server daemon stopped every launch on an "incompatible feature settings" dialog when another version's daemon was running, and ran commands with another session's CODEMAN_* environment. - Evaluate registry version gates against the gated CLI's own version (resolveSessionCliVersion) instead of claude's for every entry. - Add the opt-in synced setting App Settings > Codex > Allow network access (codexNetworkAccess, default off): --config sandbox_workspace_write.network_access=true, filled as a launch default and clamped off for non-granted owners like the bypass switch. - Skill: carry on with the curl path when `codeman` is not on PATH, re-run a sandbox-blocked call once through the CLI's approval path, name the new setting. Wiki and registry docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
247 lines
12 KiB
TypeScript
247 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
|
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
|
import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
|
import { applyLaunchDefaults } from '../../src/web/launch-defaults.js';
|
|
import { buildCodexCommand } from '../../src/tmux-manager.js';
|
|
import { Session } from '../../src/session.js';
|
|
import { safeRmHomeTree } from '../mocks/index.js';
|
|
import { getDataDir } from '../../src/config/instance.js';
|
|
import { SettingsUpdateSchema } from '../../src/web/schemas.js';
|
|
import { getCli } from '../../src/config/cli-registry/registry.js';
|
|
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
|
|
import type { CodexConfig } from '../../src/types.js';
|
|
|
|
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
|
|
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
|
|
});
|
|
|
|
/** The codexConfig a launch gets from the synced defaults alone (the caller sent `config`). */
|
|
async function resolveCodexDefaults(config: CodexConfig | undefined, customEndpoint = false) {
|
|
return (await applyLaunchDefaults('codex', { codexConfig: config }, customEndpoint)).codexConfig;
|
|
}
|
|
|
|
describe('Codex launch defaults', () => {
|
|
let harness: RouteTestHarness;
|
|
const workingDir = join(homedir(), 'codex-default-test');
|
|
|
|
beforeEach(async () => {
|
|
for (const name of ['docker-hosts.json', 'docker-cases.json', 'remote-hosts.json', 'remote-cases.json']) {
|
|
await rm(join(getDataDir(), name), { force: true });
|
|
}
|
|
await mkdir(workingDir, { recursive: true });
|
|
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
|
|
await writeFile(SETTINGS_PATH, JSON.stringify({ codexModel: 'gpt-6.1', codexReasoningEffort: 'high' }));
|
|
harness = await createRouteTestHarness(registerSessionRoutes);
|
|
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await harness.app.close();
|
|
safeRmHomeTree(CASES_DIR);
|
|
safeRmHomeTree(workingDir);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function createdConfig(url: string, overrides: Record<string, unknown> = {}) {
|
|
const payload =
|
|
url === '/api/sessions'
|
|
? { workingDir, mode: 'codex', ...overrides }
|
|
: { caseName: 'codex-default-test', mode: 'codex', ...overrides };
|
|
const res = await harness.app.inject({ method: 'POST', url, payload });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().success, res.body).not.toBe(false);
|
|
const session = [...harness.ctx.sessions.values()].at(-1) as Session | undefined;
|
|
expect(session).toBeDefined();
|
|
return session!.codexConfig;
|
|
}
|
|
|
|
for (const url of ['/api/sessions', '/api/quick-start']) {
|
|
it(`applies persisted defaults at ${url} and emits the CLI options`, async () => {
|
|
const config = await createdConfig(url);
|
|
expect(config).toMatchObject({ model: 'gpt-6.1', reasoningEffort: 'high' });
|
|
expect(buildCodexCommand(config)).toContain('--model gpt-6.1');
|
|
expect(buildCodexCommand(config)).toContain('--config model_reasoning_effort=high');
|
|
});
|
|
|
|
it(`preserves explicit model and effort at ${url}`, async () => {
|
|
expect(await createdConfig(url, { codexConfig: { model: 'gpt-other', reasoningEffort: 'low' } })).toMatchObject({
|
|
model: 'gpt-other',
|
|
reasoningEffort: 'low',
|
|
});
|
|
});
|
|
}
|
|
|
|
it('leaves empty defaults to Codex and ignores malformed persisted values', async () => {
|
|
for (const settings of [
|
|
{ codexModel: '', codexReasoningEffort: '' },
|
|
{ codexModel: 'bad;command', codexReasoningEffort: 'invalid' },
|
|
]) {
|
|
await writeFile(SETTINGS_PATH, JSON.stringify(settings));
|
|
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
|
|
expect(buildCodexCommand(await resolveCodexDefaults(undefined))).toBe('codex');
|
|
}
|
|
});
|
|
|
|
it('fills Allow network access into every local codex launch, and an explicit value wins', async () => {
|
|
await writeFile(SETTINGS_PATH, JSON.stringify({ codexNetworkAccess: true }));
|
|
expect(await resolveCodexDefaults(undefined)).toEqual({ networkAccess: true });
|
|
expect(buildCodexCommand(await resolveCodexDefaults(undefined))).toBe(
|
|
'codex --config sandbox_workspace_write.network_access=true'
|
|
);
|
|
// The browser sends a codexConfig without the field, so the default still applies.
|
|
expect(await resolveCodexDefaults({ dangerouslyBypassApprovals: false })).toEqual({
|
|
dangerouslyBypassApprovals: false,
|
|
networkAccess: true,
|
|
});
|
|
expect(await resolveCodexDefaults({ networkAccess: false })).toEqual({ networkAccess: false });
|
|
// Off (and a malformed value) fills nothing, so the launch stays byte-identical.
|
|
for (const value of [false, 'yes']) {
|
|
await writeFile(SETTINGS_PATH, JSON.stringify({ codexNetworkAccess: value }));
|
|
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
for (const url of ['/api/sessions', '/api/quick-start']) {
|
|
it(`records Allow network access on a codex session created at ${url}`, async () => {
|
|
await writeFile(SETTINGS_PATH, JSON.stringify({ codexNetworkAccess: true }));
|
|
expect(await createdConfig(url)).toMatchObject({ networkAccess: true });
|
|
});
|
|
}
|
|
|
|
it('keeps defaults out of custom endpoint launches', async () => {
|
|
const config = { model: 'local-model', animations: false };
|
|
expect(await resolveCodexDefaults(config, true)).toBe(config);
|
|
});
|
|
|
|
it('is driven by the registry: codex declares the defaults, and a CLI without them is untouched', async () => {
|
|
// The routes call applyLaunchDefaults for every mode and never ask which CLI it is, so
|
|
// what codex gets is exactly what its entry declares.
|
|
expect(getCli('codex')!.capabilities.launchDefaults).toEqual({
|
|
model: 'codexModel',
|
|
reasoningEffort: 'codexReasoningEffort',
|
|
networkAccess: 'codexNetworkAccess',
|
|
});
|
|
for (const entry of STOCK_CLIS) {
|
|
if ((entry.id as string) === 'codex') continue;
|
|
expect(entry.capabilities.launchDefaults, entry.id).toBeUndefined();
|
|
const configs = { codexConfig: undefined, geminiConfig: { model: 'g' }, piConfig: undefined };
|
|
expect(await applyLaunchDefaults(entry.id, configs), entry.id).toBe(configs);
|
|
}
|
|
// Only the entry's own config object is filled; the rest of the bag passes through.
|
|
const bag = { codexConfig: { animations: false }, geminiConfig: { model: 'g' }, name: 'n' };
|
|
const filled = await applyLaunchDefaults('codex', bag);
|
|
expect(filled).toEqual({
|
|
codexConfig: { animations: false, model: 'gpt-6.1', reasoningEffort: 'high' },
|
|
geminiConfig: { model: 'g' },
|
|
name: 'n',
|
|
});
|
|
expect(filled.geminiConfig).toBe(bag.geminiConfig);
|
|
expect(bag.codexConfig).toEqual({ animations: false });
|
|
// A field the caller sent is never overwritten, one at a time.
|
|
expect(await resolveCodexDefaults({ model: 'mine' })).toEqual({ model: 'mine', reasoningEffort: 'high' });
|
|
expect(await resolveCodexDefaults({ reasoningEffort: 'low' })).toEqual({
|
|
model: 'gpt-6.1',
|
|
reasoningEffort: 'low',
|
|
});
|
|
});
|
|
|
|
it('does not record unused defaults for a Docker quick-start', async () => {
|
|
await writeFile(
|
|
join(getDataDir(), 'docker-hosts.json'),
|
|
JSON.stringify([{ id: 'docker1', label: 'Docker', image: 'codeman/agent:base' }])
|
|
);
|
|
await writeFile(
|
|
join(getDataDir(), 'docker-cases.json'),
|
|
JSON.stringify([
|
|
{
|
|
name: 'codex-default-test',
|
|
type: 'docker',
|
|
hostId: 'docker1',
|
|
hostWorkspacePath: workingDir,
|
|
container: 'codeman-default-test',
|
|
},
|
|
])
|
|
);
|
|
expect(await createdConfig('/api/quick-start')).toBeUndefined();
|
|
const session = [...harness.ctx.sessions.values()].at(-1) as Session;
|
|
expect(session.docker?.containerName).toBe('codeman-default-test');
|
|
expect(session.toState().model).toBeUndefined();
|
|
});
|
|
|
|
it('does not record unused defaults for a remote attach', async () => {
|
|
await writeFile(
|
|
join(getDataDir(), 'remote-hosts.json'),
|
|
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
|
|
);
|
|
expect(
|
|
await createdConfig('/api/sessions', {
|
|
workingDir: undefined,
|
|
attachRemoteSession: { hostId: 'remote1', remoteSessionName: 'codeman-existing' },
|
|
})
|
|
).toBeUndefined();
|
|
const session = [...harness.ctx.sessions.values()].at(-1) as Session;
|
|
expect(session.toState().remote?.hostId).toBe('remote1');
|
|
expect(session.toState().model).toBeUndefined();
|
|
});
|
|
|
|
it('does not record unused defaults for a remote quick-start', async () => {
|
|
await writeFile(
|
|
join(getDataDir(), 'remote-hosts.json'),
|
|
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
|
|
);
|
|
await writeFile(
|
|
join(getDataDir(), 'remote-cases.json'),
|
|
JSON.stringify([{ name: 'codex-default-test', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }])
|
|
);
|
|
expect(await createdConfig('/api/quick-start')).toBeUndefined();
|
|
const session = [...harness.ctx.sessions.values()].at(-1) as Session;
|
|
expect(session.toState().remote?.hostId).toBe('remote1');
|
|
expect(session.toState().model).toBeUndefined();
|
|
});
|
|
|
|
it('accepts, persists, clears and validates synced settings via HTTP', async () => {
|
|
const system = await createRouteTestHarness(registerSystemRoutes);
|
|
try {
|
|
const put = (payload: unknown) => system.app.inject({ method: 'PUT', url: '/api/settings', payload });
|
|
expect((await put({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' })).statusCode).toBe(200);
|
|
const read = await system.app.inject({ method: 'GET', url: '/api/settings' });
|
|
expect(read.json()).toMatchObject({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' });
|
|
expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400);
|
|
expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400);
|
|
expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200);
|
|
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
|
|
} finally {
|
|
await system.app.close();
|
|
}
|
|
});
|
|
|
|
it('the App Settings client check mirrors SettingsUpdateSchema.codexModel and runs before the local write', () => {
|
|
// SettingsUpdateSchema is .strict(), so a codexModel the schema refuses 400s the
|
|
// WHOLE settings PUT while the toast still says "Settings saved". saveAppSettings()
|
|
// refuses it client-side with a copy of the schema's pattern; a looser copy brings
|
|
// that silent 400 back, a stricter one refuses valid model ids. Length is left
|
|
// out on purpose: the input's maxlength="100" covers .max(100).
|
|
const src = readFileSync(resolve(import.meta.dirname, '../../src/web/public/settings-ui.js'), 'utf8');
|
|
const start = src.indexOf('async saveAppSettings() {');
|
|
expect(start).toBeGreaterThan(-1);
|
|
const body = src.slice(start);
|
|
const m = body.match(/if \(!\/(\^\[[^\]\n]+\]\*\$)\/\.test\(settings\.codexModel\)\)/);
|
|
expect(m).not.toBeNull();
|
|
const client = new RegExp(m![1]);
|
|
for (const v of ['', 'gpt-5.1', 'org/model_1-x', 'gpt-oss:20b', 'a b', 'bad;cmd', '-x', 'é']) {
|
|
expect(client.test(v), v).toBe(SettingsUpdateSchema.safeParse({ codexModel: v }).success);
|
|
}
|
|
const guardAt = body.indexOf(m![0]);
|
|
const writeAt = body.indexOf('this.saveAppSettingsToStorage(settings);');
|
|
expect(writeAt).toBeGreaterThan(-1);
|
|
expect(guardAt).toBeLessThan(writeAt);
|
|
});
|
|
});
|