diff --git a/LESSONS.md b/LESSONS.md index d00f618f9..10f412f2b 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -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 diff --git a/PROJECT.md b/PROJECT.md index e5b5c15b5..0f4d6181e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 7886b506f..bf92f6e61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/changelog.d/2337.fixed.md b/changelog.d/2337.fixed.md new file mode 100644 index 000000000..51f967534 --- /dev/null +++ b/changelog.d/2337.fixed.md @@ -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. diff --git a/core/src/host/project-codex-config.js b/core/src/host/project-codex-config.js new file mode 100644 index 000000000..f397d466e --- /dev/null +++ b/core/src/host/project-codex-config.js @@ -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 } } +} diff --git a/core/src/host/project-harness-turn.js b/core/src/host/project-harness-turn.js index 6878d3db6..460a7cdaf 100644 --- a/core/src/host/project-harness-turn.js +++ b/core/src/host/project-harness-turn.js @@ -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 @@ -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) diff --git a/core/src/host/project-harness-worker.mjs b/core/src/host/project-harness-worker.mjs index 0c853176c..f9c0bd39b 100644 --- a/core/src/host/project-harness-worker.mjs +++ b/core/src/host/project-harness-worker.mjs @@ -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 => { diff --git a/core/src/host/project-harness.js b/core/src/host/project-harness.js index f3302f749..b5de20b6d 100644 --- a/core/src/host/project-harness.js +++ b/core/src/host/project-harness.js @@ -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') }) }) diff --git a/core/test/project-codex-config.test.js b/core/test/project-codex-config.test.js new file mode 100644 index 000000000..f6515fcd0 --- /dev/null +++ b/core/test/project-codex-config.test.js @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { codexConfigArguments, validateCodexConfiguration, codexExecutable } from '../src/host/project-codex-config.js' +import { runHarnessTurn } from '../src/host/project-harness-turn.js' +const exec = promisify(execFile) + +async function settings(request) { + let options + const stop = new Error('captured-before-model') + await assert.rejects(runHarnessTurn({ harness: 'codex', model: 'unused', prompt: 'unused', ...request }, { emit() {}, loadCodex: async () => ({ Codex: class {} }), validateCodex: async (_request, value) => { options = value; throw stop } }), error => error === stop) + return options +} +test('configuration check stops an invalid profile before creating a paid SDK thread', async () => { + let created = false + await assert.rejects(runHarnessTurn({ harness: 'codex' }, { loadCodex: async () => ({ Codex: class { constructor() { created = true } } }), validateCodex: async () => { throw Object.assign(new Error('not ready'), { code: 'bot/harness-capability' }) } }), { code: 'bot/harness-capability' }) + assert.equal(created, false) + const options = await settings({ cwd: '/candidate', scratchDirectory: '/scratch', readOnly: true }) + assert.ok(options.configOverrides.at(-1).includes('\":tmpdir\"=\"write\"')) + assert.ok(!options.configOverrides.at(-1).includes('\":tmp\"=')) + assert.deepEqual(codexConfigArguments({ features: { network_proxy: true }, hooks: [{ value: 'quoted' }] }, ['plugins={}']), ['--config', 'features.network_proxy=true', '--config', 'hooks=[{"value"="quoted"}]', '--config', 'plugins={}']) +}) + +test('CLI version failures expose no stderr or profile values', async () => { + const directory = await mkdtemp(join(tmpdir(), 'flybot-cli-fixture-')) + try { + const executable = join(directory, 'codex') + await writeFile(executable, '#!/bin/sh\necho codex-cli 0.1.0\necho private-fixture-value >&2\n', { mode: 0o700 }) + const request = { cwd: directory, env: { PATH: process.env.PATH }, noTools: true } + await assert.rejects(validateCodexConfiguration(request, { config: {}, configOverrides: [] }, { executable }), error => error.code === 'bot/harness-capability' && error.capability === 'unsupported-cli-version' && !error.message.includes('private-fixture-value')) + } finally { await rm(directory, { recursive: true, force: true }) } +}) + +// Opt-in on the actual resource executable; basic CI needs no vendor SDK install. +if (process.env.FLYBOT_TEST_CODEX_EXECUTABLE) test('actual Codex 0.166 parses role policy, rejects bad profiles and legacy conflicts, and protects candidate writes offline', async () => { + const directory = await mkdtemp(join(tmpdir(), 'flybot-cli-offline-')) + try { + const home = join(directory, 'home'), scratch = join(directory, 'scratch'), cwd = join(directory, 'candidate') + await Promise.all([home, scratch, cwd].map(path => mkdir(path))) + const candidate = join(cwd, 'candidate.txt') + await writeFile(candidate, 'sealed') + const executable = process.env.FLYBOT_TEST_CODEX_EXECUTABLE + if (process.env.FLYBOT_TEST_CODEX_SDK_URL) assert.equal(codexExecutable(process.env.FLYBOT_TEST_CODEX_SDK_URL).executable, executable) + const env = { PATH: process.env.PATH, HOME: home, CODEX_HOME: home, TMPDIR: scratch, LANG: 'C.UTF-8' } + for (const readOnly of [false, true]) { + const request = { cwd, scratchDirectory: scratch, env, readOnly } + const options = await settings(request) + const validated = await validateCodexConfiguration(request, options, { executable }) + assert.equal(validated.codexPathOverride, executable) + assert.equal(validated.capability.modelInvoked, false) + const args = codexConfigArguments(options.config, options.configOverrides) + await exec(executable, [...args, 'sandbox', '-P', 'flybot-role', '--', '/bin/sh', '-c', 'printf scratch > "$TMPDIR/probe"'], { env, cwd }) + assert.equal(await readFile(join(scratch, 'probe'), 'utf8'), 'scratch') + const write = exec(executable, [...args, 'sandbox', '-P', 'flybot-role', '--', '/bin/sh', '-c', 'printf changed > candidate.txt'], { env, cwd }) + if (readOnly) await assert.rejects(write) + else await write + assert.equal(await readFile(candidate, 'utf8'), readOnly ? 'sealed' : 'changed') + await writeFile(candidate, 'sealed') + const invalid = { ...options, configOverrides: options.configOverrides.map(value => value.replaceAll(':tmpdir', ':tmp')) } + await assert.rejects(validateCodexConfiguration(request, invalid, { executable }), { code: 'bot/harness-capability', capability: 'permission-profile-mismatch' }) + const badProfile = { ...options, configOverrides: options.configOverrides.map(value => value.replaceAll(readOnly ? ':read-only' : ':workspace', ':unsupported-profile')) } + await assert.rejects(validateCodexConfiguration(request, badProfile, { executable }), { code: 'bot/harness-capability', capability: 'cli-configuration' }) + await writeFile(join(home, 'config.toml'), 'sandbox_mode="danger-full-access"\n') + await assert.rejects(validateCodexConfiguration(request, options, { executable }), { code: 'bot/harness-capability', capability: 'legacy-sandbox-conflict' }) + await rm(join(home, 'config.toml')) + } + const chief = { cwd, env, noTools: true, readOnly: true } + await validateCodexConfiguration(chief, await settings(chief), { executable }) + } finally { await rm(directory, { recursive: true, force: true }) } +}) diff --git a/core/test/project-harness-turn.test.js b/core/test/project-harness-turn.test.js index 01642671c..fea3efe2a 100644 --- a/core/test/project-harness-turn.test.js +++ b/core/test/project-harness-turn.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { runHarnessTurn } from '../src/host/project-harness-turn.js' +import { runHarnessTurn as actualRunHarnessTurn } from '../src/host/project-harness-turn.js' +const runHarnessTurn = (request, ports) => actualRunHarnessTurn(request, { validateCodex: async (_request, options) => ({ codexPathOverride: '/test/sdk-cli', env: options.env }), ...ports }) const request = { harness: 'codex', cwd: '/workspace/task', model: 'explicit-model', prompt: 'Implement only the frozen Task', env: { HOME: '/explicit/profile', CODEX_HOME: '/explicit/codex' } } function codex(events, inspect = () => {}) { return async () => ({ Codex: class { @@ -89,7 +90,7 @@ test('Codex process IPC policy cannot be overridden into external access or writ assert.equal(policy.network.mode, 'limited'); assert.deepEqual(policy.network.domains, {}) assert.equal(policy.network.allow_local_binding, false); assert.equal(policy.network.dangerously_allow_all_unix_sockets, false) assert.match(observed[0].configOverrides.at(-1), /permissions\.flybot-role=/) - assert.match(observed[0].configOverrides.at(-1), /":tmp"="write"/) + assert.match(observed[0].configOverrides.at(-1), /":tmpdir"="write"/) assert.equal(observed[1].sandboxMode, undefined, 'Legacy flags cannot override the named constrained profile') }) diff --git a/core/test/project-harness.test.js b/core/test/project-harness.test.js index 9f8c3265a..a00277759 100644 --- a/core/test/project-harness.test.js +++ b/core/test/project-harness.test.js @@ -68,6 +68,23 @@ test('SDK workers reject unregistered Workspaces and missing explicit profiles', await assert.rejects(harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd }), /explicitly authorized/) }) +test('resource-side configuration failure remains environment evidence and releases only after worker exit', async t => { + const { harness, rt, cwd } = await fixture(t) + const file = join(rt.config.projectResources.r.runtimeDir, 'src/host/project-harness-worker.mjs') + let source = await readFile(file, 'utf8') + source = source.replace("write({type:'completed',sessionId:'bound-thread',text:'OUTPUT implementation: actual contract worker output',usage:{input_tokens:1}});", "write({type:'failure',reason:'Codex configuration is not ready',capabilityFailure:{code:'bot/harness-capability',kind:'legacy-sandbox-conflict',stage:'configuration',modelInvoked:false}});") + await writeFile(file, source) + const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Do the assigned Task' }) + const result = new Promise(resolve => harness.onResult(resolve)) + await harness.dispatch({ botId: id, taskId: 't', brief: 'Frozen requirement', outputs: [{ name: 'implementation' }] }) + assert.equal((await result).ok, false) + assert.equal(rt.state.projectHarnessWorkers[id].capabilityFailure.kind, 'legacy-sandbox-conflict') + assert.equal(rt.state.projectHarnessHealth['p:r:codex'].status, 'environmentError') + assert.equal(rt.projectResources.occupied('r'), 1) + await harness.release(id) + assert.equal(rt.projectResources.occupied('r'), 0) +}) + test('Workflow result callback can await the same SDK worker release without blocking stop acknowledgement', async t => { const { rt, harness, cwd } = await fixture(t) const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Implement Task' }) diff --git a/docs/backend-operations.md b/docs/backend-operations.md index cdb48e9ae..51eb056e5 100644 --- a/docs/backend-operations.md +++ b/docs/backend-operations.md @@ -119,3 +119,15 @@ JSON/SQLite回退保留新代码格式守卫、ID和已完成Effects,**不是 #2336:资源 reservation 记录 Task/claim/代次归属。已证明未启动的失效预留由原对账回收;registered/starting/spawned 是不同阶段,starting 或退出未知继续占用。reclamation 和 releaseProof 保留回收依据;不要通过清 bindings、删工作树或强清 lease 恢复容量。 + +### Codex configuration readiness (#2337) + +The pinned SDK and CLI must both be 0.166.0 on the selected resource. FlyBot checks +the SDK-resolved executable, not global PATH, before inference. Named role profiles +use `:tmpdir` and their own scratch directory. A `bot/harness-capability` failure +requires inspecting the selected profile's explicit legacy sandbox settings or +installing the pinned CLI with optional platform dependencies; do not disable the +sandbox, expand network access, or replace credentials to hide a configuration +error. Fix the resource profile through authorized operations, then retry the +original Task. Readiness is not a login/model/test PASS; health keeps configuration +failures distinct. Offline regression instructions are in the Harness contract. diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index c5d99a39a..bb9852933 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -34020,7 +34020,7 @@ "notes": "payload.requestId identifies the original SDK permission question; outer requestId is the independent command receipt ID. A role string never grants execution identity.", "implementation": { "path": "core/src/host/project-harness.js", - "sha256": "0fee68a81e90d88054b9e56fc5c2031d2bdbb5520e827d18412e1900d14dc4a1" + "sha256": "acf48a6d21ec863e939f96b57bd99e880b6bcba4bed7a6bdfeaeaee6cd34e963" } }, { @@ -44968,7 +44968,7 @@ "notes": "payload.requestId identifies the original SDK permission question; outer requestId is the independent command receipt ID. A role string never grants execution identity.", "implementation": { "path": "core/src/host/project-harness.js", - "sha256": "0fee68a81e90d88054b9e56fc5c2031d2bdbb5520e827d18412e1900d14dc4a1" + "sha256": "acf48a6d21ec863e939f96b57bd99e880b6bcba4bed7a6bdfeaeaee6cd34e963" }, "aliasOf": "development.command.execution.input" }, @@ -45468,6 +45468,7 @@ "core/src/host/project-chief-protocol.js": "a09d85e04171b22625d3eed131dd6a5227dc1a946ad39d8a59f017bb75f71936", "core/src/host/project-chief.js": "a3f01a053177fd8f10d4b778d79ac98c25aee70aa3c4d967dc7e1da2aea2cf6e", "core/src/host/project-child-inputs.js": "db1f534c4222dba1b500d308b1cd07f0df50a02eb228f520b150a3b79d6f126d", + "core/src/host/project-codex-config.js": "4ddf4e4e3e2b324b862ad4f6a6c2a90ac3f3003843b8e446f873b146872c4598", "core/src/host/project-command-receipts.js": "1e654a257b8bdbbb7fea8f96e214b35437fe855651cc897b318a5fc8987dfe52", "core/src/host/project-compatibility.js": "4ab65df4a237fb55ea2c68396bd180bb0a9ed4c42dc63444ce4ef0869c8988bc", "core/src/host/project-consultations.js": "c7a1e9afa0ad3982e23c141926f017cfff13556607b5c39b2f10296b9fb835a0", @@ -45490,8 +45491,8 @@ "core/src/host/project-github-connections.js": "471e5e07d424fa6a7b22b173545c2232836ebcf45b76f8b9e2f1a1af376bfd2d", "core/src/host/project-github-read.js": "dbbdbbc0d917bbf20c4fdf7306464cbb682b0191b98c36fddbe70e6837a57943", "core/src/host/project-github.js": "bf6d3891a6fa9c0516cd330d3322a750fc6db8813752f7801d4d9760fca84061", - "core/src/host/project-harness-turn.js": "a8ab111b8d5473a27413968e4ea5c2631ea472c992414e9387da245463f7b21a", - "core/src/host/project-harness.js": "0fee68a81e90d88054b9e56fc5c2031d2bdbb5520e827d18412e1900d14dc4a1", + "core/src/host/project-harness-turn.js": "61ac162619bb5856f9786c8f7b80bdd2ff92e875828897cf49d70903cd2453df", + "core/src/host/project-harness.js": "acf48a6d21ec863e939f96b57bd99e880b6bcba4bed7a6bdfeaeaee6cd34e963", "core/src/host/project-history-pages.js": "38958623f0aa98099e570559c55a207285fd0349689ffdcec47b965c40f4e1bc", "core/src/host/project-inputs.js": "dd52c6ddc7b0f2614ecaa3c3cccbea7a39ea914f24e1602946b93a290eff622e", "core/src/host/project-lifecycle.js": "055784e0a1cbf2a9e465e990c60fbd6df4b8844052db43bbcb7cae5de9282936", diff --git a/docs/subsystems/project-harness.md b/docs/subsystems/project-harness.md index bb1dc8972..cb1c503e5 100644 --- a/docs/subsystems/project-harness.md +++ b/docs/subsystems/project-harness.md @@ -13,6 +13,8 @@ owned_paths: - core/src/host/project-git-sandbox.js - core/test/project-git-sandbox.test.js - core/src/host/project-harness.js + - core/src/host/project-codex-config.js + - core/test/project-codex-config.test.js - core/src/host/project-harness-turn.js - core/src/host/project-harness-worker.mjs - core/src/host/project-harness-deny-tool.mjs @@ -389,3 +391,34 @@ intent fences the exact lease/generation/worker. Registered-only failures roll b through resource.reclaim; possibly spawned work retains occupancy until real exit. The shared exit predicate accepts only the exact matching no-start proof, not missing PID/workerId. This does not clear prior Task-role bindings or enable a fresh Session. + +## Actual Codex configuration readiness (#2337) + +Command roles grant `:tmpdir` and the resource-bound absolute scratch path. The +unsupported `:tmp` spelling is forbidden. Before creating/resuming a paid thread, +the worker resolves the CLI from the installed SDK dependency graph and passes +that same executable through the SDK public `codexPathOverride`. Its native helper +PATH is retained; a different global CLI cannot stand in for this check. + +Supported CLI/SDK is 0.166.0. Offline `--version`, `app-server +--print-startup-config` and `config/read` validate the exact SDK config and raw +TOML entry points, in the selected cwd/profile/environment. Explicit loaded legacy +`sandbox_mode`/`sandbox_workspace_write` layers conflict with named role profiles: +fail with `bot/harness-capability`, never silently edit user config or relax policy. +Chief retains its explicit read-only SDK sandbox and denied tools. The probe creates +no thread/turn and logs no config, stderr or authentication values. There is no +cached readiness promise that can survive a configuration change. + +Resource worker failure evidence and Harness health distinguish configuration +capability failure from code tests; no success, paid inference or exit proof follows +from configuration readiness. Unknown CLI versions fail before model work. Checks +are bounded, and subprocess exit precedes returning a result. + +`FLYBOT_TEST_CODEX_EXECUTABLE=/absolute/resource/sdk/vendor/bin/codex node --test +core/test/project-codex-config.test.js` runs an opt-in offline actual-CLI fixture +with disposable HOME/CODEX_HOME/TMPDIR, valid/invalid profiles, legacy conflicts, +scratch writes and candidate write protection. Basic tests need no vendor install. +0.166 can silently accept unknown filesystem alias keys, so parser success alone +is insufficient: inspect resolved semantics and run isolated filesystem probes. +See [official permissions](https://learn.chatgpt.com/docs/permissions) and +[configuration read API](https://learn.chatgpt.com/docs/app-server). diff --git a/patches/ds-bot/files/src/host/project-codex-config.js b/patches/ds-bot/files/src/host/project-codex-config.js new file mode 100644 index 000000000..f397d466e --- /dev/null +++ b/patches/ds-bot/files/src/host/project-codex-config.js @@ -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 } } +} diff --git a/patches/ds-bot/files/src/host/project-harness-turn.js b/patches/ds-bot/files/src/host/project-harness-turn.js index 6878d3db6..460a7cdaf 100644 --- a/patches/ds-bot/files/src/host/project-harness-turn.js +++ b/patches/ds-bot/files/src/host/project-harness-turn.js @@ -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 @@ -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) diff --git a/patches/ds-bot/files/src/host/project-harness-worker.mjs b/patches/ds-bot/files/src/host/project-harness-worker.mjs index 0c853176c..f9c0bd39b 100644 --- a/patches/ds-bot/files/src/host/project-harness-worker.mjs +++ b/patches/ds-bot/files/src/host/project-harness-worker.mjs @@ -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 => { diff --git a/patches/ds-bot/files/src/host/project-harness.js b/patches/ds-bot/files/src/host/project-harness.js index f3302f749..b5de20b6d 100644 --- a/patches/ds-bot/files/src/host/project-harness.js +++ b/patches/ds-bot/files/src/host/project-harness.js @@ -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') }) }) diff --git a/patches/ds-bot/files/test/project-codex-config.test.js b/patches/ds-bot/files/test/project-codex-config.test.js new file mode 100644 index 000000000..f6515fcd0 --- /dev/null +++ b/patches/ds-bot/files/test/project-codex-config.test.js @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { codexConfigArguments, validateCodexConfiguration, codexExecutable } from '../src/host/project-codex-config.js' +import { runHarnessTurn } from '../src/host/project-harness-turn.js' +const exec = promisify(execFile) + +async function settings(request) { + let options + const stop = new Error('captured-before-model') + await assert.rejects(runHarnessTurn({ harness: 'codex', model: 'unused', prompt: 'unused', ...request }, { emit() {}, loadCodex: async () => ({ Codex: class {} }), validateCodex: async (_request, value) => { options = value; throw stop } }), error => error === stop) + return options +} +test('configuration check stops an invalid profile before creating a paid SDK thread', async () => { + let created = false + await assert.rejects(runHarnessTurn({ harness: 'codex' }, { loadCodex: async () => ({ Codex: class { constructor() { created = true } } }), validateCodex: async () => { throw Object.assign(new Error('not ready'), { code: 'bot/harness-capability' }) } }), { code: 'bot/harness-capability' }) + assert.equal(created, false) + const options = await settings({ cwd: '/candidate', scratchDirectory: '/scratch', readOnly: true }) + assert.ok(options.configOverrides.at(-1).includes('\":tmpdir\"=\"write\"')) + assert.ok(!options.configOverrides.at(-1).includes('\":tmp\"=')) + assert.deepEqual(codexConfigArguments({ features: { network_proxy: true }, hooks: [{ value: 'quoted' }] }, ['plugins={}']), ['--config', 'features.network_proxy=true', '--config', 'hooks=[{"value"="quoted"}]', '--config', 'plugins={}']) +}) + +test('CLI version failures expose no stderr or profile values', async () => { + const directory = await mkdtemp(join(tmpdir(), 'flybot-cli-fixture-')) + try { + const executable = join(directory, 'codex') + await writeFile(executable, '#!/bin/sh\necho codex-cli 0.1.0\necho private-fixture-value >&2\n', { mode: 0o700 }) + const request = { cwd: directory, env: { PATH: process.env.PATH }, noTools: true } + await assert.rejects(validateCodexConfiguration(request, { config: {}, configOverrides: [] }, { executable }), error => error.code === 'bot/harness-capability' && error.capability === 'unsupported-cli-version' && !error.message.includes('private-fixture-value')) + } finally { await rm(directory, { recursive: true, force: true }) } +}) + +// Opt-in on the actual resource executable; basic CI needs no vendor SDK install. +if (process.env.FLYBOT_TEST_CODEX_EXECUTABLE) test('actual Codex 0.166 parses role policy, rejects bad profiles and legacy conflicts, and protects candidate writes offline', async () => { + const directory = await mkdtemp(join(tmpdir(), 'flybot-cli-offline-')) + try { + const home = join(directory, 'home'), scratch = join(directory, 'scratch'), cwd = join(directory, 'candidate') + await Promise.all([home, scratch, cwd].map(path => mkdir(path))) + const candidate = join(cwd, 'candidate.txt') + await writeFile(candidate, 'sealed') + const executable = process.env.FLYBOT_TEST_CODEX_EXECUTABLE + if (process.env.FLYBOT_TEST_CODEX_SDK_URL) assert.equal(codexExecutable(process.env.FLYBOT_TEST_CODEX_SDK_URL).executable, executable) + const env = { PATH: process.env.PATH, HOME: home, CODEX_HOME: home, TMPDIR: scratch, LANG: 'C.UTF-8' } + for (const readOnly of [false, true]) { + const request = { cwd, scratchDirectory: scratch, env, readOnly } + const options = await settings(request) + const validated = await validateCodexConfiguration(request, options, { executable }) + assert.equal(validated.codexPathOverride, executable) + assert.equal(validated.capability.modelInvoked, false) + const args = codexConfigArguments(options.config, options.configOverrides) + await exec(executable, [...args, 'sandbox', '-P', 'flybot-role', '--', '/bin/sh', '-c', 'printf scratch > "$TMPDIR/probe"'], { env, cwd }) + assert.equal(await readFile(join(scratch, 'probe'), 'utf8'), 'scratch') + const write = exec(executable, [...args, 'sandbox', '-P', 'flybot-role', '--', '/bin/sh', '-c', 'printf changed > candidate.txt'], { env, cwd }) + if (readOnly) await assert.rejects(write) + else await write + assert.equal(await readFile(candidate, 'utf8'), readOnly ? 'sealed' : 'changed') + await writeFile(candidate, 'sealed') + const invalid = { ...options, configOverrides: options.configOverrides.map(value => value.replaceAll(':tmpdir', ':tmp')) } + await assert.rejects(validateCodexConfiguration(request, invalid, { executable }), { code: 'bot/harness-capability', capability: 'permission-profile-mismatch' }) + const badProfile = { ...options, configOverrides: options.configOverrides.map(value => value.replaceAll(readOnly ? ':read-only' : ':workspace', ':unsupported-profile')) } + await assert.rejects(validateCodexConfiguration(request, badProfile, { executable }), { code: 'bot/harness-capability', capability: 'cli-configuration' }) + await writeFile(join(home, 'config.toml'), 'sandbox_mode="danger-full-access"\n') + await assert.rejects(validateCodexConfiguration(request, options, { executable }), { code: 'bot/harness-capability', capability: 'legacy-sandbox-conflict' }) + await rm(join(home, 'config.toml')) + } + const chief = { cwd, env, noTools: true, readOnly: true } + await validateCodexConfiguration(chief, await settings(chief), { executable }) + } finally { await rm(directory, { recursive: true, force: true }) } +}) diff --git a/patches/ds-bot/files/test/project-harness-turn.test.js b/patches/ds-bot/files/test/project-harness-turn.test.js index 01642671c..fea3efe2a 100644 --- a/patches/ds-bot/files/test/project-harness-turn.test.js +++ b/patches/ds-bot/files/test/project-harness-turn.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { runHarnessTurn } from '../src/host/project-harness-turn.js' +import { runHarnessTurn as actualRunHarnessTurn } from '../src/host/project-harness-turn.js' +const runHarnessTurn = (request, ports) => actualRunHarnessTurn(request, { validateCodex: async (_request, options) => ({ codexPathOverride: '/test/sdk-cli', env: options.env }), ...ports }) const request = { harness: 'codex', cwd: '/workspace/task', model: 'explicit-model', prompt: 'Implement only the frozen Task', env: { HOME: '/explicit/profile', CODEX_HOME: '/explicit/codex' } } function codex(events, inspect = () => {}) { return async () => ({ Codex: class { @@ -89,7 +90,7 @@ test('Codex process IPC policy cannot be overridden into external access or writ assert.equal(policy.network.mode, 'limited'); assert.deepEqual(policy.network.domains, {}) assert.equal(policy.network.allow_local_binding, false); assert.equal(policy.network.dangerously_allow_all_unix_sockets, false) assert.match(observed[0].configOverrides.at(-1), /permissions\.flybot-role=/) - assert.match(observed[0].configOverrides.at(-1), /":tmp"="write"/) + assert.match(observed[0].configOverrides.at(-1), /":tmpdir"="write"/) assert.equal(observed[1].sandboxMode, undefined, 'Legacy flags cannot override the named constrained profile') }) diff --git a/patches/ds-bot/files/test/project-harness.test.js b/patches/ds-bot/files/test/project-harness.test.js index 9f8c3265a..a00277759 100644 --- a/patches/ds-bot/files/test/project-harness.test.js +++ b/patches/ds-bot/files/test/project-harness.test.js @@ -68,6 +68,23 @@ test('SDK workers reject unregistered Workspaces and missing explicit profiles', await assert.rejects(harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd }), /explicitly authorized/) }) +test('resource-side configuration failure remains environment evidence and releases only after worker exit', async t => { + const { harness, rt, cwd } = await fixture(t) + const file = join(rt.config.projectResources.r.runtimeDir, 'src/host/project-harness-worker.mjs') + let source = await readFile(file, 'utf8') + source = source.replace("write({type:'completed',sessionId:'bound-thread',text:'OUTPUT implementation: actual contract worker output',usage:{input_tokens:1}});", "write({type:'failure',reason:'Codex configuration is not ready',capabilityFailure:{code:'bot/harness-capability',kind:'legacy-sandbox-conflict',stage:'configuration',modelInvoked:false}});") + await writeFile(file, source) + const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Do the assigned Task' }) + const result = new Promise(resolve => harness.onResult(resolve)) + await harness.dispatch({ botId: id, taskId: 't', brief: 'Frozen requirement', outputs: [{ name: 'implementation' }] }) + assert.equal((await result).ok, false) + assert.equal(rt.state.projectHarnessWorkers[id].capabilityFailure.kind, 'legacy-sandbox-conflict') + assert.equal(rt.state.projectHarnessHealth['p:r:codex'].status, 'environmentError') + assert.equal(rt.projectResources.occupied('r'), 1) + await harness.release(id) + assert.equal(rt.projectResources.occupied('r'), 0) +}) + test('Workflow result callback can await the same SDK worker release without blocking stop acknowledgement', async t => { const { rt, harness, cwd } = await fixture(t) const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Implement Task' })