@lyupro/codex-bridge 0.1.0
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 +39 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/codex-bridge.mjs +105 -0
- package/cli/copy.mjs +38 -0
- package/cli/doctor.mjs +170 -0
- package/cli/hosts.mjs +50 -0
- package/cli/install.mjs +109 -0
- package/cli/manifest.mjs +210 -0
- package/cli/settings-merge.mjs +101 -0
- package/cli/stop.mjs +125 -0
- package/cli/uninstall.mjs +62 -0
- package/cli/update.mjs +156 -0
- package/package.json +48 -0
- package/scripts/check-file-size.mjs +193 -0
- package/scripts/run-tests.mjs +26 -0
- package/src/agents/codex-build.md +224 -0
- package/src/agents/codex-review.md +166 -0
- package/src/agents/codex-scout.md +188 -0
- package/src/commands/env.md +38 -0
- package/src/commands/usage.md +88 -0
- package/src/hooks/reply-guard.mjs +311 -0
- package/src/meta/chain.mjs +87 -0
- package/src/meta/environment.mjs +36 -0
- package/src/meta/paths.mjs +193 -0
- package/src/meta/reply.mjs +133 -0
- package/src/meta/run-state.mjs +197 -0
- package/src/meta/verdict.mjs +362 -0
- package/src/rules/codex-bridge.rules +173 -0
- package/src/run-codex.mjs +113 -0
- package/src/run-config.json +4 -0
- package/src/run-config.mjs +302 -0
- package/src/runner/args.mjs +111 -0
- package/src/runner/attach.mjs +111 -0
- package/src/runner/codex-cmd.mjs +265 -0
- package/src/runner/git-state.mjs +144 -0
- package/src/runner/launcher.mjs +374 -0
- package/src/runner/project-dir.mjs +92 -0
- package/src/runner/prompts.mjs +97 -0
- package/src/runner/run-context.mjs +52 -0
- package/src/runner/run-env.mjs +38 -0
- package/src/runner/runs-root.mjs +16 -0
- package/src/runner/schemas.mjs +135 -0
- package/src/runner/worker.mjs +62 -0
- package/src/write-meta.mjs +135 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives a run's status from what its artifacts prove, never from what the run claims.
|
|
3
|
+
*
|
|
4
|
+
* Exit code, log size, result file, the worktree either side of the run, HEAD either side
|
|
5
|
+
* of it, and the sub-questions the order asked — a dispatcher agent therefore cannot report
|
|
6
|
+
* an outcome its own run does not support. Every branch in resolveStatus() exists because
|
|
7
|
+
* a real run satisfied every schema while doing something other than the job.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
SERVICE_RE,
|
|
13
|
+
changedPaths,
|
|
14
|
+
declaredHits,
|
|
15
|
+
displayPath,
|
|
16
|
+
expandDeclared,
|
|
17
|
+
globToRegExp,
|
|
18
|
+
line,
|
|
19
|
+
normalizePath,
|
|
20
|
+
readJson,
|
|
21
|
+
readText,
|
|
22
|
+
} from './paths.mjs';
|
|
23
|
+
import { chainBaseline } from './chain.mjs';
|
|
24
|
+
import { splitRunChanges } from './environment.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Quota exhaustion is a transport error, not a word. Both markers must sit on the same
|
|
28
|
+
* line: a run whose review text merely discusses "quota exhaustion" is not a run that
|
|
29
|
+
* hit the quota, and reporting LIMIT there tells the orchestrator not to retry when it
|
|
30
|
+
* should. "try again later" is deliberately absent — it is a transient-failure phrase,
|
|
31
|
+
* not a quota one.
|
|
32
|
+
*/
|
|
33
|
+
const LIMIT_RE = /rate.?limit|usage limit|usage_limit|quota exceeded|quota exhausted|too many requests|\b429\b/i;
|
|
34
|
+
const ERROR_RE = /\bERROR\b|error[:=]|stream error|"status"\s*:\s*429|rejected|refused|failed/i;
|
|
35
|
+
|
|
36
|
+
// How much prose an answer must carry once coordinates and paths are subtracted. Measured
|
|
37
|
+
// on the scout run of 2026-07-30 that replied with a table of `file.ts:60-79` rows: every
|
|
38
|
+
// one of its six sub-answers scored under 30, while the shortest honest explanation of a
|
|
39
|
+
// single mechanism in the same report scored above 120. 80 is one full sentence; 200 is
|
|
40
|
+
// the single-question mode, where one question means the whole answer is the essay.
|
|
41
|
+
const MIN_SUBSTANCE_CHARS = 80;
|
|
42
|
+
const MIN_SINGLE_SUBSTANCE_CHARS = 200;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The honest reason for a failure. Codex prints API errors as pretty-printed JSON, so
|
|
46
|
+
* the literally-last line is often just `}` — useless in a one-line reply. Prefer the
|
|
47
|
+
* error message field, then any error line, then the last line that carries text.
|
|
48
|
+
*/
|
|
49
|
+
const reasonFrom = (log) => {
|
|
50
|
+
const messages = [...log.matchAll(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/g)];
|
|
51
|
+
if (messages.length) return line(messages[messages.length - 1][1].replace(/\\"/g, '"'));
|
|
52
|
+
const lines = log.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
53
|
+
const errorLine = [...lines].reverse().find((l) => /error|failed|denied|refused/i.test(l));
|
|
54
|
+
return line(errorLine || lines[lines.length - 1] || '');
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const limitSignal = (log) => {
|
|
58
|
+
const hit = log.split(/\r?\n/).find((l) => LIMIT_RE.test(l) && ERROR_RE.test(l));
|
|
59
|
+
return hit ? line(hit) : null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Which touched paths the task never authorised. The scope exists because prose does not
|
|
64
|
+
* bind: one build run was told in words to keep away from `!Plans/*.md` and edited them
|
|
65
|
+
* anyway, and nothing in the artifacts objected — reportVersusWork() only needs a single
|
|
66
|
+
* declared path to match the tree, so extra files were never looked at.
|
|
67
|
+
*
|
|
68
|
+
* Service directories are outside every scope by construction: no task grants a delegated
|
|
69
|
+
* run the right to edit `.git/` or `.claude/`, so no pattern can whitelist them.
|
|
70
|
+
*/
|
|
71
|
+
export function outOfScope(touchedPaths, patterns) {
|
|
72
|
+
const allowed = (patterns || []).filter(Boolean).map(globToRegExp);
|
|
73
|
+
const bad = [];
|
|
74
|
+
for (const raw of touchedPaths || []) {
|
|
75
|
+
const file = displayPath(raw);
|
|
76
|
+
const comparison = normalizePath(file);
|
|
77
|
+
if (!comparison) continue;
|
|
78
|
+
if (SERVICE_RE.test(comparison)) {
|
|
79
|
+
bad.push(file);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (allowed.length && !allowed.some((re) => re.test(file))) bad.push(file);
|
|
83
|
+
}
|
|
84
|
+
return bad;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reserved for verdicts where the work really was the wrong work, not for check artefacts. */
|
|
88
|
+
const WRONG_WORK = 'wrong work was done: ';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Does the report describe the work that actually happened? A schema cannot answer this:
|
|
92
|
+
* a well-formed report about unrelated work is indistinguishable from a well-formed
|
|
93
|
+
* report about the job. The worktree can — it was snapshotted either side of the run.
|
|
94
|
+
*
|
|
95
|
+
* The report describes the state of the TASK, so it is matched against the run's own delta
|
|
96
|
+
* first and against the whole chain's accumulated work second (baseline of the first run ->
|
|
97
|
+
* this run's "after"). A second pass that legitimately changed little is carried, not
|
|
98
|
+
* failed. Both sides empty stays agreement: "there was nothing to change" is a legitimate
|
|
99
|
+
* outcome, and failing it would punish the one honest way to say so.
|
|
100
|
+
*/
|
|
101
|
+
export function reportVersusWork(runDir, result, ctx) {
|
|
102
|
+
const declared = (result?.changes || []).flatMap((c) => expandDeclared(c?.file));
|
|
103
|
+
const after = readText(path.join(runDir, 'state-after.txt'));
|
|
104
|
+
// Only the run's own work is compared: a report that names nothing is honest when the sole
|
|
105
|
+
// change in the tree was written by the tooling around the run.
|
|
106
|
+
const touched = splitRunChanges(
|
|
107
|
+
runDir,
|
|
108
|
+
changedPaths(readText(path.join(runDir, 'state-before.txt')), after),
|
|
109
|
+
).work;
|
|
110
|
+
const show = (list) => list.slice(0, 3).join(', ');
|
|
111
|
+
const agreed = (carried = false) => ({ ok: true, carried, reason: null });
|
|
112
|
+
const broken = (reason) => ({ ok: false, carried: false, reason });
|
|
113
|
+
|
|
114
|
+
if (!declared.length && !touched.length) return agreed();
|
|
115
|
+
if (!declared.length) {
|
|
116
|
+
return broken(
|
|
117
|
+
`${WRONG_WORK}tree changed (${touched.length}: ${show(touched)}), but the report names no changes`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (declared.some((d) => declaredHits(d, touched))) return agreed();
|
|
121
|
+
|
|
122
|
+
// Ahead of the chain lookup: no earlier pass of any task grants the right to edit `.git/`
|
|
123
|
+
// or `.claude/`, so finding such a path in the chain would excuse the one thing that is
|
|
124
|
+
// never excusable.
|
|
125
|
+
const service = declared.filter((f) => SERVICE_RE.test(f));
|
|
126
|
+
if (service.length) {
|
|
127
|
+
return broken(
|
|
128
|
+
`${WRONG_WORK}report describes work in service directories (${show(service)}), not task files`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const baseline = chainBaseline(ctx?.runsRoot, ctx?.repo, ctx?.slug, ctx?.taskHash, ctx?.orderId);
|
|
133
|
+
if (baseline !== null) {
|
|
134
|
+
const accumulated = splitRunChanges(runDir, changedPaths(baseline, after)).work;
|
|
135
|
+
if (declared.some((d) => declaredHits(d, accumulated))) return agreed(true);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return broken(
|
|
139
|
+
touched.length
|
|
140
|
+
? `${WRONG_WORK}report names ${show(declared)}, but ${show(touched)} changed`
|
|
141
|
+
: 'run did not change the tree, and earlier runs of this task do not contain the declared files',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Which task a run belongs to, for the chain lookup. status.json is written before Codex is
|
|
147
|
+
* even probed, so repo and slug are on disk by the time a verdict is computed; a run from
|
|
148
|
+
* before status.json existed yields an empty context, and the chain is then not guessed.
|
|
149
|
+
*/
|
|
150
|
+
const chainContextOf = (runDir) => {
|
|
151
|
+
const status = readJson(path.join(runDir, 'status.json')) || {};
|
|
152
|
+
return {
|
|
153
|
+
runsRoot: path.dirname(runDir),
|
|
154
|
+
repo: status.repo || '',
|
|
155
|
+
slug: status.slug || '',
|
|
156
|
+
// Runs from before the fingerprint existed carry no hash; they chain by slug as they did.
|
|
157
|
+
taskHash: status.task_hash || '',
|
|
158
|
+
orderId: status.order_id || '',
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* How much of an answer is prose rather than a map. Coordinates (`path/to/file.ts:12-40`,
|
|
164
|
+
* `foo.ts:88`) and bare paths are subtracted first, then punctuation and spacing, because a
|
|
165
|
+
* "scout" that returns nothing but a coordinate table is the exact failure this measures
|
|
166
|
+
* — and by character count that table is long.
|
|
167
|
+
*/
|
|
168
|
+
function substanceLength(text) {
|
|
169
|
+
return String(text ?? '')
|
|
170
|
+
.replace(/`+/g, ' ')
|
|
171
|
+
.replace(/\S*\.[A-Za-z0-9]+:\d+(?:\s*[-– — ]\s*\d+)?/g, ' ')
|
|
172
|
+
.replace(/\S*[\\/]\S*/g, ' ')
|
|
173
|
+
.replace(/[^\p{L}\p{N}]+/gu, '').length;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const questionsOf = (runDir) => {
|
|
177
|
+
const parsed = readJson(path.join(runDir, 'questions.json'));
|
|
178
|
+
return Array.isArray(parsed) ? parsed.filter((q) => q && q.id) : [];
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Answers indexed by question id, first one wins; ids come back in whatever case Codex used. */
|
|
182
|
+
const answersById = (result) => {
|
|
183
|
+
const map = new Map();
|
|
184
|
+
for (const answer of Array.isArray(result?.answers) ? result.answers : []) {
|
|
185
|
+
const id = String(answer?.question_id || '').trim().toUpperCase();
|
|
186
|
+
if (id && !map.has(id)) map.set(id, answer);
|
|
187
|
+
}
|
|
188
|
+
return map;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Why a scout run is not done. Coverage is checked against the orchestrator's questions.json,
|
|
193
|
+
* including a one-question order. Runs from before that artifact existed retain the legacy
|
|
194
|
+
* single-answer check. Every branch here exists because a schema alone accepted the run that
|
|
195
|
+
* answered in coordinates.
|
|
196
|
+
*/
|
|
197
|
+
function scoutCoverageGap(runDir, result) {
|
|
198
|
+
const questions = questionsOf(runDir);
|
|
199
|
+
const show = (list) => list.slice(0, 8).join(', ');
|
|
200
|
+
|
|
201
|
+
if (!questions.length) {
|
|
202
|
+
const chars = substanceLength(result?.answer);
|
|
203
|
+
if (chars < MIN_SINGLE_SUBSTANCE_CHARS) {
|
|
204
|
+
return `response without analysis: ${chars} substantive characters, minimum ` +
|
|
205
|
+
`${MIN_SINGLE_SUBSTANCE_CHARS} (coordinates and paths excluded)`;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const byId = answersById(result);
|
|
211
|
+
const missing = questions
|
|
212
|
+
.filter((q) => !String(byId.get(q.id)?.answer || '').trim())
|
|
213
|
+
.map((q) => q.id);
|
|
214
|
+
if (missing.length) {
|
|
215
|
+
return `scout did not answer ${missing.length} sub-questions: ${show(missing)}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const thin = questions
|
|
219
|
+
.filter((q) => substanceLength(byId.get(q.id).answer) < MIN_SUBSTANCE_CHARS)
|
|
220
|
+
.map((q) => q.id);
|
|
221
|
+
if (thin.length) return `response to ${show(thin)} contains coordinates without analysis`;
|
|
222
|
+
|
|
223
|
+
const unsourced = questions
|
|
224
|
+
.filter((q) => !(byId.get(q.id).evidence || []).some((e) => String(e || '').trim()))
|
|
225
|
+
.map((q) => q.id);
|
|
226
|
+
if (unsourced.length) return `responses without a single code reference: ${show(unsourced)}`;
|
|
227
|
+
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** `1/1 sub-questions`, or null for legacy runs with no questions.json artifact. */
|
|
232
|
+
export function scoutCoverage(runDir, result) {
|
|
233
|
+
const questions = questionsOf(runDir);
|
|
234
|
+
if (!questions.length) return null;
|
|
235
|
+
const byId = answersById(result);
|
|
236
|
+
const answered = questions.filter((q) => String(byId.get(q.id)?.answer || '').trim()).length;
|
|
237
|
+
return `${answered}/${questions.length} sub-questions`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A commit made under an explicit ban. HEAD is snapshotted either side of the run, so this
|
|
242
|
+
* is a fact rather than an interpretation: one build run went to commit while the task said
|
|
243
|
+
* not to, and only the sandbox (a read-only `.git`) stopped it — the contract said nothing.
|
|
244
|
+
*/
|
|
245
|
+
function commitDuringRun(runDir) {
|
|
246
|
+
const before = readText(path.join(runDir, 'head-before.txt')).trim();
|
|
247
|
+
const after = readText(path.join(runDir, 'head-after.txt')).trim();
|
|
248
|
+
if (!before || !after || before === after) return null;
|
|
249
|
+
return `commit made despite prohibition: ${before.slice(0, 12)} → ${after.slice(0, 12)}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A branch moved under an explicit ban. Branch names are snapshotted either side of the run,
|
|
254
|
+
* so the artifacts prove whether it stayed on the same ref or left the repository detached.
|
|
255
|
+
*
|
|
256
|
+
* Existence is checked rather than emptiness, because here an empty file is data — detached
|
|
257
|
+
* HEAD — and a missing one is silence. Both snapshots have to be present for the comparison
|
|
258
|
+
* to mean anything: a run whose launcher predates this check, or whose worker died before
|
|
259
|
+
* writing its half, would otherwise be accused of detaching a branch it never touched.
|
|
260
|
+
*/
|
|
261
|
+
function branchDuringRun(runDir) {
|
|
262
|
+
const beforeFile = path.join(runDir, 'branch-before.txt');
|
|
263
|
+
const afterFile = path.join(runDir, 'branch-after.txt');
|
|
264
|
+
if (!fs.existsSync(beforeFile) || !fs.existsSync(afterFile)) return null;
|
|
265
|
+
const before = readText(beforeFile).trim();
|
|
266
|
+
const after = readText(afterFile).trim();
|
|
267
|
+
if (before === after) return null;
|
|
268
|
+
if (before && !after) return `branch moved despite prohibition: ${before} → detached HEAD`;
|
|
269
|
+
return `branch moved despite prohibition: ${before || 'detached HEAD'} → ${after}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Status comes from artifacts, never from intent. An abandoned run leaves raw.log at
|
|
274
|
+
* zero bytes, and that is a FAIL with its own reason — not a retry hint. An empty
|
|
275
|
+
* result with a quota signal in the log is LIMIT; without one it is FAIL.
|
|
276
|
+
*/
|
|
277
|
+
export function resolveStatus({ log, logBytes, resultOk, exit, agent, result, runDir }) {
|
|
278
|
+
if (!logBytes) {
|
|
279
|
+
return {
|
|
280
|
+
status: 'FAIL',
|
|
281
|
+
reason: 'run abandoned at startup: raw.log is empty, Codex did not run',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
// Ahead of everything else, including LIMIT: a commit under an explicit ban is a broken
|
|
285
|
+
// contract, not a grade for the work. Whatever else the run produced, the orchestrator has
|
|
286
|
+
// to learn first that history moved under it.
|
|
287
|
+
if (agent === 'codex-build') {
|
|
288
|
+
const committed = commitDuringRun(runDir);
|
|
289
|
+
if (committed) return { status: 'FAIL', reason: committed };
|
|
290
|
+
const branched = branchDuringRun(runDir);
|
|
291
|
+
if (branched) return { status: 'FAIL', reason: branched };
|
|
292
|
+
}
|
|
293
|
+
if (!resultOk) {
|
|
294
|
+
const limit = limitSignal(log);
|
|
295
|
+
if (limit) return { status: 'LIMIT', reason: limit };
|
|
296
|
+
return { status: 'FAIL', reason: reasonFrom(log) || `result is empty, exit=${exit}` };
|
|
297
|
+
}
|
|
298
|
+
if (exit !== 0) {
|
|
299
|
+
return { status: 'FAIL', reason: `result exists, but exit=${exit}: ${reasonFrom(log)}` };
|
|
300
|
+
}
|
|
301
|
+
// A red verification is a failed job, not an OK with a footnote. The orchestrator
|
|
302
|
+
// reads the first word; burying "fail" in line three is how a broken build passes.
|
|
303
|
+
if (agent === 'codex-build' && result?.verify_passed === false) {
|
|
304
|
+
return {
|
|
305
|
+
status: 'FAIL',
|
|
306
|
+
reason: `verification “${line(result.verify_command || 'no command', 60)}” failed; changes remain in the tree`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
|
|
310
|
+
// only when questions.json is absent — see scoutCoverageGap().
|
|
311
|
+
if (agent === 'codex-scout') {
|
|
312
|
+
const gap = scoutCoverageGap(runDir, result);
|
|
313
|
+
if (gap) return { status: 'FAIL', reason: gap };
|
|
314
|
+
}
|
|
315
|
+
// Files the task never authorised. Checked before the mismatch below, because a run that
|
|
316
|
+
// did the job AND edited three unrelated files passes reportVersusWork() — one matching
|
|
317
|
+
// path is all it needs — and the unrelated files would go unmentioned. No automatic
|
|
318
|
+
// revert: what to do with the extra edits is the orchestrator's call, not this file's.
|
|
319
|
+
if (agent === 'codex-build') {
|
|
320
|
+
const patterns = readText(path.join(runDir, 'scope.txt'))
|
|
321
|
+
.split(/\r?\n/)
|
|
322
|
+
.map((l) => l.trim())
|
|
323
|
+
.filter(Boolean);
|
|
324
|
+
if (patterns.length) {
|
|
325
|
+
// Environment writes are subtracted first: attributing them to the run failed an honest
|
|
326
|
+
// pass for a file it never opened. What was subtracted is reported, not swallowed —
|
|
327
|
+
// see the "Environment" line in reply.mjs and environment_changes in meta.json.
|
|
328
|
+
const { work } = splitRunChanges(
|
|
329
|
+
runDir,
|
|
330
|
+
changedPaths(
|
|
331
|
+
readText(path.join(runDir, 'state-before.txt')),
|
|
332
|
+
readText(path.join(runDir, 'state-after.txt')),
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
const strays = outOfScope(work, patterns);
|
|
336
|
+
if (strays.length) {
|
|
337
|
+
return {
|
|
338
|
+
status: 'FAIL',
|
|
339
|
+
reason: `out-of-scope changes (${strays.length}): ${strays.slice(0, 3).join(', ')}`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Work on the wrong thing is a failed job even when every artifact is well-formed. The
|
|
345
|
+
// run that forced this check was green on all of them while Codex spent the quota
|
|
346
|
+
// quarantining someone else's session file.
|
|
347
|
+
if (agent === 'codex-build') {
|
|
348
|
+
const verdict = reportVersusWork(runDir, result, chainContextOf(runDir));
|
|
349
|
+
if (!verdict.ok) {
|
|
350
|
+
// Hooks on is the known cause of this exact failure, so the reason says so instead
|
|
351
|
+
// of leaving the operator to rediscover it from the log.
|
|
352
|
+
const hooks = readJson(path.join(runDir, 'env.json'))?.hooks
|
|
353
|
+
? ' (hooks are enabled in run-config.json — a likely cause)'
|
|
354
|
+
: '';
|
|
355
|
+
return { status: 'FAIL', reason: `${verdict.reason}${hooks}` };
|
|
356
|
+
}
|
|
357
|
+
// Work found in the chain but not in this run's delta is done work, not a lie: the
|
|
358
|
+
// second pass of a task legitimately has little left to change.
|
|
359
|
+
if (verdict.carried) return { status: 'OK', reason: null, carried: true };
|
|
360
|
+
}
|
|
361
|
+
return { status: 'OK', reason: null };
|
|
362
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# codex-bridge installs this file to constrain delegated Codex runs.
|
|
2
|
+
# It exists because a delegated run must not rewrite the operator's repository history or branches.
|
|
3
|
+
# Check it with: codex execpolicy check --rules src/rules/codex-bridge.rules -- <command> <args>.
|
|
4
|
+
# Codex combines this file with other active rule files, and the strictest matching decision wins.
|
|
5
|
+
# Known limit: prefix rules cannot detect shell redirection into .git/HEAD or equivalent indirect writes.
|
|
6
|
+
|
|
7
|
+
prefix_rule(
|
|
8
|
+
pattern = ["git", "reset"],
|
|
9
|
+
decision = "forbidden",
|
|
10
|
+
justification = "Leave your work uncommitted in the tree; the index, commits and HEAD belong to the orchestrator.",
|
|
11
|
+
match = ["git reset --hard HEAD~1", "git reset HEAD -- file.txt"],
|
|
12
|
+
not_match = ["git status", "git diff --stat"],
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
prefix_rule(
|
|
16
|
+
pattern = ["git", "checkout"],
|
|
17
|
+
decision = "forbidden",
|
|
18
|
+
justification = "Read another revision with git show <rev>:<path>; branches and revisions are switched by the operator, never by a run.",
|
|
19
|
+
match = ["git checkout master", "git checkout -- file.txt"],
|
|
20
|
+
not_match = ["git show HEAD:file.txt", "git restore file.txt"],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
prefix_rule(
|
|
24
|
+
pattern = ["git", "switch"],
|
|
25
|
+
decision = "forbidden",
|
|
26
|
+
justification = "Inspect branches with git branch --show-current and leave branch switching or creation to the operator.",
|
|
27
|
+
match = ["git switch main", "git switch -c feature"],
|
|
28
|
+
not_match = ["git branch --show-current", "git status"],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
prefix_rule(
|
|
32
|
+
pattern = ["git", "branch", ["-d", "-D", "-m", "-M", "--delete", "--move", "--force"]],
|
|
33
|
+
decision = "forbidden",
|
|
34
|
+
justification = "Inspect branches with git branch or git branch --show-current; ask the operator to delete, rename, or force a branch.",
|
|
35
|
+
match = ["git branch -D feature", "git branch --move old new", "git branch --force feature HEAD"],
|
|
36
|
+
not_match = ["git branch", "git branch --show-current", "git branch --list"],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
prefix_rule(
|
|
40
|
+
pattern = ["git", ["rebase", "cherry-pick", "revert", "merge"]],
|
|
41
|
+
decision = "forbidden",
|
|
42
|
+
justification = "Inspect candidate changes with git show or git diff and leave history integration to the operator.",
|
|
43
|
+
match = ["git rebase main", "git cherry-pick abc123", "git revert HEAD", "git merge feature"],
|
|
44
|
+
not_match = ["git show HEAD", "git diff main...feature"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
prefix_rule(
|
|
48
|
+
pattern = ["git", "stash", ["drop", "clear", "pop"]],
|
|
49
|
+
decision = "forbidden",
|
|
50
|
+
justification = "Inspect saved work with git stash list or git stash show and ask the operator before removing or applying it.",
|
|
51
|
+
match = ["git stash drop", "git stash clear", "git stash pop"],
|
|
52
|
+
not_match = ["git stash", "git stash list", "git stash show"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
prefix_rule(
|
|
56
|
+
pattern = ["git", ["update-ref", "symbolic-ref", "worktree", "filter-branch"]],
|
|
57
|
+
decision = "forbidden",
|
|
58
|
+
justification = "Inspect references with git show-ref or git branch and ask the operator to mutate refs, worktrees, or repository history.",
|
|
59
|
+
match = ["git update-ref refs/heads/main HEAD", "git symbolic-ref HEAD refs/heads/main", "git worktree remove ../other", "git filter-branch -- --all"],
|
|
60
|
+
not_match = ["git show-ref", "git branch --show-current"],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
prefix_rule(
|
|
64
|
+
pattern = ["git", "gc", ["--prune", "--prune=now"]],
|
|
65
|
+
decision = "forbidden",
|
|
66
|
+
justification = "Inspect repository size with git count-objects -v and leave object pruning to the operator.",
|
|
67
|
+
match = ["git gc --prune", "git gc --prune=now"],
|
|
68
|
+
not_match = ["git gc", "git count-objects -v"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
prefix_rule(
|
|
72
|
+
pattern = ["git", "reflog", "expire"],
|
|
73
|
+
decision = "forbidden",
|
|
74
|
+
justification = "Inspect recovery history with git reflog show and leave reflog expiration to the operator.",
|
|
75
|
+
match = ["git reflog expire --expire=now --all"],
|
|
76
|
+
not_match = ["git reflog", "git reflog show"],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
prefix_rule(
|
|
80
|
+
pattern = ["git", "push"],
|
|
81
|
+
decision = "forbidden",
|
|
82
|
+
justification = "Prepare local commits and show the operator the verified git push command; only the operator publishes changes.",
|
|
83
|
+
match = ["git push", "git push origin main", "git push --force"],
|
|
84
|
+
not_match = ["git status", "git remote -v"],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
prefix_rule(
|
|
88
|
+
pattern = ["git", "remote", ["set-url", "remove", "rm", "set-head"]],
|
|
89
|
+
decision = "forbidden",
|
|
90
|
+
justification = "Inspect remotes with git remote -v and ask the operator to change remote configuration.",
|
|
91
|
+
match = ["git remote set-url origin example.invalid/repo", "git remote remove origin", "git remote rm origin", "git remote set-head origin main"],
|
|
92
|
+
not_match = ["git remote", "git remote -v", "git remote get-url origin"],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
prefix_rule(
|
|
96
|
+
pattern = ["git", "config", ["--global", "--system"]],
|
|
97
|
+
decision = "forbidden",
|
|
98
|
+
justification = "Read configuration with git config --get <key>; ask the operator to change global or system configuration.",
|
|
99
|
+
match = ["git config --global user.name Agent", "git config --system core.autocrlf false"],
|
|
100
|
+
not_match = ["git config --get user.name", "git config user.name"],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
prefix_rule(
|
|
104
|
+
pattern = ["git", "commit", "--amend"],
|
|
105
|
+
decision = "forbidden",
|
|
106
|
+
justification = "Create a new commit when authorized and leave rewriting the previous commit to the operator.",
|
|
107
|
+
match = ["git commit --amend", "git commit --amend --no-edit"],
|
|
108
|
+
not_match = ["git commit -m msg", "git status"],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
prefix_rule(
|
|
112
|
+
pattern = ["git", "clean", ["-f", "-fd", "-df", "-fdx", "-xfd"]],
|
|
113
|
+
decision = "forbidden",
|
|
114
|
+
justification = "Preview untracked cleanup with git clean -n and remove only explicitly reviewed paths without forced bulk cleanup.",
|
|
115
|
+
match = ["git clean -f", "git clean -fd", "git clean -df", "git clean -fdx", "git clean -xfd"],
|
|
116
|
+
not_match = ["git clean -n", "git status --short"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
prefix_rule(
|
|
120
|
+
pattern = ["rm", ["-rf", "-fr", "-Rf", "-fR", "-rF", "-Fr", "-RF", "-FR"]],
|
|
121
|
+
decision = "forbidden",
|
|
122
|
+
justification = "Remove specific reviewed files without combined recursive-force flags, or ask the operator for bulk deletion.",
|
|
123
|
+
match = ["rm -rf build", "rm -fr /tmp/example", "rm -RF cache"],
|
|
124
|
+
not_match = ["rm -f stale.txt", "rm notes.md"],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
prefix_rule(
|
|
128
|
+
pattern = ["rm", ["-r", "-R", "--recursive"], ["-f", "--force"]],
|
|
129
|
+
decision = "forbidden",
|
|
130
|
+
justification = "Remove specific reviewed files without recursive forced deletion, or ask the operator for bulk deletion.",
|
|
131
|
+
match = ["rm -r -f build", "rm --recursive --force cache"],
|
|
132
|
+
not_match = ["rm -r empty-directory", "rm -f stale.txt"],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
prefix_rule(
|
|
136
|
+
pattern = ["rm", ["-f", "--force"], ["-r", "-R", "--recursive"]],
|
|
137
|
+
decision = "forbidden",
|
|
138
|
+
justification = "Remove specific reviewed files without recursive forced deletion, or ask the operator for bulk deletion.",
|
|
139
|
+
match = ["rm -f -r build", "rm --force --recursive cache"],
|
|
140
|
+
not_match = ["rm -f stale.txt", "rm -r empty-directory"],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
prefix_rule(
|
|
144
|
+
pattern = ["rm", ".git"],
|
|
145
|
+
decision = "forbidden",
|
|
146
|
+
justification = "Inspect repository metadata with git status or git rev-parse and leave removal of the repository database to the operator.",
|
|
147
|
+
match = ["rm .git"],
|
|
148
|
+
not_match = ["rm .gitignore", "git status"],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
prefix_rule(
|
|
152
|
+
pattern = ["rm", ["-r", "-R", "--recursive"], ".git"],
|
|
153
|
+
decision = "forbidden",
|
|
154
|
+
justification = "Inspect repository metadata with git status or git rev-parse and leave removal of the repository database to the operator.",
|
|
155
|
+
match = ["rm -r .git", "rm --recursive .git"],
|
|
156
|
+
not_match = ["rm -r build", "rm .gitignore"],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
prefix_rule(
|
|
160
|
+
pattern = ["mv", ".git"],
|
|
161
|
+
decision = "forbidden",
|
|
162
|
+
justification = "Inspect the repository location with git rev-parse --git-dir and ask the operator to move repository metadata.",
|
|
163
|
+
match = ["mv .git .git-old"],
|
|
164
|
+
not_match = ["mv notes.txt archive.txt", "git rev-parse --git-dir"],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
prefix_rule(
|
|
168
|
+
pattern = ["git", "restore"],
|
|
169
|
+
decision = "prompt",
|
|
170
|
+
justification = "Every form of restore rewinds tracked state: leave the tree as it is and report what you changed instead.",
|
|
171
|
+
match = ["git restore file.txt", "git restore --worktree file.txt", "git restore --staged file.txt"],
|
|
172
|
+
not_match = ["git status", "git show HEAD:file.txt"],
|
|
173
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runs one delegated Codex job end to end:
|
|
4
|
+
* node run-codex.mjs --agent <codex-scout|codex-build|codex-review> [options] < task.txt
|
|
5
|
+
*
|
|
6
|
+
* Options:
|
|
7
|
+
* --agent codex-scout | codex-build | codex-review (required)
|
|
8
|
+
* --repo repository path (default: current directory)
|
|
9
|
+
* --slug short run name used in the run folder (default: agent suffix)
|
|
10
|
+
* --effort reasoning effort, passed to Codex as given (default: medium)
|
|
11
|
+
* --verify verification command, codex-build only (e.g. "npm test")
|
|
12
|
+
* --scope comma-separated globs the run may edit, codex-build only (required there)
|
|
13
|
+
* --mode uncommitted | base:<branch> | commit:<sha>, codex-review only
|
|
14
|
+
* --continue acknowledge that this repo+slug already has runs (required if it does)
|
|
15
|
+
*
|
|
16
|
+
* The operator's task text arrives on stdin, verbatim.
|
|
17
|
+
*
|
|
18
|
+
* Why this is a script and not a list of steps inside an agent prompt: a prompt can be
|
|
19
|
+
* silently not followed, and twice it wasn't — one dispatcher reviewed the diff itself
|
|
20
|
+
* after Codex disappointed it, another announced a background run that never existed.
|
|
21
|
+
* Here the caller still blocks until a verdict exists on disk, the status is computed from
|
|
22
|
+
* artifacts by write-meta.mjs, and the reply is printed by code. "Started in the
|
|
23
|
+
* background, will report later" and "I analyzed it myself instead" are unreachable.
|
|
24
|
+
*
|
|
25
|
+
* This file is two programs. A plain call is the LAUNCHER: it does every preparation and
|
|
26
|
+
* every refusal that costs no quota, then spawns the WORKER — this same file called as
|
|
27
|
+
* `--worker <runDir>`, detached, with no stdio — prints `RUN=<folder>` immediately and waits
|
|
28
|
+
* for the worker's reply.txt, which it prints verbatim. The worker runs Codex, snapshots the
|
|
29
|
+
* tree and writes the verdict.
|
|
30
|
+
*
|
|
31
|
+
* The split exists because the caller dies long before a 20-25 minute run does. Run
|
|
32
|
+
* `2026-07-31_114736_*` has no raw.log at all while 11+ files were written into the tree: the
|
|
33
|
+
* dispatcher's Bash call hit its two-minute timeout, node was killed, spawnSync's buffered
|
|
34
|
+
* output died with it — and Codex kept going, unwatched and unrecorded. Killing that orphan
|
|
35
|
+
* is not the fix either: its edits were the work, and later runs finished on top of them. So
|
|
36
|
+
* the process the caller can kill is now the launcher, and the worker it leaves behind closes
|
|
37
|
+
* the run with artifacts whether anyone is still listening or not.
|
|
38
|
+
*
|
|
39
|
+
* The work itself lives in runner/, one concern per module and the imports pointing one way:
|
|
40
|
+
* run-context.mjs holds the run in progress and answers for it, run-env.mjs decides what
|
|
41
|
+
* environment a run gets, args.mjs reads and refuses the command line, schemas.mjs and
|
|
42
|
+
* prompts.mjs are what each agent is asked for and told, git-state.mjs reads the repository,
|
|
43
|
+
* codex-cmd.mjs invokes the Codex CLI, and launcher.mjs / worker.mjs are the two halves
|
|
44
|
+
* above. This file is the command line and the fork between them; importers name it and
|
|
45
|
+
* nothing below it.
|
|
46
|
+
*/
|
|
47
|
+
import fs from 'node:fs';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
import { fileURLToPath } from 'node:url';
|
|
50
|
+
import { writeFailure } from './write-meta.mjs';
|
|
51
|
+
import { setWorkerDir, getRun, emitReply } from './runner/run-context.mjs';
|
|
52
|
+
import { launcher } from './runner/launcher.mjs';
|
|
53
|
+
import { worker } from './runner/worker.mjs';
|
|
54
|
+
|
|
55
|
+
export { parseArgs } from './runner/args.mjs';
|
|
56
|
+
export { runsPrefixInside, worktreeSnapshot } from './runner/git-state.mjs';
|
|
57
|
+
|
|
58
|
+
// `--worker <runDir>` and nothing else: the worker takes its whole order from worker.json in
|
|
59
|
+
// that folder, so a half-parsed command line cannot make the two halves disagree about which
|
|
60
|
+
// run they are working on.
|
|
61
|
+
const WORKER_DIR = process.argv[2] === '--worker' ? process.argv[3] : null;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Only a direct call is a run. Imported — which is how the tests reach parseArgs(),
|
|
65
|
+
* runsPrefixInside() and worktreeSnapshot() — this file must do nothing at all: no argument
|
|
66
|
+
* parsing, no stdin, no spawn, not even a global crash handler that would swallow someone
|
|
67
|
+
* else's exception. Same guard run-config.mjs and write-meta.mjs already use.
|
|
68
|
+
*/
|
|
69
|
+
const invokedDirectly =
|
|
70
|
+
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
71
|
+
|
|
72
|
+
setWorkerDir(WORKER_DIR);
|
|
73
|
+
|
|
74
|
+
// A crash of the runner itself still has to read as a FAIL, and that FAIL still has to
|
|
75
|
+
// be backed by meta.json — otherwise there would be a second path by which a reply
|
|
76
|
+
// exists without an artifact behind it, which is the whole failure mode this design
|
|
77
|
+
// removes. Before the run folder exists there is nowhere to write, so the reply says so.
|
|
78
|
+
//
|
|
79
|
+
// One handler, here, for both halves: it has to catch a crash whichever branch below is
|
|
80
|
+
// running, and a second registration inside launcher.mjs or worker.mjs would be a second
|
|
81
|
+
// answer to the same question.
|
|
82
|
+
if (invokedDirectly) {
|
|
83
|
+
process.on('uncaughtException', (err) => {
|
|
84
|
+
const { dir: currentRun, agent: currentAgent } = getRun();
|
|
85
|
+
if (currentRun) {
|
|
86
|
+
try {
|
|
87
|
+
fs.appendFileSync(path.join(currentRun, 'raw.log'), `\nrun-codex crash: ${err.stack}\n`);
|
|
88
|
+
const { reply } = writeFailure(currentRun, currentAgent, `Codex runner crashed: ${err.message}`, [
|
|
89
|
+
`Log: ${path.join(currentRun, 'raw.log')}`,
|
|
90
|
+
]);
|
|
91
|
+
emitReply(reply);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
} catch {
|
|
94
|
+
// Falls through to the bare reply below: the disk itself is not cooperating.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
emitReply(
|
|
98
|
+
[
|
|
99
|
+
`FAIL — Codex runner crashed before creating the run folder: ${String(err.message).replace(/\s+/g, ' ').slice(0, 150)}`,
|
|
100
|
+
'No artifacts; nothing to inspect',
|
|
101
|
+
].join('\n'),
|
|
102
|
+
);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (invokedDirectly) {
|
|
108
|
+
if (WORKER_DIR) {
|
|
109
|
+
worker(WORKER_DIR);
|
|
110
|
+
} else {
|
|
111
|
+
launcher();
|
|
112
|
+
}
|
|
113
|
+
}
|