feat(codex): let Codex sessions drive other sessions through the codeman skill

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>
This commit is contained in:
Codeman maintainer committed 2026-11-01 06:21:38 +01:00
1 parent f1f7eec4d6
commit 87c656283d
20 files changed
+284 -34

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
/**
* @fileoverview A launch gate is measured against its OWN CLI's version.
*
* `capabilities.gates` used to be resolved with `getClaudeCliVersion()` for every entry,
* because claude was the only CLI with a gate. Codex's `noDaemonFlag` (0.162.0) would then
* have passed on any claude 2.x and handed an older codex `--no-daemon`, which makes codex
* exit on launch. The bridge now asks `resolveSessionCliVersion(entry.id)`; this pins that
* with both resolvers mocked to DIFFERENT versions.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const versions: Record<string, string | null> = {};
vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>();
return { ...actual, resolveSessionCliVersion: vi.fn((mode: string) => versions[mode] ?? null) };
});
vi.mock('../src/utils/claude-cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/claude-cli-resolver.js')>();
return { ...actual, getClaudeCliVersion: vi.fn(() => '2.1.316') };
});
const { getCli } = await import('../src/config/cli-registry/registry.js');
const { buildSpawnCommandFromRegistry, launchGatePasses } = await import('../src/session-cli-registry-bridge.js');
const SID = '00000000-0000-4000-8000-000000000000';
function codexCommand(): string | undefined {
return buildSpawnCommandFromRegistry(getCli('codex')!, { mode: 'codex', sessionId: SID });
}
describe('launch gates read the gated CLI version', () => {
beforeEach(() => {
for (const key of Object.keys(versions)) delete versions[key];
});
it("does not pass codex's gate on claude's version", () => {
versions.claude = '2.1.316';
versions.codex = '0.161.0';
expect(codexCommand()).toBe('codex');
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(false);
});
it("passes codex's gate on codex's own version", () => {
versions.codex = '0.162.0';
expect(codexCommand()).toBe('codex --no-daemon');
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(true);
});
it('fails closed when the codex version is unknown', () => {
versions.claude = '2.1.316';
expect(codexCommand()).toBe('codex');
});
it("still measures claude's gates on claude's version", () => {
versions.claude = '2.1.316';
versions.codex = '0.1.0';
expect(launchGatePasses('claude', 'nameFlag')).toBe(true);
});
});
+1 -1
View File
@@ -391,7 +391,7 @@ describe('cross-field integrity', () => {
// shipped entry must be on the param side of that line.
const codex = STOCK_CLIS.find((e) => (e.id as string) === 'codex');
expect(codex).toBeDefined();
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals']);
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals', 'networkAccess']);
expect(codex!.launch.legacyConfigAliases?.bypassApprovals).toBe('dangerouslyBypassApprovals');
const wrong = baseEntry('codex');
+24
View File
@@ -182,6 +182,30 @@ describe('codex', () => {
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
);
});
it('emits the sandbox network-access config only when asked', () => {
expect(cx({ networkAccess: true })).toBe('codex --config sandbox_workspace_write.network_access=true');
expect(cx({ networkAccess: false })).toBe('codex');
});
// The `claudeCliVersion` option is the version every gate of THIS entry is measured
// against (the name predates a second gated CLI); undefined probes codex itself.
const cxv = (version: string | null, codexConfig?: SpawnBridgeOptions['codexConfig']) =>
render({ mode: 'codex', sessionId: SID, codexConfig, claudeCliVersion: version });
it('runs without the shared app-server daemon from 0.162.0, and fails closed below or unknown', () => {
expect(cxv('0.162.0')).toBe('codex --no-daemon');
expect(cxv('0.166.0')).toBe('codex --no-daemon');
expect(cxv('0.161.9')).toBe('codex');
expect(cxv(null)).toBe('codex');
});
it('keeps every flag ahead of the resume subcommand with the daemon flag on', () => {
expect(cxv('0.162.0', { dangerouslyBypassApprovals: true, networkAccess: true, resumeSessionId: 'roll_42' })).toBe(
'codex --no-daemon --dangerously-bypass-approvals-and-sandbox ' +
'--config sandbox_workspace_write.network_access=true resume roll_42'
);
});
});
describe('gemini', () => {
+21 -2
View File
@@ -99,7 +99,7 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
undefined,
{ alwaysApprove: true, model: 'grok-4.5' }
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, model: 'gpt-5' });
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, networkAccess: false, model: 'gpt-5' });
expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: false, model: 'gemini-3-pro' });
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
});
@@ -134,7 +134,26 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
undefined,
undefined
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' });
expect(out.codexConfig).toEqual({
dangerouslyBypassApprovals: false,
networkAccess: false,
reasoningEffort: 'xhigh',
});
});
it("forces a non-granted owner's codex network access off, as when the launch default filled it", async () => {
// applyLaunchDefaults runs BEFORE this clamp, so App Settings → Allow network access
// arrives here as an ordinary `networkAccess: true` in a sent config. It widens the
// sandbox to every host, so a non-granted owner must not get it.
const out = await _clampExternalCliBypassForOwner(
'peon',
{ networkAccess: true },
undefined,
undefined,
undefined,
undefined
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, networkAccess: false });
});
it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => {
@@ -89,6 +89,32 @@ describe('Codex launch defaults', () => {
}
});
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);
@@ -100,6 +126,7 @@ describe('Codex launch defaults', () => {
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;