Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ Dated, short lessons from real FlyBot work. Agents read this **before coding**.

## Open lessons

### 2026-11-04 — Parsing success is not effective permission proof (Seen 1×)

CLI 0.166 accepts unknown filesystem alias keys. Use supported `:tmpdir`, inspect
loaded legacy overrides, and test actual scratch/candidate behavior with the same
SDK-resolved executable; mock argument equality cannot establish sandbox readiness.


### 2026-11-04 — Missing workerId does not define admission cleanup (Seen 1×)

Binding can fail before execute returns; register reservation ownership and cleanup
Expand Down
2 changes: 2 additions & 0 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Owner authorizes sequential #2336/#2337/#2334/#2346/#2338/#2347/#2331/#2342
implementation, one PR each, tests/memory/docs and validated merge to the delivery
branch. #2336 establishes owned reservation rollback and a durable pre-spawn fence;
unknown execution, workspace/context and current authority remain protected.
#2337 validates the actual SDK-resolved CLI and role profile before inference;
configuration readiness is separate from model/test/exit evidence.
No live task/configuration/credential mutation or deployment is implied.

## Execution recovery — 2026-11-04
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Implement #2336 reservation cleanup first; then #2337 CLI configuration, #2334
environment attribution, #2346 context migration, #2338 receipts, #2347 recovery
routing, #2331 controlled merge and #2342 operational-agent collaboration. Separate
PRs retain existing UI/control-plane, authority and exact exit/evidence contracts.
#2336 is merged; #2337 adds offline actual-CLI readiness and scratch protection.

## Recovery delivery — 2026-11-04

Expand Down
1 change: 1 addition & 0 deletions changelog.d/2337.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Validate the actual Codex CLI and loaded role permission configuration before inference; correct the temporary-directory alias and distinguish configuration capability failures.
97 changes: 97 additions & 0 deletions core/src/host/project-codex-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { dirname, join, delimiter } from 'node:path'
import { createInterface } from 'node:readline'

const failure = code => Object.assign(new Error('Codex configuration is not ready; verify the selected resource CLI version and remove conflicting legacy sandbox settings before retrying'), { code: 'bot/harness-capability', capability: code })
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
const toml = value => object(value) ? '{' + Object.entries(value).filter(([, child]) => child !== undefined).map(([key, child]) => `${JSON.stringify(key)}=${toml(child)}`).join(',') + '}' : Array.isArray(value) ? '[' + value.map(toml).join(',') + ']' : JSON.stringify(value)
export function codexConfigArguments(config, overrides = []) {
const values = []
function flatten(value, prefix = '') {
for (const [key, child] of Object.entries(value)) {
if (child === undefined) continue
const path = prefix ? `${prefix}.${key}` : key
if (object(child) && Object.keys(child).length) flatten(child, path)
else values.push(`${path}=${toml(child)}`)
}
}
flatten(config)
return [...values, ...overrides].flatMap(value => ['--config', value])
}

// Resolve from the same installed SDK dependency graph, never global PATH.
export function codexExecutable(sdkUrl) {
try {
const sdkRequire = createRequire(sdkUrl ?? import.meta.resolve('@openai/codex-sdk'))
const cliRequire = createRequire(sdkRequire.resolve('@openai/codex/package.json'))
const target = { linux: { x64: 'x86_64-unknown-linux-musl', arm64: 'aarch64-unknown-linux-musl' }, darwin: { x64: 'x86_64-apple-darwin', arm64: 'aarch64-apple-darwin' }, win32: { x64: 'x86_64-pc-windows-msvc', arm64: 'aarch64-pc-windows-msvc' } }[process.platform]?.[process.arch]
if (!target) throw failure('unsupported-platform')
const platform = { linux: 'linux', darwin: 'darwin', win32: 'win32' }[process.platform]
const vendor = join(dirname(cliRequire.resolve(`@openai/codex-${platform}-${process.arch}/package.json`)), 'vendor', target)
const name = process.platform === 'win32' ? 'codex.exe' : 'codex'
if (existsSync(join(vendor, 'codex-package.json')) && existsSync(join(vendor, 'bin', name))) return { executable: join(vendor, 'bin', name), pathDirectory: join(vendor, 'codex-path') }
if (existsSync(join(vendor, 'codex', name))) return { executable: join(vendor, 'codex', name), pathDirectory: join(vendor, 'path') }
throw failure('missing-cli')
} catch { throw failure('missing-cli') }
}

