mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(agent): make coordination routes opt-in and address review
Gate inspect, stage and submit behind the synced agentCoordinationEnabled setting (default OFF, App Settings > Cross-session features, zh-CN strings). While off, all three routes answer 403 FORBIDDEN naming the setting, checked before session lookup; the CLI verbs print it verbatim. Remember the attributed Codex rollout per session and re-verify only its head on later calls, rescanning only date directories not older than it for a /new conflict. The first walk reads 1 KiB prefixes first, so a store of 2,100+ unrelated rollouts still attributes. Answer unexpected inspection errors with 422 OPERATION_FAILED and log the session id and error code only. Read the guarded composer glyph and footer from the CLI registry: codex workDetect.promptGlyph and a new workDetect.composerFooterLine capability, compiled through compileVersionRegex. Codex footer effort words are one shared list with modelDetect.screenLine. Fix the wiki API reference link, frame the versioning policy entry as a case of the experimental rule, join the CLAUDE.md Agent CLI paragraph, and update the changeset, skill reference and architecture notes.
This commit is contained in:
1 parent
1a2c3be0ea
commit
c78635a3d3
32 files changed
+848
-124
No files matched your search
@@ -1,9 +1,13 @@
|
||||
/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { findExactCodexConversation, resolveEffectiveCodexHome } from '../src/agent-codex-identity.js';
|
||||
import {
|
||||
__clearCodexAttributionMemo,
|
||||
findExactCodexConversation,
|
||||
resolveEffectiveCodexHome,
|
||||
} from '../src/agent-codex-identity.js';
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
@@ -154,3 +158,80 @@ it.each(['workspace', 'metadata'] as const)(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
describe('remembered attribution on a large, never-pruned store', () => {
|
||||
beforeEach(() => __clearCodexAttributionMemo());
|
||||
const OWNED = '33333333-3333-4333-8333-333333333333';
|
||||
const NEWER = '44444444-4444-4444-8444-444444444444';
|
||||
/** codex's own layout: sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl */
|
||||
async function dated(f: Awaited<ReturnType<typeof fixture>>, day: string, id: string, payload: object = {}) {
|
||||
const dir = join(f.home, 'sessions', ...day.split('/'));
|
||||
await mkdir(dir, { recursive: true });
|
||||
const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`);
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload },
|
||||
}) + '\n'
|
||||
);
|
||||
return path;
|
||||
}
|
||||
/** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */
|
||||
async function unrelated(f: Awaited<ReturnType<typeof fixture>>, count: number) {
|
||||
const instructions = 'x'.repeat(19 * 1024);
|
||||
for (let start = 0; start < count; start += 200) {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(200, count - start) }, (_, k) => {
|
||||
const n = start + k;
|
||||
const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`;
|
||||
return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, {
|
||||
originator: 'codex_cli',
|
||||
instructions,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
const small = { maxEntries: 20, maxBytes: 1048576 };
|
||||
|
||||
it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => {
|
||||
const f = await fixture();
|
||||
await unrelated(f, 2100);
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
}, 60_000);
|
||||
|
||||
it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => {
|
||||
const f = await fixture();
|
||||
await unrelated(f, 300);
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
// 20 entries cannot cover 300 rollouts in 9 months of day directories: only the
|
||||
// remembered head plus the directories not older than it fit.
|
||||
expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path });
|
||||
__clearCodexAttributionMemo();
|
||||
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still catches a newer owned rollout (codex /new) after remembering one', async () => {
|
||||
const f = await fixture();
|
||||
await unrelated(f, 50);
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
await dated(f, '2026/10/25', NEWER);
|
||||
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops the remembered file when it is replaced, and walks again', async () => {
|
||||
const f = await fixture();
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
await rm(path);
|
||||
await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' });
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
const moved = await dated(f, '2026/10/23', NEWER);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the
|
||||
* experimental inspect/stage/submit routes: the strict settings schema admits it, App
|
||||
* Settings loads and saves it under the same control id, and its row reads in Chinese
|
||||
* (zh-CN) with no English left beyond the command and product names. The route gate itself
|
||||
* is pinned in test/routes/agent-session-routes.test.ts.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
|
||||
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
|
||||
const ID = 'appSettingsAgentCoordination';
|
||||
|
||||
type Api = { configure(o: Record<string, string>): void; start(): void };
|
||||
|
||||
/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */
|
||||
const leftover = (text: string) =>
|
||||
text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
function rowStrings(doc: Document): { label: string; description: string } {
|
||||
const row = doc.getElementById(ID)!.closest('.set-row')!;
|
||||
return {
|
||||
label: row.querySelector('.set-row-label')!.textContent!.trim(),
|
||||
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const source = new JSDOM(INDEX);
|
||||
const english = rowStrings(source.window.document);
|
||||
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ language: 'zh-CN' });
|
||||
const chinese = rowStrings(dom.window.document);
|
||||
|
||||
afterAll(() => {
|
||||
source.window.close();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
describe('agentCoordinationEnabled', () => {
|
||||
it('is a boolean the strict settings schema accepts, and nothing else', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => {
|
||||
expect(INDEX.split(`id="${ID}"`)).toHaveLength(2);
|
||||
expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox');
|
||||
expect(SETTINGS_UI).toContain(
|
||||
`document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;`
|
||||
);
|
||||
expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`);
|
||||
});
|
||||
|
||||
it('sits in the synced Cross-session features group', () => {
|
||||
const group = source.window.document.getElementById(ID)!.closest('.set-group')!;
|
||||
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features');
|
||||
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced');
|
||||
});
|
||||
|
||||
it('finds the row (the translation check is not vacuous)', () => {
|
||||
expect(english.label).toBe('Experimental agent coordination');
|
||||
expect(english.description).toMatch(/^Lets agents read another session's output/);
|
||||
});
|
||||
|
||||
it('translates the label and the description, with no English left', () => {
|
||||
for (const key of ['label', 'description'] as const) {
|
||||
expect(chinese[key]).not.toBe(english[key]);
|
||||
expect(leftover(chinese[key])).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
for (const text of [english.label, english.description]) {
|
||||
expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,18 @@
|
||||
import { beforeEach, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js';
|
||||
import {
|
||||
GuardedStageStore,
|
||||
guardedComposerChrome,
|
||||
readGuardedComposer,
|
||||
readGuardedSnapshot,
|
||||
type GuardedInputTarget,
|
||||
} from '../src/agent-staging.js';
|
||||
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
|
||||
/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */
|
||||
const CODEX = guardedComposerChrome(getCli('codex'))!;
|
||||
const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX);
|
||||
const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX);
|
||||
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
let store: GuardedStageStore;
|
||||
let time: number;
|
||||
@@ -110,7 +121,6 @@ it('does not issue a receipt on failed staging or intervening input during its w
|
||||
});
|
||||
|
||||
it('reads exact composer spaces and refuses wrapped or modal frames', async () => {
|
||||
const { readGuardedCodexComposer } = await import('../src/agent-staging.js');
|
||||
expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world');
|
||||
expect(
|
||||
readGuardedCodexComposer(
|
||||
@@ -122,7 +132,6 @@ it('reads exact composer spaces and refuses wrapped or modal frames', async () =
|
||||
expect(readGuardedCodexComposer('› hello\n')).toBeNull();
|
||||
});
|
||||
it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => {
|
||||
const { readGuardedCodexSnapshot } = await import('../src/agent-staging.js');
|
||||
const { formatPaneSnapshot } = await import('../src/tmux-manager.js');
|
||||
const frame = (text: string, x: number, y = 0) =>
|
||||
formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y });
|
||||
@@ -157,7 +166,6 @@ it('reserves receipt capacity before concurrent stages type', async () => {
|
||||
it.each([...CODEX_REASONING_EFFORTS, 'default'])(
|
||||
'accepts the shared Codex effort word %s in the footer',
|
||||
async (word) => {
|
||||
const { readGuardedCodexComposer } = await import('../src/agent-staging.js');
|
||||
expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello');
|
||||
}
|
||||
);
|
||||
@@ -168,3 +176,22 @@ it('refuses messages that reach the available pane columns before typing', async
|
||||
expect(writes).toEqual([]);
|
||||
await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false });
|
||||
});
|
||||
|
||||
it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => {
|
||||
const codex = getCli('codex')!.capabilities.workDetect!;
|
||||
expect(CODEX.promptGlyph).toBe(codex.promptGlyph);
|
||||
expect(CODEX.footerLine.source).toBe(codex.composerFooterLine);
|
||||
// Another glyph drives the same reader: nothing in it is codex-specific.
|
||||
const other = { promptGlyph: '>>', footerLine: CODEX.footerLine };
|
||||
expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello');
|
||||
expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull();
|
||||
const declaring = listClis()
|
||||
.filter((entry) => entry.capabilities.workDetect?.composerFooterLine)
|
||||
.map((entry) => entry.id);
|
||||
expect(declaring).toEqual(['codex']);
|
||||
expect(guardedComposerChrome(getCli('claude'))).toBeNull();
|
||||
// A pattern the ReDoS guard refuses yields no chrome, so no guarded input.
|
||||
const unsafe = structuredClone(getCli('codex')!);
|
||||
unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b';
|
||||
expect(guardedComposerChrome(unsafe)).toBeNull();
|
||||
});
|
||||
@@ -92,3 +92,36 @@ it('registers required file and confirmation options and bounded inspect flags',
|
||||
?.mandatory
|
||||
).toBe(true);
|
||||
});
|
||||
it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
|
||||
try {
|
||||
const path = join(dir, 'message');
|
||||
await writeFile(path, 'hello');
|
||||
const message =
|
||||
'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
|
||||
for (const json of [false, true]) {
|
||||
const lines: string[] = [];
|
||||
const refusing: AgentDeps = {
|
||||
ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
|
||||
json,
|
||||
now: () => 1,
|
||||
io: { out: (line) => lines.push(line), err: (line) => lines.push(line) },
|
||||
request: async () => ({
|
||||
status: 403,
|
||||
text: '',
|
||||
json: { success: false, error: message, errorCode: 'FORBIDDEN' },
|
||||
}),
|
||||
};
|
||||
expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1);
|
||||
expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1);
|
||||
expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1);
|
||||
expect(lines).toHaveLength(3);
|
||||
for (const line of lines) {
|
||||
const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line;
|
||||
expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -184,6 +184,27 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional composerFooterLine to the same guard', () => {
|
||||
// Run over the rows under the composer each time guarded input reads the pane.
|
||||
for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
composerFooterLine,
|
||||
};
|
||||
}, 'guarded input tests it against pane rows a clis.json override can supply');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts every shipped composerFooterLine', () => {
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const src = entry.capabilities.workDetect?.composerFooterLine;
|
||||
if (!src) continue;
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional blockedLine to the same guard', () => {
|
||||
// Run over the foot of a capture at every settle and on a timer while a dialog is
|
||||
// up, and a clis.json override can set it.
|
||||
|
||||
@@ -1,11 +1,118 @@
|
||||
/** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { registerAgentSessionRoutes } from '../../src/web/routes/agent-session-routes.js';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AGENT_COORDINATION_DISABLED_MESSAGE,
|
||||
registerAgentSessionRoutes,
|
||||
} from '../../src/web/routes/agent-session-routes.js';
|
||||
import { InspectionReader } from '../../src/agent-inspection.js';
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
|
||||
let harness: RouteTestHarness;
|
||||
/** The routes are opt-in (`agentCoordinationEnabled`, default OFF); undefined = key absent. */
|
||||
function setEnabled(on: boolean | undefined): void {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { agentCoordinationEnabled: on }));
|
||||
}
|
||||
beforeEach(() => setEnabled(true));
|
||||
afterEach(async () => {
|
||||
await harness?.app.close();
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
});
|
||||
|
||||
describe('the agentCoordinationEnabled gate', () => {
|
||||
const requests = (id: string) => [
|
||||
{ method: 'GET' as const, url: `/api/sessions/${id}/inspect` },
|
||||
{ method: 'GET' as const, url: `/api/sessions/${id}/inspect?maxBytes=0` },
|
||||
{ method: 'POST' as const, url: `/api/sessions/${id}/stage`, payload: { message: 'hello' } },
|
||||
{
|
||||
method: 'POST' as const,
|
||||
url: `/api/sessions/${id}/submit`,
|
||||
payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) },
|
||||
},
|
||||
];
|
||||
it.each([
|
||||
['absent', undefined],
|
||||
['false', false],
|
||||
] as const)('answers 403 naming the setting on every route while it is %s', async (_label, value) => {
|
||||
setEnabled(value);
|
||||
harness = await createRouteTestHarness(registerAgentSessionRoutes);
|
||||
const session = [...harness.ctx.sessions.values()][0];
|
||||
// A session id that does not exist is refused the same way: the gate runs before lookup.
|
||||
for (const request of [...requests(session.id), ...requests('no-such-session')]) {
|
||||
const result = await harness.app.inject(request);
|
||||
expect(result.statusCode).toBe(403);
|
||||
expect(result.json()).toEqual({
|
||||
success: false,
|
||||
error: AGENT_COORDINATION_DISABLED_MESSAGE,
|
||||
errorCode: 'FORBIDDEN',
|
||||
});
|
||||
}
|
||||
expect(AGENT_COORDINATION_DISABLED_MESSAGE).toContain('agentCoordinationEnabled');
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
});
|
||||
it('answers on every route once it is on, with no restart', async () => {
|
||||
setEnabled(false);
|
||||
harness = await createRouteTestHarness(registerAgentSessionRoutes);
|
||||
const session = [...harness.ctx.sessions.values()][0];
|
||||
expect((await harness.app.inject(requests(session.id)[0])).statusCode).toBe(403);
|
||||
setEnabled(true);
|
||||
const [inspect, invalid, stage, submit] = await Promise.all(
|
||||
requests(session.id).map((request) => harness.app.inject(request))
|
||||
);
|
||||
expect(inspect.statusCode).toBe(200);
|
||||
expect(inspect.json().data.source).toBe('pane-snapshot');
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
// The mock session has no characterized composer, so the guards refuse: the route answered.
|
||||
expect(stage.statusCode).toBe(409);
|
||||
expect(submit.statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a non-cursor inspection failure as OPERATION_FAILED and logs only the session and error code', async () => {
|
||||
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const { Session } = await import('../../src/session.js');
|
||||
const root = await mkdtemp(join(tmpdir(), 'agent-inspect-fail-'));
|
||||
const home = join(root, 'codex');
|
||||
await mkdir(join(home, 'sessions'), { recursive: true });
|
||||
const session = new Session({
|
||||
id: 'inspect-fail',
|
||||
workingDir: root,
|
||||
mode: 'codex',
|
||||
envOverrides: { CODEX_HOME: home },
|
||||
});
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
const path = join(home, 'sessions', `rollout-${id}.jsonl`);
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'
|
||||
);
|
||||
harness = await createRouteTestHarness(registerAgentSessionRoutes);
|
||||
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
|
||||
const read = vi
|
||||
.spyOn(InspectionReader.prototype, 'read')
|
||||
.mockRejectedValue(Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { code: 'EACCES' }));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` });
|
||||
expect(read).toHaveBeenCalled();
|
||||
expect(result.statusCode).toBe(422);
|
||||
expect(result.json().errorCode).toBe('OPERATION_FAILED');
|
||||
expect(result.body).not.toContain(path);
|
||||
const logged = warn.mock.calls.map((call) => call.join(' ')).join('\n');
|
||||
expect(logged).toContain(session.id);
|
||||
expect(logged).toContain('EACCES');
|
||||
expect(logged).not.toContain(home);
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
warn.mockRestore();
|
||||
await session.stop(false);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
describe('agent session routes', () => {
|
||||
it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => {
|
||||
|
||||
@@ -45,8 +45,32 @@ describe('WebServer on an ephemeral port', () => {
|
||||
const session = new Session({ id: 'agent-route-wiring', workingDir: process.env.HOME!, mode: 'shell' });
|
||||
const sessions = (server as unknown as { sessions: Map<string, typeof session> }).sessions;
|
||||
sessions.set(session.id, session);
|
||||
const { mkdirSync, rmSync, writeFileSync } = await import('node:fs');
|
||||
const { dirname } = await import('node:path');
|
||||
const { SETTINGS_PATH } = await import('../src/web/route-helpers.js');
|
||||
const setEnabled = (on: boolean) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ agentCoordinationEnabled: on }));
|
||||
};
|
||||
try {
|
||||
const prefix = `http://127.0.0.1:${server.boundPort}/api/v1/sessions/${session.id}`;
|
||||
// Opt-in: off, the /api/v1 alias of every route refuses with 403 FORBIDDEN.
|
||||
setEnabled(false);
|
||||
for (const [method, verb] of [
|
||||
['GET', 'inspect'],
|
||||
['POST', 'stage'],
|
||||
['POST', 'submit'],
|
||||
] as const) {
|
||||
const refused = await fetch(prefix + '/' + verb, {
|
||||
method,
|
||||
...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: '{}' } : {}),
|
||||
});
|
||||
expect(refused.status).toBe(403);
|
||||
const body = await refused.json();
|
||||
expect(body.errorCode).toBe('FORBIDDEN');
|
||||
expect(body.error).toContain('agentCoordinationEnabled');
|
||||
}
|
||||
setEnabled(true);
|
||||
const inspected = await fetch(prefix + '/inspect');
|
||||
expect(inspected.status).toBe(200);
|
||||
expect((await inspected.json()).data.source).toBe('pane-snapshot');
|
||||
@@ -63,6 +87,7 @@ describe('WebServer on an ephemeral port', () => {
|
||||
expect((await response.json()).errorCode).toBe('CONFLICT');
|
||||
}
|
||||
} finally {
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
sessions.delete(session.id);
|
||||
await session.stop(false);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user