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

+5 -1
View File
@@ -480,7 +480,11 @@ const capabilitiesSchema = z
launchDefaults: z
.record(
z.string(),
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
z.enum([
'codexModel',
'codexReasoningEffort',
'codexNetworkAccess',
] as const satisfies readonly LaunchDefaultSettingKey[])
)
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
.optional(),
+48 -2
View File
@@ -627,6 +627,7 @@ const CODEX: CliEntry = {
launch: {
params: {
bypassApprovals: { type: 'bool' },
networkAccess: { type: 'bool' },
animations: { type: 'bool' },
model: { type: 'token', pattern: 'model' },
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
@@ -637,7 +638,33 @@ const CODEX: CliEntry = {
id: 'default',
args: [
{ lit: 'codex' },
// Codex 0.162 hands every client's tool commands to ONE shared background
// app-server (`codex app-server --managed-daemon`), started by whichever client
// came first. Two things break inside a Codeman pane, both measured 2026-11-01 on
// 0.162.0: the commands run with the STARTER's environment, so
// `$CODEMAN_SESSION_ID`/`$CODEMAN_API_URL` belong to another session (the codeman
// skill's self-check 404s and its delete guard protects the wrong id), and a daemon
// of another version (a 0.166 one left by a later install) stops every launch on an
// "incompatible feature settings" dialog before the composer appears. `--no-daemon`
// runs this session's own in-process server, which is what a pane per session
// means anyway. Gated at the version it was measured on; an older or unknown
// codex gets nothing (fail-closed), since an unknown flag makes codex exit.
{ flag: '--no-daemon', when: { capabilityGate: 'noDaemonFlag' } },
{ flag: '--dangerously-bypass-approvals-and-sandbox', when: { param: 'bypassApprovals', is: true } },
// Lets commands in codex's workspace-write sandbox open network connections,
// Codeman's own API on loopback included (the codeman skill's curl calls fail
// with "couldn't connect" without it). Narrower than the bypass above: the
// sandbox and the approval prompts stay. Measured on 0.162.0 (`codex sandbox` and
// a live pane): loopback stays blocked under workspace-write until this is set.
// ⚠️ Workspace-write ONLY: a folder codex runs read-only (one it does not trust,
// such as a fresh non-git case) keeps the network closed and asks per command.
// Deliberately not paired with `--sandbox workspace-write`, which would also
// grant write access to a folder codex itself chose not to trust.
{
flag: '--config',
value: 'sandbox_workspace_write.network_access=true',
when: { param: 'networkAccess', is: true },
},
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
@@ -803,7 +830,14 @@ const CODEX: CliEntry = {
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
// `networkAccess` (App Settings → Codex → Allow network access) rides the same path, so
// a codex session started by the codeman skill, `codeman agent spawn` or the MCP server
// gets it as well as one started from the browser. Off (the default) fills nothing.
launchDefaults: {
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
networkAccess: 'codexNetworkAccess',
},
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
@@ -828,7 +862,19 @@ const CODEX: CliEntry = {
// does. codex is the entry where the two names differ (`bypassApprovals` here,
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
//
// `networkAccess` widens the sandbox too (any host, not just Codeman's loopback API), so
// a non-granted owner gets it forced off like the bypass. Only-if-sent is enough here as
// well: an absent value leaves codex's own no-network default, and the launch default
// above is filled BEFORE the clamp runs, so a filled `true` is still clamped.
privilegedParams: [
{ param: 'bypassApprovals', clampTo: false },
{ param: 'networkAccess', clampTo: false },
],
// `--no-daemon` above. Evaluated against codex's OWN version (resolveSessionCliVersion).
gates: {
noDaemonFlag: { minVersion: '0.162.0', failClosed: true },
},
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
+1 -1
View File
@@ -110,7 +110,7 @@ export type ModelConfigResolverName = 'deepseek-route';
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
* key, which the resolver's typing enforces.
*/
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort' | 'codexNetworkAccess';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat =
+20 -12
View File
@@ -22,7 +22,7 @@ import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-
import { matchesPattern } from './config/cli-registry/patterns.js';
import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { compareVersions } from './utils/dependency-checker.js';
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
import { launcherDefaultTarget } from './utils/cli-launcher.js';
import { getCli } from './config/cli-registry/registry.js';
import type {
@@ -183,9 +183,19 @@ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null
export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean {
const entry = getCli(mode);
if (!entry?.capabilities.gates[gate]) return false;
return resolveGatesPassed(entry, () =>
claudeCliVersion !== undefined ? claudeCliVersion : getClaudeCliVersion()
).has(gate);
return resolveGatesPassed(entry, () => gateVersion(entry, claudeCliVersion)).has(gate);
}
/**
* The version a gate of `entry` is measured against: the caller's explicit value (tests, or
* null for "known unknown"), else THIS entry's own local CLI via resolveSessionCliVersion.
* ⚠️ Never `getClaudeCliVersion()` for every entry, which is what this used to do: codex's
* `noDaemonFlag` gate (0.162.0) would then have passed on any claude 2.x and handed an older
* codex a flag it exits on. resolveSessionCliVersion still routes claude to its own probe
* policy (`retryOnTransientFailure`), so a claude launch is unchanged.
*/
function gateVersion(entry: CliEntry, explicit: string | null | undefined): string | null {
return explicit !== undefined ? explicit : resolveSessionCliVersion(entry.id);
}
/**
@@ -248,14 +258,12 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
engineValues.effortSettingsJson = JSON.stringify(settingsObj);
}
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
// "known to be unresolvable" and must not probe. The probe only ever runs from
// resolveGatesPassed, and only for an entry that actually declares a gate, so this stays
// generic without spawning a stray `claude --version` for every other CLI's launch.
const gatesPassed = resolveGatesPassed(entry, () =>
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
);
// Preserves buildSpawnCommand's original fallback: an EXPLICIT `undefined` probes the
// entry's own local CLI (gateVersion: claude's probe for claude, the registry resolver for
// the rest, both null under vitest); an explicit `null` means "known to be unresolvable"
// and must not probe. The probe only ever runs from resolveGatesPassed, and only for an
// entry that actually declares a gate, so a CLI with none never spawns a `--version`.
const gatesPassed = resolveGatesPassed(entry, () => gateVersion(entry, options.claudeCliVersion));
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
}
+3 -2
View File
@@ -1163,8 +1163,9 @@ export function buildSpawnCommand(options: {
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
sessionName?: string;
/**
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
* (getClaudeCliVersion; null under vitest). Tests inject a value here; the
* CLI version for the launch template's version gates (claude's `--name`, codex's
* `--no-daemon`). Omitted = probe the mode's own local CLI (resolveSessionCliVersion;
* null under vitest). The name predates the second gated CLI. Tests inject a value here; the
* docker/remote paths never see this builder's output, which is what keeps the
* gate measuring the RIGHT binary, the local one.
*/
+6
View File
@@ -437,6 +437,12 @@ export interface CodexConfig {
resumeSessionId?: string;
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
dangerouslyBypassApprovals?: boolean;
/**
* Let commands in codex's workspace-write sandbox use the network, Codeman's loopback API
* included (passes --config sandbox_workspace_write.network_access=true). Filled from the
* synced `codexNetworkAccess` setting when unset; clamped off for a non-granted owner.
*/
networkAccess?: boolean;
/** Enable Codex's decorative TUI animations. Disable to reduce remote terminal redraws. */
animations?: boolean;
/** Browser rendering strategy for Codex sessions. Hybrid TUI is the only supported mode. */
+7
View File
@@ -3003,6 +3003,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCodexDangerouslyBypassApprovals"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="codex network access sandbox loopback codeman skill orchestrate spawn">
<div class="set-row-text">
<span class="set-row-label">Allow network access</span>
<span class="set-row-desc">Lets commands in Codex's workspace-write sandbox use the network, which the Codeman skill needs to reach this server. A folder Codex runs read-only (one it does not trust) still asks per command. The sandbox and approvals stay on. Applies to new Codex sessions.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCodexNetworkAccess"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="codex animations status effects">
<div class="set-row-text">
<span class="set-row-label">Animated status effects</span>
+2
View File
@@ -697,6 +697,7 @@ Object.assign(CodemanApp.prototype, {
// that does have it.
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
settings.codexDangerouslyBypassApprovals ?? false;
document.getElementById('appSettingsCodexNetworkAccess').checked = settings.codexNetworkAccess ?? false;
document.getElementById('appSettingsCodexAnimations').checked =
settings.codexAnimationsEnabled ?? false;
document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? '';
@@ -3072,6 +3073,7 @@ Object.assign(CodemanApp.prototype, {
codexModel: document.getElementById('appSettingsCodexModel').value.trim(),
codexReasoningEffort: document.getElementById('appSettingsCodexReasoningEffort').value,
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
codexNetworkAccess: document.getElementById('appSettingsCodexNetworkAccess').checked,
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
+2
View File
@@ -341,6 +341,7 @@ const CodexConfigSchema = z
.regex(/^[a-zA-Z0-9_-]+$/)
.optional(),
dangerouslyBypassApprovals: z.boolean().optional(),
networkAccess: z.boolean().optional(),
animations: z.boolean().optional(),
renderMode: z
.enum(['scrollback', 'hybrid'])
@@ -1685,6 +1686,7 @@ export const SettingsUpdateSchema = z
.optional(),
codexReasoningEffort: z.enum(['', ...CODEX_REASONING_EFFORTS]).optional(),
codexDangerouslyBypassApprovals: z.boolean().optional(),
codexNetworkAccess: z.boolean().optional(),
codexAnimationsEnabled: z.boolean().optional(),
// Terminal history and retention
terminalScrollbackLines: z