function cli(executable, args, { env, cwd, signal, rpc = false }) {
return new Promise((resolve, reject) => {
let child, timer, bytes = 0, result, invalid = false, stdout = ''
const stop = () => { invalid = true; child?.kill('SIGKILL') }
try { child = spawn(executable, args, { env, cwd, stdio: ['pipe', 'pipe', 'pipe'] }) } catch { reject(failure('cli-unavailable')); return }
timer = setTimeout(stop, 10_000)
signal?.addEventListener('abort', stop, { once: true })
if (signal?.aborted) stop()
child.stdin.on('error', stop)
child.stderr.on('data', data => { bytes += data.length; if (bytes > 2 * 1024 * 1024) stop() })
child.stdout.on('data', data => { bytes += data.length; if (bytes > 2 * 1024 * 1024) stop(); if (!rpc) stdout += data })
if (rpc) {
const lines = createInterface({ input: child.stdout })
lines.on('line', line => {
try {
const value = JSON.parse(line)
if (value.id === 1) {
if (value.error) { stop(); return }
child.stdin.write(JSON.stringify({ method: 'initialized' }) + '\n')
child.stdin.write(JSON.stringify({ id: 2, method: 'config/read', params: { includeLayers: true, cwd } }) + '\n')
}
if (value.id === 2) { result = value.result; if (!result || value.error) invalid = true; child.kill('SIGTERM') }
} catch { stop() }
})
child.stdin.write(JSON.stringify({ id: 1, method: 'initialize', params: { clientInfo: { name: 'flybot-config-check', version: '1' } } }) + '\n')
} else child.stdin.end()
child.once('error', () => { invalid = true })
child.once('close', code => {
clearTimeout(timer); signal?.removeEventListener('abort', stop)
if (invalid || (rpc ? !result : code !== 0)) reject(failure('cli-configuration'))
else resolve(rpc ? result : stdout)
})
})
}

