@lyupro/codex-bridge 0.6.2 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +146 -0
- package/README.md +3 -3
- package/cli/runs-inventory.mjs +5 -3
- package/cli/stop.mjs +2 -2
- package/cli/unlock.mjs +2 -2
- package/package.json +2 -2
- package/src/agents/codex-advisor.md +187 -0
- package/src/agents/codex-build.md +4 -0
- package/src/commands/env.md +5 -4
- package/src/home/hooks/live-runs.mjs +19 -21
- package/src/home/hooks/reply-guard.mjs +5 -2
- package/src/home/hooks/shell-write-intent.mjs +320 -154
- package/src/home/hooks/worktree-lock.mjs +9 -2
- package/src/home/hooks/worktree-witness.mjs +21 -40
- package/src/home/lib/agents.mjs +3 -0
- package/src/home/lib/budgets.mjs +64 -0
- package/src/home/lib/config-validate.mjs +3 -39
- package/src/home/lib/meta/advice-status.mjs +61 -0
- package/src/home/lib/meta/advice-verdict.mjs +226 -0
- package/src/home/lib/meta/advisor-task.mjs +69 -0
- package/src/home/lib/meta/events.mjs +4 -0
- package/src/home/lib/meta/launch-rows.mjs +45 -0
- package/src/home/lib/meta/reply.mjs +42 -34
- package/src/home/lib/meta/run-liveness.mjs +64 -0
- package/src/home/lib/meta/run-state.mjs +28 -34
- package/src/home/lib/meta/verdict.mjs +18 -0
- package/src/home/lib/required-inputs.mjs +25 -4
- package/src/home/lib/run-codex.mjs +1 -0
- package/src/home/lib/run-config.mjs +5 -1
- package/src/home/lib/runner/args.mjs +8 -4
- package/src/home/lib/runner/attach.mjs +5 -13
- package/src/home/lib/runner/codex-args.mjs +14 -5
- package/src/home/lib/runner/codex-cmd.mjs +105 -19
- package/src/home/lib/runner/git-state.mjs +6 -1
- package/src/home/lib/runner/launcher.mjs +36 -30
- package/src/home/lib/runner/preflight.mjs +109 -0
- package/src/home/lib/runner/prompts.mjs +39 -0
- package/src/home/lib/runner/sandbox-probe.mjs +109 -0
- package/src/home/lib/runner/schemas.mjs +137 -1
- package/src/home/lib/runner/scope-check.mjs +31 -3
- package/src/home/lib/runner/worker-order.mjs +4 -2
- package/src/home/lib/runner/worker.mjs +2 -2
- package/src/home/lib/write-meta.mjs +18 -1
|
@@ -11,7 +11,6 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { spawn, spawnSync } from 'node:child_process';
|
|
13
13
|
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../heartbeat.mjs';
|
|
14
|
-
import { writeFailure } from '../write-meta.mjs';
|
|
15
14
|
import { MAX_LOG } from './git-state.mjs';
|
|
16
15
|
|
|
17
16
|
// Thirty seconds, not a fraction of one: after `exit` the pipe still holds whatever Codex wrote
|
|
@@ -20,6 +19,8 @@ import { MAX_LOG } from './git-state.mjs';
|
|
|
20
19
|
// left no event". The grace exists to bound a grandchild holding stdio open forever (2026-08-06,
|
|
21
20
|
// run 2026-08-06_204007_build waited 25 minutes), and thirty seconds bounds that just as well.
|
|
22
21
|
const STDIO_DRAIN_GRACE_MS = 30_000;
|
|
22
|
+
// Plan_57 D28: a stalled taskkill must not block probe or paid-run deadline settlement forever.
|
|
23
|
+
const TASKKILL_TIMEOUT_MS = 10_000;
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* An argument cmd.exe cannot be handed safely, or undefined. `%` is fatal because cmd
|
|
@@ -30,6 +31,17 @@ const STDIO_DRAIN_GRACE_MS = 30_000;
|
|
|
30
31
|
export const unsafeForCmd = (args) =>
|
|
31
32
|
['codex', ...args].find((a) => a.includes('%') || a.includes('"'));
|
|
32
33
|
|
|
34
|
+
// Plan_57: preflight and paid runs must use the same Windows quoting and npm shim path.
|
|
35
|
+
export function codexSpawnSpec(args, platform = process.platform) {
|
|
36
|
+
if (platform !== 'win32') return { command: 'codex', args, options: {} };
|
|
37
|
+
const cmdline = ['codex', ...args].map((a) => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
|
|
38
|
+
return {
|
|
39
|
+
command: process.env.ComSpec || 'cmd.exe',
|
|
40
|
+
args: ['/d', '/s', '/c', `"${cmdline}"`],
|
|
41
|
+
options: { windowsVerbatimArguments: true, windowsHide: true },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
/**
|
|
34
46
|
* Stop the process that owns the Codex invocation. On Windows that process is cmd.exe, so
|
|
35
47
|
* child.kill() would reap only the shell and leave Codex spending quota as its grandchild —
|
|
@@ -44,10 +56,91 @@ export function stopCodex(child, onWindows = process.platform === 'win32') {
|
|
|
44
56
|
const killed = spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
45
57
|
stdio: 'ignore',
|
|
46
58
|
windowsHide: true,
|
|
59
|
+
timeout: TASKKILL_TIMEOUT_MS,
|
|
47
60
|
});
|
|
48
61
|
if (killed.error || killed.status !== 0) child.kill();
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
// Plan_57 D28: keep spawnSync's 1 MiB default (stdout head, stderr tail); overflow is inconclusive, never dead.
|
|
65
|
+
const CAPTURE_LIMIT_BYTES = 1024 * 1024;
|
|
66
|
+
const CAPTURE_CLOSE_GRACE_MS = 2_000;
|
|
67
|
+
const CAPTURE_STOP_GRACE_MS = 5_000;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Plan_57 D24: async capture lets the deadline stop the whole Windows process tree,
|
|
71
|
+
* preventing the 2026-07-31 orphan that spawnSync's shell-only timeout leaves behind.
|
|
72
|
+
* Bounded pipe draining also prevents the inherited-stdio hang of 2026-08-06.
|
|
73
|
+
*/
|
|
74
|
+
export function spawnCaptured(command, args, options) {
|
|
75
|
+
const { timeout, encoding, shell, ...rest } = options ?? {};
|
|
76
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) {
|
|
77
|
+
throw new TypeError('spawnCaptured requires a finite timeout greater than zero');
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const child = spawn(command, args, { ...rest, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
81
|
+
let stdout = Buffer.alloc(0);
|
|
82
|
+
let stderr = Buffer.alloc(0);
|
|
83
|
+
let captureError = null;
|
|
84
|
+
let settled = false;
|
|
85
|
+
let stopped = false;
|
|
86
|
+
let deadlineTimer;
|
|
87
|
+
let closeTimer;
|
|
88
|
+
let stopTimer;
|
|
89
|
+
const finish = (status, signal, error, destroyStreams = false) => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(deadlineTimer);
|
|
93
|
+
clearTimeout(closeTimer);
|
|
94
|
+
clearTimeout(stopTimer);
|
|
95
|
+
if (destroyStreams) {
|
|
96
|
+
child.stdout.destroy();
|
|
97
|
+
child.stderr.destroy();
|
|
98
|
+
child.unref();
|
|
99
|
+
}
|
|
100
|
+
resolve({ status, signal, error, stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8') });
|
|
101
|
+
};
|
|
102
|
+
const stop = () => {
|
|
103
|
+
if (stopped) return;
|
|
104
|
+
stopped = true;
|
|
105
|
+
stopCodex(child);
|
|
106
|
+
};
|
|
107
|
+
const onPipeError = (error) => {
|
|
108
|
+
if (settled) return;
|
|
109
|
+
stop();
|
|
110
|
+
finish(null, null, error, true);
|
|
111
|
+
};
|
|
112
|
+
child.stdout.on('error', onPipeError);
|
|
113
|
+
child.stderr.on('error', onPipeError);
|
|
114
|
+
child.stdout.on('data', (chunk) => {
|
|
115
|
+
const incoming = Buffer.from(chunk);
|
|
116
|
+
const room = CAPTURE_LIMIT_BYTES - stdout.length;
|
|
117
|
+
if (incoming.length > room && !captureError) {
|
|
118
|
+
captureError = Object.assign(new Error(`stdout exceeded ${CAPTURE_LIMIT_BYTES} bytes`), { code: 'ENOBUFS' });
|
|
119
|
+
}
|
|
120
|
+
if (room > 0) stdout = Buffer.concat([stdout, incoming.subarray(0, room)]);
|
|
121
|
+
});
|
|
122
|
+
child.stderr.on('data', (chunk) => {
|
|
123
|
+
stderr = Buffer.concat([stderr, Buffer.from(chunk)]);
|
|
124
|
+
if (stderr.length > CAPTURE_LIMIT_BYTES) stderr = stderr.subarray(-CAPTURE_LIMIT_BYTES);
|
|
125
|
+
});
|
|
126
|
+
child.on('error', (error) => finish(null, null, error, true));
|
|
127
|
+
child.on('close', (code, signal) => finish(code, signal, captureError));
|
|
128
|
+
child.on('exit', (code, signal) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
clearTimeout(deadlineTimer);
|
|
131
|
+
clearTimeout(stopTimer);
|
|
132
|
+
closeTimer = setTimeout(() => finish(code, signal, captureError, true), CAPTURE_CLOSE_GRACE_MS);
|
|
133
|
+
});
|
|
134
|
+
deadlineTimer = setTimeout(() => {
|
|
135
|
+
captureError = Object.assign(new Error(`timed out after ${timeout} ms`), { code: 'ETIMEDOUT' });
|
|
136
|
+
stop();
|
|
137
|
+
if (!settled) {
|
|
138
|
+
stopTimer = setTimeout(() => finish(null, null, captureError, true), CAPTURE_STOP_GRACE_MS);
|
|
139
|
+
}
|
|
140
|
+
}, timeout);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
51
144
|
/**
|
|
52
145
|
* Windows npm ships `codex` as codex.cmd, and Node refuses to spawn .cmd without a
|
|
53
146
|
* shell. One command line through cmd.exe keeps a single code path; every argument is
|
|
@@ -65,7 +158,7 @@ export function stopCodex(child, onWindows = process.platform === 'win32') {
|
|
|
65
158
|
export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = STDIO_DRAIN_GRACE_MS) {
|
|
66
159
|
const onWindows = process.platform === 'win32';
|
|
67
160
|
const deadlineMs =
|
|
68
|
-
|
|
161
|
+
Number.isFinite(budgetMinutes) && budgetMinutes > 0
|
|
69
162
|
? budgetMinutes * 60 * 1000
|
|
70
163
|
: null;
|
|
71
164
|
let exited = false;
|
|
@@ -129,21 +222,17 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
|
|
|
129
222
|
return { append, finish };
|
|
130
223
|
})();
|
|
131
224
|
|
|
132
|
-
let child;
|
|
133
225
|
if (onWindows) {
|
|
134
226
|
// Thrown rather than died: in the worker a bare exit would leave the run open, while a
|
|
135
227
|
// throw goes through the crash handler and closes it with meta.json like any failure.
|
|
136
228
|
const bad = unsafeForCmd(args);
|
|
137
229
|
if (bad) throw new Error(`argument unsafe for cmd.exe (contains % or "): ${bad}`);
|
|
138
|
-
const cmdline = ['codex', ...args].map((a) => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
|
|
139
|
-
child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${cmdline}"`], {
|
|
140
|
-
windowsVerbatimArguments: true,
|
|
141
|
-
windowsHide: true,
|
|
142
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
143
|
-
});
|
|
144
|
-
} else {
|
|
145
|
-
child = spawn('codex', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
146
230
|
}
|
|
231
|
+
const spec = codexSpawnSpec(args);
|
|
232
|
+
const child = spawn(spec.command, spec.args, {
|
|
233
|
+
...spec.options,
|
|
234
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
235
|
+
});
|
|
147
236
|
|
|
148
237
|
return new Promise((resolve) => {
|
|
149
238
|
let failed = false;
|
|
@@ -249,20 +338,17 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
|
|
|
249
338
|
|
|
250
339
|
/**
|
|
251
340
|
* Codex missing or not authorised is a deterministic FAIL, never a reason to improvise.
|
|
252
|
-
*
|
|
341
|
+
* Plan_58, 2026-09-19: return a reason for refusal before registration, like the dead-sandbox
|
|
342
|
+
* probe. One no-quota question must not have two answers about whether it leaves a folder.
|
|
253
343
|
*/
|
|
254
|
-
export function
|
|
344
|
+
export function codexUnavailableReason() {
|
|
255
345
|
const probe = spawnSync(
|
|
256
346
|
process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex',
|
|
257
347
|
process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'],
|
|
258
348
|
{ encoding: 'utf8' },
|
|
259
349
|
);
|
|
260
350
|
if (probe.error || probe.status !== 0) {
|
|
261
|
-
|
|
262
|
-
const { reply } = writeFailure(runDir, agent, `Codex CLI unavailable: ${why}`, [
|
|
263
|
-
'Operator check: codex --version (and codex login if authorization is rejected)',
|
|
264
|
-
], true);
|
|
265
|
-
console.log(reply);
|
|
266
|
-
process.exit(1);
|
|
351
|
+
return (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
|
|
267
352
|
}
|
|
353
|
+
return null;
|
|
268
354
|
}
|
|
@@ -40,7 +40,12 @@ export function worktreeSnapshot(repo) {
|
|
|
40
40
|
const skip = runsPrefixInside(repo);
|
|
41
41
|
// The path is the third tab-separated field in both git outputs used here.
|
|
42
42
|
const mine = (line) => Boolean(skip) && (line.split('\t')[2] || '').startsWith(skip);
|
|
43
|
-
|
|
43
|
+
// `--no-renames` because every reader of this snapshot compares paths against a scope: with
|
|
44
|
+
// rename detection on, numstat prints one row spelled `old => new` (or `dir/{a => b}/file`),
|
|
45
|
+
// which is not a path and matches no pattern — an in-scope rename would be judged a stray, and
|
|
46
|
+
// the witness named that token at the orchestrator on 2026-09-20. Without it a rename is a
|
|
47
|
+
// deletion plus an addition: two rows, both real paths, both judged on their own merits.
|
|
48
|
+
const tracked = (git(repo, ['diff', 'HEAD', '--numstat', '--no-renames']).stdout || '')
|
|
44
49
|
.split(/\r?\n/)
|
|
45
50
|
.filter((line) => line.trim() && !mine(line))
|
|
46
51
|
.join('\n')
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
writeStatus,
|
|
16
16
|
markAbandoned,
|
|
17
17
|
abandonedBranchDrift,
|
|
18
|
-
activeRunDetails,
|
|
19
18
|
chainRuns,
|
|
20
19
|
startedRuns,
|
|
21
20
|
taskFingerprint,
|
|
@@ -28,18 +27,21 @@ import { parseArgs, die } from './args.mjs';
|
|
|
28
27
|
import { settleTaskInput } from './task-input.mjs';
|
|
29
28
|
import { parseContinuationGrant } from '../required-inputs.mjs';
|
|
30
29
|
import { continuationRefusal } from './continuation.mjs';
|
|
31
|
-
import {
|
|
30
|
+
import { parseAdvisorTask } from '../meta/advisor-task.mjs';
|
|
31
|
+
import { schemaFor } from './schemas.mjs';
|
|
32
32
|
import { INSTRUCTIONS } from './prompts.mjs';
|
|
33
33
|
import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
|
|
34
34
|
import { agentRole } from '../agents.mjs';
|
|
35
35
|
import { codexArgs, runProfile } from './codex-args.mjs';
|
|
36
36
|
import { writeWorkerOrder } from './worker-order.mjs';
|
|
37
|
-
import {
|
|
37
|
+
import { unsafeForCmd } from './codex-cmd.mjs';
|
|
38
38
|
import { runsRoot } from './runs-root.mjs';
|
|
39
39
|
import { resolveProjectRunsDir } from './project-dir.mjs';
|
|
40
40
|
import { cleanupRetention } from '../retention.mjs';
|
|
41
41
|
import { renderConventions } from './conventions.mjs';
|
|
42
42
|
import { validateScope } from './scope-check.mjs';
|
|
43
|
+
import { probeSandbox, sandboxRefusal } from './sandbox-probe.mjs';
|
|
44
|
+
import { preflightRefusal, resolveRunPhase, scopeRunRefusal, taskPreflight } from './preflight.mjs';
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
|
|
@@ -86,13 +88,17 @@ export const runDirPath = (root, slug, runStamp = stamp()) => {
|
|
|
86
88
|
/**
|
|
87
89
|
* Everything a run needs before a single token of someone else's quota is spent, and every
|
|
88
90
|
* refusal that costs nothing: bad arguments, an open chain without --continue, a busy
|
|
89
|
-
* worktree, a missing Codex CLI. All
|
|
91
|
+
* worktree, a dead Codex sandbox, a missing Codex CLI. All in the process the caller is free
|
|
90
92
|
* to kill — so a killed caller can only ever interrupt a run that was already paid for.
|
|
91
93
|
*/
|
|
92
94
|
export async function launcher(argv = process.argv.slice(2)) {
|
|
93
95
|
loadRunEnv();
|
|
94
96
|
const opts = parseArgs(argv);
|
|
97
|
+
opts.phase = resolveRunPhase(opts, RUN_ENV.budgets);
|
|
95
98
|
const taskText = settleTaskInput(opts);
|
|
99
|
+
// Plan_59 D5/D6: blind choices and design authority must be checked before the paid probe.
|
|
100
|
+
const taskGate = taskPreflight({ agent: opts.agent, taskText });
|
|
101
|
+
if (taskGate.refusal) die(taskGate.refusal, 1);
|
|
96
102
|
const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
|
|
97
103
|
const isGitRepo = topLevel.status === 0;
|
|
98
104
|
const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
|
|
@@ -154,6 +160,10 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
154
160
|
continuationGrant,
|
|
155
161
|
);
|
|
156
162
|
if (continuationError) die(continuationError);
|
|
163
|
+
// Plan_59 D14: the grant is valid by now; an advise pass may continue only an OK scope run.
|
|
164
|
+
const scopeRun = continuationGrant &&
|
|
165
|
+
scopeRunRefusal({ agent: opts.agent, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant.run });
|
|
166
|
+
if (scopeRun) die(scopeRun, 1);
|
|
157
167
|
|
|
158
168
|
// One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
|
|
159
169
|
// restart the synchronous launcher. A live same-order run is now the repeat target: attach
|
|
@@ -187,10 +197,19 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
187
197
|
);
|
|
188
198
|
}
|
|
189
199
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
const
|
|
200
|
+
// Review 2026-09-17: the slow probe widened the window between the busy check and registration.
|
|
201
|
+
// Probe first so the busy check sees writers that registered while it waited.
|
|
202
|
+
// Keep dead refusal before retention to preserve old run artifacts.
|
|
203
|
+
const sandboxProbe = await probeSandbox({ agent: opts.agent, repo: repoRoot });
|
|
204
|
+
if (sandboxProbe.outcome === 'dead') die(sandboxRefusal(sandboxProbe));
|
|
205
|
+
|
|
206
|
+
// Everything that can refuse without touching the tree is asked here, after the probe so the
|
|
207
|
+
// busy check sees writers that registered while it waited, and before makeRunDir below: on
|
|
208
|
+
// 2026-09-19 a busy refusal left its folder inside ~/.claude and the live run's witness spent
|
|
209
|
+
// every tool call demanding the orchestrator revert a directory the tool itself had created.
|
|
210
|
+
// Exit 1, not the usage code 2: the order was correct, the host or the tree was not.
|
|
211
|
+
const preflightError = preflightRefusal({ agent: opts.agent, projectRunsRoot, repoRoot });
|
|
212
|
+
if (preflightError) die(preflightError, 1);
|
|
194
213
|
|
|
195
214
|
let retention = null;
|
|
196
215
|
try {
|
|
@@ -204,7 +223,7 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
204
223
|
const runDir = makeRunDir(runDirPath(projectRunsRoot, opts.slug));
|
|
205
224
|
setRun(runDir, opts.agent);
|
|
206
225
|
|
|
207
|
-
// Written before
|
|
226
|
+
// Written before the worker can start. From here on a killed runner leaves a folder that
|
|
208
227
|
// says what it was and whose pid to check, instead of a folder that says nothing — the run
|
|
209
228
|
// itself takes 20-25 minutes, far longer than the caller's default timeout, so being killed
|
|
210
229
|
// mid-run is the normal way for this to end, not the exotic one.
|
|
@@ -220,8 +239,10 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
220
239
|
agent: opts.agent,
|
|
221
240
|
slug: opts.slug,
|
|
222
241
|
order_id: opts.orderId,
|
|
242
|
+
phase: opts.phase,
|
|
243
|
+
...(taskGate.advice === undefined ? {} : { advice: taskGate.advice }),
|
|
223
244
|
// Fingerprint of the order, so a later run of the same task finds this one whatever it
|
|
224
|
-
// calls itself. Written here, before Codex
|
|
245
|
+
// calls itself. Written here, before Codex starts, like everything the chain reads.
|
|
225
246
|
task_hash: taskHash,
|
|
226
247
|
repo: repoRoot,
|
|
227
248
|
started_at: new Date().toISOString(),
|
|
@@ -231,30 +252,13 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
231
252
|
// `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
|
|
232
253
|
...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
|
|
233
254
|
...(retention ? { retention } : {}),
|
|
255
|
+
sandbox_probe: sandboxProbe,
|
|
234
256
|
});
|
|
235
257
|
|
|
236
258
|
// Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
|
|
237
259
|
// orchestrator with a folder to look into.
|
|
238
260
|
console.log(`RUN=${runDir} order-id=${opts.orderId}`);
|
|
239
261
|
|
|
240
|
-
// Before requireCodex, so a blocked run costs nothing at all.
|
|
241
|
-
if (busy) {
|
|
242
|
-
const identityNote = busy.identity === 'unverified'
|
|
243
|
-
? '; process identity could not be confirmed'
|
|
244
|
-
: '';
|
|
245
|
-
const { reply } = writeFailure(
|
|
246
|
-
runDir,
|
|
247
|
-
opts.agent,
|
|
248
|
-
`run ${busy.run} is already active for this repository; two writing runs in one tree are prohibited${identityNote}`,
|
|
249
|
-
[`Active run: ${path.join(projectRunsRoot, busy.run)}`, 'Codex was not started; quota was not spent'],
|
|
250
|
-
true,
|
|
251
|
-
);
|
|
252
|
-
console.log(reply);
|
|
253
|
-
return 1;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
requireCodex(runDir, opts.agent);
|
|
257
|
-
|
|
258
262
|
const scope = opts.agent === 'codex-review' ? reviewScope(repoRoot, opts.changeset) : null;
|
|
259
263
|
if (scope) {
|
|
260
264
|
fs.writeFileSync(
|
|
@@ -270,6 +274,7 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
270
274
|
if (opts.agent === 'codex-scout') {
|
|
271
275
|
fs.writeFileSync(path.join(runDir, 'questions.json'), `${JSON.stringify(questions, null, 2)}\n`);
|
|
272
276
|
}
|
|
277
|
+
if (opts.agent === 'codex-advisor') fs.writeFileSync(path.join(runDir, 'advisor-task.json'), `${JSON.stringify(parseAdvisorTask(taskText), null, 2)}\n`);
|
|
273
278
|
|
|
274
279
|
if (opts.agent === 'codex-build') {
|
|
275
280
|
fs.writeFileSync(path.join(runDir, 'scope.txt'), `${opts.scopePatterns.join('\n')}\n`);
|
|
@@ -315,7 +320,7 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
315
320
|
fs.writeFileSync(path.join(runDir, 'task.md'), `${sections.join('\n\n')}\n`);
|
|
316
321
|
fs.writeFileSync(
|
|
317
322
|
path.join(runDir, 'schema.json'),
|
|
318
|
-
`${JSON.stringify(
|
|
323
|
+
`${JSON.stringify(schemaFor(opts.agent, opts.phase), null, 2)}\n`,
|
|
319
324
|
);
|
|
320
325
|
|
|
321
326
|
if (opts.agent === 'codex-build') {
|
|
@@ -348,7 +353,8 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
348
353
|
repo: repoRoot,
|
|
349
354
|
isGitRepo,
|
|
350
355
|
launcherPid: process.pid,
|
|
351
|
-
|
|
356
|
+
phase: opts.phase,
|
|
357
|
+
budgetMinutes: RUN_ENV.budgets[agentRole(opts.agent)][opts.phase],
|
|
352
358
|
scopeNew: opts.scopeNewPatterns,
|
|
353
359
|
profile: runProfile({ ...opts, repo: repoRoot }),
|
|
354
360
|
args: codexArgv,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refusals that must not touch the tree, decided before a run is registered. On 2026-09-19,
|
|
3
|
+
* a busy-tree refusal left its own folder in the live writer's witness. This boundary knows
|
|
4
|
+
* only the project runs root and existing holders, never the prospective run's directory.
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { advisorTaskRefusal } from '../meta/advisor-task.mjs';
|
|
9
|
+
import { activeRunDetails, readJson } from '../write-meta.mjs';
|
|
10
|
+
import { codexUnavailableReason } from './codex-cmd.mjs';
|
|
11
|
+
import { agentRole } from '../agents.mjs';
|
|
12
|
+
import { die } from './args.mjs';
|
|
13
|
+
|
|
14
|
+
/** Plan_59 D5/D6: settle design authority before any probe can spend quota. */
|
|
15
|
+
export function taskPreflight({ agent, taskText }) {
|
|
16
|
+
const freeRefusal = (reason) => ({
|
|
17
|
+
refusal: `${reason}\nThe run folder was not created; quota was not spent.`,
|
|
18
|
+
});
|
|
19
|
+
if (agent === 'codex-advisor') {
|
|
20
|
+
const reason = advisorTaskRefusal(taskText);
|
|
21
|
+
return reason ? freeRefusal(reason) : { refusal: null };
|
|
22
|
+
}
|
|
23
|
+
if (agent !== 'codex-build') return { refusal: null };
|
|
24
|
+
const lines = taskText.split(/\r\n|\n|\r/)
|
|
25
|
+
.flatMap((line) => {
|
|
26
|
+
const match = line.match(/^\s*(?:-\s+)?advice\s*:\s*(.*?)\s*$/i);
|
|
27
|
+
return match ? [match[1]] : [];
|
|
28
|
+
});
|
|
29
|
+
const advice = lines[0];
|
|
30
|
+
if (lines.length === 1) {
|
|
31
|
+
if (['mechanical', 'revert', 'docs-only', 'test-only'].includes(advice)) {
|
|
32
|
+
return { refusal: null, advice };
|
|
33
|
+
}
|
|
34
|
+
if (path.isAbsolute(advice)) {
|
|
35
|
+
try {
|
|
36
|
+
if (fs.statSync(advice).isDirectory() &&
|
|
37
|
+
readJson(path.join(advice, 'meta.json'))?.agent === 'codex-advisor') {
|
|
38
|
+
return { refusal: null, advice };
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Plan_59 D6: missing or unreadable evidence cannot authorize a construction.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return freeRefusal('codex-build requires exactly one advice: line: mechanical | revert | docs-only | test-only ' +
|
|
46
|
+
'or an absolute path to an existing advisor run directory with meta.json agent codex-advisor. ' +
|
|
47
|
+
'An order that invents a construction needs a second opinion first; only work with no design choice may skip it.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Plan_59 D14: an advice pass may continue only a completed, successful advisor scope run. */
|
|
51
|
+
export function scopeRunRefusal({ agent, phase, runsRoot, grantRun }) {
|
|
52
|
+
if (agent !== 'codex-advisor' || phase !== 'advise') return null;
|
|
53
|
+
let meta;
|
|
54
|
+
try {
|
|
55
|
+
meta = readJson(path.join(runsRoot, grantRun, 'meta.json'));
|
|
56
|
+
} catch {
|
|
57
|
+
meta = null;
|
|
58
|
+
}
|
|
59
|
+
const failed = [
|
|
60
|
+
meta?.agent === 'codex-advisor' ? null : "agent must be 'codex-advisor'",
|
|
61
|
+
meta?.phase === 'scope' ? null : "phase must be 'scope'",
|
|
62
|
+
meta?.status === 'OK' ? null : "status must be 'OK'",
|
|
63
|
+
].filter(Boolean);
|
|
64
|
+
if (!failed.length) return null;
|
|
65
|
+
return `continued run meta.json failed ${failed.join(', ')}. The run folder was not created; quota was not spent.`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** D2: phase mistakes must refuse before even the sandbox probe can spend quota. */
|
|
69
|
+
export function resolveRunPhase({ agent, phase, continue: isContinue }, budgets) {
|
|
70
|
+
// Plan_59 D7: phase 2 must retain the scope run's predictions before quota is spent.
|
|
71
|
+
if (agent === 'codex-advisor' && phase === 'advise' && !isContinue) {
|
|
72
|
+
die('codex-advisor --phase advise requires --continue: phase 2 continues the scope run of the same order ' +
|
|
73
|
+
'so it can settle the risks phase 1 predicted. The run folder was not created; quota was not spent.');
|
|
74
|
+
}
|
|
75
|
+
const phases = budgets[agentRole(agent)];
|
|
76
|
+
const names = Object.keys(phases);
|
|
77
|
+
if (phase === undefined && names.length === 1 && names[0] === 'default') return 'default';
|
|
78
|
+
if (phase !== undefined && Object.hasOwn(phases, phase)) return phase;
|
|
79
|
+
die(`${phase === undefined ? '--phase is required' : `undeclared --phase ${JSON.stringify(phase)}`} ` +
|
|
80
|
+
`for ${agent}. Action: pass --phase <name>; allowed phases: ${names.join(', ')}. ` +
|
|
81
|
+
'The run folder was not created; quota was not spent.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function preflightRefusal({ agent, projectRunsRoot, repoRoot }) {
|
|
85
|
+
// Two writing runs share one tree: the second snapshot would include the first one's edits.
|
|
86
|
+
const busy = agent === 'codex-build' ? activeRunDetails(projectRunsRoot, repoRoot) : null;
|
|
87
|
+
if (busy) {
|
|
88
|
+
const identityNote = busy.identity === 'unverified'
|
|
89
|
+
? '; process identity could not be confirmed'
|
|
90
|
+
: '';
|
|
91
|
+
return [
|
|
92
|
+
`run ${busy.run} is already active for this repository; two writing runs in one tree are prohibited${identityNote}`,
|
|
93
|
+
`Active run: ${path.join(projectRunsRoot, busy.run)}`,
|
|
94
|
+
// One sentence, the same one every other pre-flight refusal ends with. The older wording
|
|
95
|
+
// said the identical thing twice, which is how two copies of a rule start to drift.
|
|
96
|
+
'The run folder was not created; quota was not spent.',
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const why = codexUnavailableReason();
|
|
101
|
+
if (why !== null) {
|
|
102
|
+
return [
|
|
103
|
+
`Codex CLI unavailable: ${why}`,
|
|
104
|
+
'Operator check: codex --version (and codex login if authorization is rejected)',
|
|
105
|
+
'The run folder was not created; quota was not spent.',
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
@@ -8,6 +8,45 @@
|
|
|
8
8
|
import { DEFAULTS } from '../run-config.mjs';
|
|
9
9
|
import { RUN_ENV } from './run-env.mjs';
|
|
10
10
|
const BODIES = {
|
|
11
|
+
// Plan_59 D3-D5/D7/D10: independent design advice needs explicit lenses and a risk ledger.
|
|
12
|
+
'codex-advisor': (opts) => {
|
|
13
|
+
if (opts.phase !== 'scope' && opts.phase !== 'advise') {
|
|
14
|
+
throw new RangeError('codex-advisor instructions require phase scope or advise.');
|
|
15
|
+
}
|
|
16
|
+
return `Judge a DESIGN before code exists.
|
|
17
|
+
|
|
18
|
+
Rules for both phases:
|
|
19
|
+
- Read ONLY the paths listed under \`## Paths\`, plus \`git log\` / \`git show\` on those paths.
|
|
20
|
+
- Write nothing. Do your own reading and checks; do not spawn or delegate to other agents.
|
|
21
|
+
- Every claim about code must carry a \`path:line\` address inside those paths. Addresses are
|
|
22
|
+
machine-checked for existence; do not invent addresses or treat an unchecked claim as evidence.
|
|
23
|
+
- Do not guess which option the caller prefers: the task carries no preference on purpose.
|
|
24
|
+
- Two failure modes are forbidden: agreeing without independent checks ("both options are good",
|
|
25
|
+
"you know better"), and disagreeing to look independent. A disagreement without an address is
|
|
26
|
+
an opinion, not advice. Agreeing is fine when your own checks support it.
|
|
27
|
+
${opts.phase === 'scope' ? `
|
|
28
|
+
Scope phase:
|
|
29
|
+
- BEFORE reading in depth, name 3-5 predicted_risks with ids r1..r5: where this design most likely
|
|
30
|
+
breaks. Record those predictions first, then read; do not replace them with hindsight.
|
|
31
|
+
- Answer sufficient, missing_paths (concrete repository paths you still need), and taken_on_trust
|
|
32
|
+
(facts accepted from the task without checking, never empty), together with predicted_risks.
|
|
33
|
+
` : `
|
|
34
|
+
Advise phase:
|
|
35
|
+
- Settle every predicted risk from the scope run in risk_outcomes: its risk_id, an outcome of
|
|
36
|
+
confirmed or refuted, and an address supporting that outcome.
|
|
37
|
+
- Give ONE recommendation by task option_id, or none-of-these with a described unlisted_option.
|
|
38
|
+
Put every rejected task option in rejected with its cost; for none-of-these this means EVERY
|
|
39
|
+
task option. Set unlisted_option to "none" when recommending a listed option.
|
|
40
|
+
- Each field is a distinct lens: strongest_counterargument comes from a skeptic;
|
|
41
|
+
pre_mortem comes from the executor who has only this advice and the repository. Give each
|
|
42
|
+
scenario an early_check of kind test | command | inspect with a target; inspect requires an address.
|
|
43
|
+
- question_defect comes from the requester who needs the problem solved, not an option picked.
|
|
44
|
+
- Rate assumptions VERIFIED (requires an address) | REASONABLE | FRAGILE. Anything the requester
|
|
45
|
+
could refute with knowledge you lack belongs in open_questions, not why.
|
|
46
|
+
- independent_checks lists what you verified yourself, with addresses. Ground why in those checks
|
|
47
|
+
and state confidence honestly.
|
|
48
|
+
`}`;
|
|
49
|
+
},
|
|
11
50
|
'codex-scout': (opts, scope, questions = []) => `Response rules:
|
|
12
51
|
- You are working in read-only mode: do not try to write or edit anything.
|
|
13
52
|
- Answer from your own reading: do not spawn or delegate to other agents.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Checks whether this host's Codex sandbox can start a process before a run spends quota. */
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { sandboxModeFor } from './codex-args.mjs';
|
|
4
|
+
import { codexSpawnSpec, spawnCaptured } from './codex-cmd.mjs';
|
|
5
|
+
import { platformSandboxArgs } from './sandbox-flags.mjs';
|
|
6
|
+
|
|
7
|
+
export const SANDBOX_PROBE_MARKER = 'codex-bridge-sandbox-ok';
|
|
8
|
+
// Plan_57 D12/D19: a platform is judged only after both outcomes were seen live. Linux joined on
|
|
9
|
+
// 2026-09-17: dead on an Ubuntu 24.04 VPS (bwrap without a user namespace, exit 1, no marker), alive
|
|
10
|
+
// on the same host after the AppArmor repair. macOS has never been observed.
|
|
11
|
+
export const PROBED_PLATFORMS = new Set(['win32', 'linux']);
|
|
12
|
+
|
|
13
|
+
// Ubuntu 23.10+ forbids unprivileged user namespaces unless an AppArmor profile allows bwrap; that
|
|
14
|
+
// is the ordinary state of a fresh server, not an accident like the corrupted Windows state file.
|
|
15
|
+
const LINUX_REPAIR = [
|
|
16
|
+
'On Ubuntu 23.10 and later this usually means AppArmor does not let bubblewrap create a user namespace.',
|
|
17
|
+
'Official repair: https://learn.chatgpt.com/docs/sandboxing — the bwrap-userns-restrict AppArmor profile',
|
|
18
|
+
'(Ubuntu 24.04) or the bubblewrap package (25.04 and later). Do not set',
|
|
19
|
+
'kernel.apparmor_restrict_unprivileged_userns=0: it lifts the restriction for every program on the host.',
|
|
20
|
+
].join(' ');
|
|
21
|
+
|
|
22
|
+
const echoArgs = (platform) => platform === 'win32'
|
|
23
|
+
? ['cmd', '/d', '/c', 'echo', SANDBOX_PROBE_MARKER]
|
|
24
|
+
: ['echo', SANDBOX_PROBE_MARKER];
|
|
25
|
+
|
|
26
|
+
function inconclusiveReason(result, form) {
|
|
27
|
+
if (result.error) {
|
|
28
|
+
return result.error.code === 'ETIMEDOUT'
|
|
29
|
+
? `The ${form} sandbox probe timed out.`
|
|
30
|
+
: `The ${form} sandbox probe could not start or complete.`;
|
|
31
|
+
}
|
|
32
|
+
if (result.signal) return `The ${form} sandbox probe was terminated by ${result.signal}.`;
|
|
33
|
+
if (result.status === 2) return `Codex rejected the ${form} sandbox probe arguments.`;
|
|
34
|
+
if (result.status == null) return `The ${form} sandbox probe returned no exit status.`;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function probeSandbox({
|
|
39
|
+
agent, repo, platform = process.platform, run = spawnCaptured, timeoutMs = 30_000,
|
|
40
|
+
}) {
|
|
41
|
+
if (!PROBED_PLATFORMS.has(platform)) return { outcome: 'skipped' };
|
|
42
|
+
const sandbox = sandboxModeFor(agent);
|
|
43
|
+
if (typeof repo !== 'string' || !repo) throw new Error('sandbox probe requires a repository path');
|
|
44
|
+
const echo = echoArgs(platform);
|
|
45
|
+
const attempts = [];
|
|
46
|
+
const finish = (outcome, reason) => ({ outcome, reason, attempts });
|
|
47
|
+
const attempt = async (form, args) => {
|
|
48
|
+
const spec = codexSpawnSpec(args, platform);
|
|
49
|
+
const started = performance.now();
|
|
50
|
+
const result = await run(spec.command, spec.args, {
|
|
51
|
+
...spec.options, cwd: repo, encoding: 'utf8', timeout: timeoutMs,
|
|
52
|
+
});
|
|
53
|
+
const marker = String(result.stdout ?? '').includes(SANDBOX_PROBE_MARKER);
|
|
54
|
+
attempts.push({
|
|
55
|
+
form, status: result.status ?? null, marker,
|
|
56
|
+
ms: Math.round(performance.now() - started),
|
|
57
|
+
// D2: stderr is evidence for the operator, never input to the verdict.
|
|
58
|
+
stderrTail: String(result.stderr ?? '').slice(-300),
|
|
59
|
+
});
|
|
60
|
+
return { ...result, marker };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// On 2026-09-16 a corrupt deny_read_acl_state.json made every paid run unable to start tools.
|
|
64
|
+
// On 2026-09-17, -C required --permission-profile instead: cwd must be a process option.
|
|
65
|
+
// D16: no single failure exit code proves a dead sandbox; both forms and a live CLI must agree.
|
|
66
|
+
const flagged = await attempt('flagged', [
|
|
67
|
+
'sandbox', ...platformSandboxArgs(platform), '-c', `sandbox_mode=${sandbox}`, '--', ...echo,
|
|
68
|
+
]);
|
|
69
|
+
const flaggedReason = inconclusiveReason(flagged, 'flagged');
|
|
70
|
+
if (flaggedReason) return finish('inconclusive', flaggedReason);
|
|
71
|
+
if (flagged.status === 0 && flagged.marker) {
|
|
72
|
+
return finish('alive', 'The Codex sandbox started a process.');
|
|
73
|
+
}
|
|
74
|
+
// D16: the marker proves a process started, so a nonzero exit cannot prove a dead sandbox.
|
|
75
|
+
if (flagged.marker) {
|
|
76
|
+
return finish('inconclusive', `The flagged sandbox probe printed the marker but exited ${flagged.status}.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const control = await attempt('control', ['sandbox', '--', ...echo]);
|
|
80
|
+
const controlReason = inconclusiveReason(control, 'control');
|
|
81
|
+
if (controlReason) return finish('inconclusive', controlReason);
|
|
82
|
+
if (control.marker) {
|
|
83
|
+
return finish('inconclusive', 'This version of Codex no longer accepts the package sandbox flags.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const version = await attempt('version', ['--version']);
|
|
87
|
+
if (version.error || version.signal || version.status !== 0) {
|
|
88
|
+
// codexUnavailableReason() owns the refusal for an unavailable CLI, in the same pre-flight pass.
|
|
89
|
+
return finish('inconclusive', 'Codex CLI is unavailable.');
|
|
90
|
+
}
|
|
91
|
+
return finish('dead', 'The Codex sandbox on this host cannot start a process.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function sandboxRefusal(result, platform = process.platform) {
|
|
95
|
+
const control = ['codex', 'sandbox', '--', ...echoArgs(platform)].join(' ');
|
|
96
|
+
const stderr = result.attempts
|
|
97
|
+
.filter(({ form }) => form === 'flagged' || form === 'control')
|
|
98
|
+
.map(({ form, stderrTail }) =>
|
|
99
|
+
`${form} stderr:\n> ${(stderrTail || '(empty)').replace(/\r\n?/g, '\n').replace(/\n/g, '\n> ')}`);
|
|
100
|
+
// Only a dead result is refused, and its reason already is the headline; printing both said it twice.
|
|
101
|
+
return [
|
|
102
|
+
result.reason,
|
|
103
|
+
'Repair the host sandbox; do not rewrite or retry the order.',
|
|
104
|
+
...(platform === 'linux' ? [LINUX_REPAIR] : []),
|
|
105
|
+
`Operator check (run from the repository directory): ${control}`,
|
|
106
|
+
...stderr,
|
|
107
|
+
'The run folder was not created; quota was not spent.',
|
|
108
|
+
].join('\n\n');
|
|
109
|
+
}
|