export async function validateCodexConfiguration(request, options, { executable, pathDirectory } = codexExecutable()) {
const declared = options.configOverrides.find(value => value.startsWith('permissions.flybot-role='))
if (!request.noTools && (!declared || /":tmp"\s*=/.test(declared))) throw failure('permission-profile-mismatch')
const env = { ...request.env }
if (pathDirectory && existsSync(pathDirectory)) env.PATH = pathDirectory + delimiter + (env.PATH ?? '')
const context = { env, cwd: request.cwd, signal: request.signal }
const version = (await cli(executable, ['--version'], context)).trim()
if (version !== 'codex-cli 0.166.0') throw failure('unsupported-cli-version')
const args = codexConfigArguments(options.config, [...options.configOverrides,
...(options.baseUrl ? [`openai_base_url=${JSON.stringify(options.baseUrl)}`] : []),
'web_search="disabled"', 'approval_policy="never"',
...(request.noTools ? ['sandbox_mode="read-only"', 'sandbox_workspace_write.network_access=false'] : [])])
await cli(executable, [...args, 'app-server', '--print-startup-config'], context)
if (!request.noTools) {
const resolved = await cli(executable, [...args, 'app-server'], { ...context, rpc: true })
// Defaults in config/read contain legacy fields too; only explicitly loaded
// layers establish a conflict. Never log their values or credential material.
if (!Array.isArray(resolved.layers) || resolved.layers.some(layer => Object.hasOwn(layer.config ?? {}, 'sandbox_mode') || Object.hasOwn(layer.config ?? {}, 'sandbox_workspace_write')) || resolved.config?.default_permissions !== 'flybot-role') throw failure('legacy-sandbox-conflict')
const profile = resolved.config?.permissions?.['flybot-role']
if (profile?.extends !== (request.readOnly ? ':read-only' : ':workspace') || profile.filesystem?.[':tmpdir'] !== 'write' || Object.keys(profile.filesystem ?? {}).some(key => key.startsWith(':') && key !== ':tmpdir') || profile.network?.mode !== 'limited' || Object.keys(profile.network?.domains ?? {}).length || resolved.config?.features?.network_proxy !== true) throw failure('permission-profile-mismatch')
}
return { codexPathOverride: executable, env, capability: { kind: 'codex-cli-config/v1', version: '0.166.0', profile: request.noTools ? 'chief-read-only' : 'flybot-role', modelInvoked: false } }
}
10 changes: 7 additions & 3 deletions core/src/host/project-harness-turn.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fileURLToPath } from 'node:url'
import { validateCodexConfiguration } from './project-codex-config.js'
// One real SDK turn. The host owns Workflow completion and acceptance, never this driver.
export async function runHarnessTurn(request, { emit, permission, signal, loadCodex = () => import('@openai/codex-sdk'), loadClaude = () => import('@anthropic-ai/claude-agent-sdk') }) {
export async function runHarnessTurn(request, { emit, permission, signal, validateCodex = validateCodexConfiguration, loadCodex = () => import('@openai/codex-sdk'), loadClaude = () => import('@anthropic-ai/claude-agent-sdk') }) {
let text = '', completed = false, sessionId = request.sessionId ?? null, usage = null
if (request.harness === 'codex') {
const toolOutputTokens = request.toolOutputTokens ?? 2000
Expand All @@ -16,12 +17,15 @@ export async function runHarnessTurn(request, { emit, permission, signal, loadCo
// proxy keeps an empty outbound allowlist while allowing local process IPC.
const roots = !request.readOnly && !request.noTools ? request.writableRoots ?? [] : []
const processPolicy = request.noTools ? {} : { default_permissions: 'flybot-role', features: { ...connectorPolicy, network_proxy: true },
permissions: { 'flybot-role': { extends: request.readOnly ? ':read-only' : ':workspace', filesystem: { ':tmp': 'write', ...(request.scratchDirectory ? { [request.scratchDirectory]: 'write' } : {}), ...Object.fromEntries(roots.map(path => [path, 'write'])) },
permissions: { 'flybot-role': { extends: request.readOnly ? ':read-only' : ':workspace', filesystem: { ':tmpdir': 'write', ...(request.scratchDirectory ? { [request.scratchDirectory]: 'write' } : {}), ...Object.fromEntries(roots.map(path => [path, 'write'])) },
network: { enabled: true, mode: 'limited', domains: {}, unix_sockets: {}, allow_local_binding: false, allow_upstream_proxy: false, dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false } } } }
const inlineToml = value => value && typeof value === 'object' ? '{' + Object.entries(value).map(([key, child]) => `${JSON.stringify(key)}=${inlineToml(child)}`).join(',') + '}' : JSON.stringify(value)
const rawPolicy = request.noTools ? [] : ['permissions.flybot-role=' + inlineToml(processPolicy.permissions['flybot-role'])]
const configPolicy = request.noTools ? {} : { ...processPolicy, permissions: { 'flybot-role': { extends: processPolicy.permissions['flybot-role'].extends, network: processPolicy.permissions['flybot-role'].network } } }
const client = new Codex({ env: request.env, configOverrides: ['mcp_servers={}', 'plugins={}', 'apps={_default={enabled=false}}', ...rawPolicy], ...(request.baseUrl ? { baseUrl: request.baseUrl } : {}), config: { ...(request.config ?? {}), ...chiefConfig, ...configPolicy, tool_output_token_limit: toolOutputTokens } })
const clientOptions = { env: request.env, configOverrides: ['mcp_servers={}', 'plugins={}', 'apps={_default={enabled=false}}', ...rawPolicy], ...(request.baseUrl ? { baseUrl: request.baseUrl } : {}), config: { ...(request.config ?? {}), ...chiefConfig, ...configPolicy, tool_output_token_limit: toolOutputTokens } }
const validated = await validateCodex({ ...request, signal }, clientOptions)
if (validated.capability) await emit({ type: 'configuration-checked', harness: 'codex', capability: validated.capability })
const client = new Codex({ ...clientOptions, codexPathOverride: validated.codexPathOverride, env: validated.env })
const options = { workingDirectory: request.cwd, model: request.model, approvalPolicy: 'never', additionalDirectories: roots, webSearchMode: 'disabled',
...(request.noTools ? { sandboxMode: 'read-only', networkAccessEnabled: false } : {}) }
const thread = sessionId ? client.resumeThread(sessionId, options) : client.startThread(options)
Expand Down
2 changes: 1 addition & 1 deletion core/src/host/project-harness-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ async function turn(message) {
sessionId = result.sessionId
write({ type: 'completed', ...result })
if (settings.probe) void shutdown()
} catch { write({ type: 'failure', reason: 'SDK execution failed; inspect the redacted event evidence and resource environment' }) }
} catch (error) { write({ type: 'failure', reason: error.code === 'bot/harness-capability' ? error.message : 'SDK execution failed; inspect the redacted event evidence and resource environment', ...(error.code === 'bot/harness-capability' ? { capabilityFailure: { code: error.code, kind: error.capability, stage: 'configuration', modelInvoked: false } } : {}) }) }
finally { busy = false }
}
input.on('line', line => {
Expand Down
11 changes: 10 additions & 1 deletion core/src/host/project-harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,16 @@ export function createProjectHarness(rt) {
}
else { row.status = 'waiting-input'; row.pending = { kind: 'message', text: event.text.slice(0, 16000) }; if (!control.chief && session.poolClaimId) await taskInputWake(row.taskId) }
}
if (event.type === 'failure') { row.status = 'failed'; await report(row, false, event.reason) }
if (event.type === 'configuration-checked' && event.harness === 'codex' && event.capability?.kind === 'codex-cli-config/v1' && event.capability.version === VERSIONS.codex && event.capability.modelInvoked === false) row.configurationCheck = { kind: event.capability.kind, version: event.capability.version, profile: event.capability.profile === 'chief-read-only' ? 'chief-read-only' : 'flybot-role', at: Date.now(), modelInvoked: false }
if (event.type === 'failure') {
row.status = 'failed'
if (event.capabilityFailure?.code === 'bot/harness-capability' && event.capabilityFailure.stage === 'configuration' && event.capabilityFailure.modelInvoked === false && ['missing-cli', 'unsupported-cli-version', 'unsupported-platform', 'cli-unavailable', 'cli-configuration', 'legacy-sandbox-conflict', 'permission-profile-mismatch'].includes(event.capabilityFailure.kind)) {
row.capabilityFailure = event.capabilityFailure
rt.state.projectHarnessHealth ??= {}
setEntry(rt.state.projectHarnessHealth, `${row.projectId}:${row.resourceId}:${row.harness}`, { status: 'environmentError', reason: event.reason, capabilityFailure: event.capabilityFailure, resourceId: row.resourceId, harness: row.harness, at: Date.now() })
}
await report(row, false, event.reason)
}
await rt.save()
}).catch(error => { if (row.status === 'released') return; row.status = 'failed'; failAsync(row, error.code === 'bot/harness' ? error.message : 'SDK event protocol failed') })
})
Expand Down
Loading
Loading