@lyupro/codex-bridge 0.5.0 → 0.5.2
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 +104 -1
- package/README.md +62 -20
- package/bin/codex-bridge.mjs +4 -0
- package/cli/doctor.mjs +118 -19
- package/cli/frontmatter.mjs +137 -0
- package/cli/hook.mjs +2 -2
- package/cli/install-record.mjs +2 -2
- package/cli/install.mjs +14 -2
- package/cli/manifest.mjs +23 -22
- package/cli/permissions.mjs +2 -2
- package/cli/projects.mjs +3 -3
- package/cli/prune-plan.mjs +2 -2
- package/cli/prune.mjs +1 -1
- package/cli/read.mjs +2 -2
- package/cli/rules-owners.mjs +26 -4
- package/cli/run-lookup.mjs +3 -3
- package/cli/runs-inventory.mjs +3 -3
- package/cli/settings-merge.mjs +1 -1
- package/cli/stop.mjs +5 -5
- package/cli/unlock.mjs +7 -7
- package/package.json +2 -2
- package/scripts/check-file-size.mjs +1 -1
- package/src/agents/codex-build.md +265 -231
- package/src/agents/codex-review.md +219 -184
- package/src/agents/codex-scout.md +232 -200
- package/src/{hooks → home/hooks}/guard-tries.mjs +1 -1
- package/src/{hooks → home/hooks}/live-runs.mjs +43 -3
- package/src/home/hooks/order-gate.mjs +135 -0
- package/src/{hooks → home/hooks}/prune-guard.mjs +2 -2
- package/src/{hooks → home/hooks}/reply-guard.mjs +117 -97
- package/src/home/hooks/reply-parser.mjs +27 -0
- package/src/home/hooks/reply-verdicts.mjs +174 -0
- package/src/{hooks → home/hooks}/stop-guard.mjs +3 -3
- package/src/{hooks → home/hooks}/worktree-lock.mjs +3 -3
- package/src/home/lib/dispatcher-order.mjs +36 -0
- package/src/{hook-definitions.mjs → home/lib/hook-definitions.mjs} +1 -1
- package/src/home/lib/host-refusal.mjs +20 -0
- package/src/{json-file.mjs → home/lib/json-file.mjs} +4 -4
- package/src/{meta → home/lib/meta}/paths.mjs +20 -1
- package/src/{meta → home/lib/meta}/reply.mjs +1 -1
- package/src/{meta → home/lib/meta}/verdict.mjs +1 -1
- package/src/{no-self-execution.mjs → home/lib/no-self-execution.mjs} +7 -2
- package/src/{required-inputs.mjs → home/lib/required-inputs.mjs} +56 -5
- package/src/{retention.mjs → home/lib/retention.mjs} +1 -1
- package/src/{run-codex.mjs → home/lib/run-codex.mjs} +39 -11
- package/src/{run-config.mjs → home/lib/run-config.mjs} +2 -2
- package/src/{runner → home/lib/runner}/args.mjs +86 -10
- package/src/{runner → home/lib/runner}/attach.mjs +58 -9
- package/src/{runner → home/lib/runner}/launcher.mjs +33 -21
- package/src/home/lib/runner/order-owner.mjs +28 -0
- package/src/{runner → home/lib/runner}/run-env.mjs +1 -1
- package/src/home/lib/runner/task-file.mjs +92 -0
- package/src/home/lib/runner/task-input.mjs +26 -0
- package/src/{runner → home/lib/runner}/worker.mjs +1 -1
- package/src/home/lib/shell-unsafe.mjs +26 -0
- package/src/{write-meta.mjs → home/lib/write-meta.mjs} +12 -1
- package/src/hooks/order-gate.mjs +0 -73
- /package/src/{run-config.json → home/config.json} +0 -0
- /package/src/{conventions.md → home/conventions.md} +0 -0
- /package/src/{cli-names.mjs → home/lib/cli-names.mjs} +0 -0
- /package/src/{heartbeat.mjs → home/lib/heartbeat.mjs} +0 -0
- /package/src/{meta → home/lib/meta}/chain.mjs +0 -0
- /package/src/{meta → home/lib/meta}/deadline.mjs +0 -0
- /package/src/{meta → home/lib/meta}/environment.mjs +0 -0
- /package/src/{meta → home/lib/meta}/events.mjs +0 -0
- /package/src/{meta → home/lib/meta}/outcome.mjs +0 -0
- /package/src/{meta → home/lib/meta}/pre-start.mjs +0 -0
- /package/src/{meta → home/lib/meta}/reason.mjs +0 -0
- /package/src/{meta → home/lib/meta}/run-state.mjs +0 -0
- /package/src/{meta → home/lib/meta}/startup.mjs +0 -0
- /package/src/{meta → home/lib/meta}/transport.mjs +0 -0
- /package/src/{process-identity.mjs → home/lib/process-identity.mjs} +0 -0
- /package/src/{runner → home/lib/runner}/codex-cmd.mjs +0 -0
- /package/src/{runner → home/lib/runner}/continuation.mjs +0 -0
- /package/src/{runner → home/lib/runner}/conventions.mjs +0 -0
- /package/src/{runner → home/lib/runner}/git-state.mjs +0 -0
- /package/src/{runner → home/lib/runner}/project-dir.mjs +0 -0
- /package/src/{runner → home/lib/runner}/prompts.mjs +0 -0
- /package/src/{runner → home/lib/runner}/run-context.mjs +0 -0
- /package/src/{runner → home/lib/runner}/runs-root.mjs +0 -0
- /package/src/{runner → home/lib/runner}/schemas.mjs +0 -0
- /package/src/{runner → home/lib/runner}/scope-check.mjs +0 -0
- /package/src/{stop-contract.mjs → home/lib/stop-contract.mjs} +0 -0
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
5
5
|
import { IDENTITY_DEAD, IDENTITY_FOREIGN, processAlive, processIdentity } from '../process-identity.mjs';
|
|
6
6
|
import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
|
|
7
|
+
import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
|
|
7
8
|
|
|
8
9
|
// The attach call is the only process that waits for a worker it did not spawn. Keeping that
|
|
9
10
|
// wait here leaves the launcher free to return before a caller's time ceiling kills it.
|
|
@@ -11,12 +12,38 @@ const POLL_MS = 500;
|
|
|
11
12
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
13
|
|
|
13
14
|
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident showed that ATTACH alone
|
|
14
|
-
// can be mistaken for this pass
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// can be mistaken for this pass. Distinguish a saved answer from a live run whose verdict is
|
|
16
|
+
// still pending so provenance is truthful while preserving the guarantee that no work restarted.
|
|
17
|
+
function announceSavedReply(runDir, orderId, startedAt) {
|
|
18
|
+
console.log(`ATTACH=${runDir} order-id=${orderId} started=${startedAt}`);
|
|
17
19
|
console.log(`This is the answer of the previous run started at ${startedAt}; no new work was started.`);
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
function announceLiveAttach(runDir, orderId, startedAt) {
|
|
23
|
+
console.log(`ATTACH=${runDir} order-id=${orderId} started=${startedAt}`);
|
|
24
|
+
console.log(
|
|
25
|
+
`Attached to the run already in progress since ${startedAt}; no new work was started, and this invocation is waiting for its verdict.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function elapsedSince(startedAt) {
|
|
30
|
+
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
|
|
31
|
+
if (!Number.isFinite(elapsedSeconds)) return 'unknown';
|
|
32
|
+
const hours = Math.floor(elapsedSeconds / 3600);
|
|
33
|
+
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
|
|
34
|
+
const seconds = elapsedSeconds % 60;
|
|
35
|
+
if (hours) return `${hours}h ${minutes}m`;
|
|
36
|
+
if (minutes) return `${minutes}m ${seconds}s`;
|
|
37
|
+
return `${seconds}s`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function announcePending(runDir, orderId, startedAt) {
|
|
41
|
+
console.log(`ATTACH=${runDir} order-id=${orderId} started=${startedAt}`);
|
|
42
|
+
console.log(
|
|
43
|
+
`Run is still in progress; elapsed ${elapsedSince(startedAt)}. Repeat the same command without --no-wait later to wait for its verdict.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
20
47
|
export const readJsonFile = (file) => {
|
|
21
48
|
try {
|
|
22
49
|
return readJsonFileSync(file);
|
|
@@ -77,12 +104,24 @@ export async function waitForReply(runDir, workerPid, status = {}) {
|
|
|
77
104
|
* reason. `--continue` is the one case that must not attach: it is the orchestrator saying it read
|
|
78
105
|
* the previous reply and wants another pass.
|
|
79
106
|
*/
|
|
80
|
-
export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue } = {}) {
|
|
107
|
+
export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue, noWait } = {}) {
|
|
81
108
|
if (isContinue) return null;
|
|
82
109
|
const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
|
|
83
|
-
const
|
|
110
|
+
const runRecords = runs
|
|
84
111
|
.map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
|
|
85
|
-
.filter(({ status }) => status
|
|
112
|
+
.filter(({ status }) => status);
|
|
113
|
+
const sameOrder = runsForOrder(runRecords, orderId);
|
|
114
|
+
|
|
115
|
+
const owner = conflictingOrderOwner(runRecords, orderId, taskHash);
|
|
116
|
+
if (owner) {
|
|
117
|
+
const ownerDir = path.join(runsRoot, owner.run);
|
|
118
|
+
console.log(
|
|
119
|
+
`Order id collision: ${JSON.stringify(String(orderId ?? ''))} already belongs to run folder ${ownerDir} ` +
|
|
120
|
+
`(slug ${owner.status.slug}, started_at ${owner.status.started_at}) with a different task. ` +
|
|
121
|
+
'Pass a new --order-id, or pass --continue if this really is another pass of the same order.',
|
|
122
|
+
);
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
86
125
|
|
|
87
126
|
// The run an order is currently about is its newest one, and the chain arrives oldest first.
|
|
88
127
|
// Reading it from the end is the whole point: `--continue` adds a second run under the same
|
|
@@ -93,7 +132,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
93
132
|
const entry = sameOrder[i];
|
|
94
133
|
const dir = path.join(runsRoot, entry.run);
|
|
95
134
|
if (fs.existsSync(path.join(dir, 'reply.txt'))) {
|
|
96
|
-
|
|
135
|
+
announceSavedReply(dir, orderId, entry.status.started_at);
|
|
97
136
|
console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
|
|
98
137
|
return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
|
|
99
138
|
}
|
|
@@ -102,10 +141,20 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
102
141
|
break;
|
|
103
142
|
}
|
|
104
143
|
}
|
|
105
|
-
if (!candidate)
|
|
144
|
+
if (!candidate) {
|
|
145
|
+
if (!noWait) return null;
|
|
146
|
+
console.log(`No run exists for order id ${JSON.stringify(String(orderId ?? ''))}; --no-wait never starts a new run.`);
|
|
147
|
+
return 4;
|
|
148
|
+
}
|
|
106
149
|
|
|
107
150
|
const runDir = path.join(runsRoot, candidate.run);
|
|
108
|
-
|
|
151
|
+
// On 2026-08-13 a killed waiting call became an invented FAIL over a run already finished OK.
|
|
152
|
+
// A distinct call outcome lets the dispatcher inspect disk without mistaking pending for failure.
|
|
153
|
+
if (noWait) {
|
|
154
|
+
announcePending(runDir, orderId, candidate.status.started_at);
|
|
155
|
+
return 4;
|
|
156
|
+
}
|
|
157
|
+
announceLiveAttach(runDir, orderId, candidate.status.started_at);
|
|
109
158
|
const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
|
|
110
159
|
if (replyText !== null) {
|
|
111
160
|
console.log(replyText);
|
|
@@ -31,6 +31,7 @@ import { attach } from './attach.mjs';
|
|
|
31
31
|
import { setRun } from './run-context.mjs';
|
|
32
32
|
import { loadRunEnv, RUN_ENV } from './run-env.mjs';
|
|
33
33
|
import { parseArgs, die } from './args.mjs';
|
|
34
|
+
import { settleTaskInput } from './task-input.mjs';
|
|
34
35
|
import { parseContinuationGrant } from '../required-inputs.mjs';
|
|
35
36
|
import { continuationRefusal } from './continuation.mjs';
|
|
36
37
|
import { SCHEMAS } from './schemas.mjs';
|
|
@@ -48,7 +49,6 @@ import { validateScope } from './scope-check.mjs';
|
|
|
48
49
|
* below is the CLI entry one level up — not this module, which has no command line of its own.
|
|
49
50
|
*/
|
|
50
51
|
const RUNNER_ENTRY = fileURLToPath(new URL('../run-codex.mjs', import.meta.url));
|
|
51
|
-
|
|
52
52
|
export const questionsFromFlags = (questionTexts = []) =>
|
|
53
53
|
questionTexts.map((text, i) => ({ id: `Q${i + 1}`, text }));
|
|
54
54
|
|
|
@@ -63,7 +63,7 @@ const stamp = () => {
|
|
|
63
63
|
* otherwise overwrite each other's task.md, log and result — and a status could then be
|
|
64
64
|
* computed from another run's artifacts.
|
|
65
65
|
*/
|
|
66
|
-
function makeRunDir(base) {
|
|
66
|
+
export function makeRunDir(base) {
|
|
67
67
|
fs.mkdirSync(path.dirname(base), { recursive: true });
|
|
68
68
|
for (let n = 2, dir = base; ; n += 1) {
|
|
69
69
|
try {
|
|
@@ -76,19 +76,26 @@ function makeRunDir(base) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export const runDirPath = (root, slug, runStamp = stamp()) => {
|
|
80
|
+
// `2026-08-13_235525_2026-08-13_plan4-6g-ceiling-scope` duplicated the order date. Only the
|
|
81
|
+
// directory copy is stripped: the slug stored in status.json stays verbatim, because a run
|
|
82
|
+
// with neither order_id nor task_hash is found by its stored slug alone (meta/chain.mjs).
|
|
83
|
+
const stripped = String(slug).replace(/^\d{4}-\d{2}-\d{2}[_-]/, '');
|
|
84
|
+
// An order id that is nothing but a date would otherwise name a folder ending in `_`.
|
|
85
|
+
const directorySlug = stripped || String(slug);
|
|
86
|
+
return path.join(root, `${runStamp}_${directorySlug}`);
|
|
87
|
+
};
|
|
88
|
+
|
|
79
89
|
/**
|
|
80
90
|
* Everything a run needs before a single token of someone else's quota is spent, and every
|
|
81
91
|
* refusal that costs nothing: bad arguments, an open chain without --continue, a busy
|
|
82
92
|
* worktree, a missing Codex CLI. All of it happens here, in the process the caller is free
|
|
83
93
|
* to kill — so a killed caller can only ever interrupt a run that was already paid for.
|
|
84
94
|
*/
|
|
85
|
-
export async function launcher() {
|
|
95
|
+
export async function launcher(argv = process.argv.slice(2)) {
|
|
86
96
|
loadRunEnv();
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const taskText = fs.readFileSync(0, 'utf8').trim();
|
|
90
|
-
if (!taskText) die('task text on stdin is empty');
|
|
91
|
-
|
|
97
|
+
const opts = parseArgs(argv);
|
|
98
|
+
const taskText = settleTaskInput(opts);
|
|
92
99
|
const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
|
|
93
100
|
const isGitRepo = topLevel.status === 0;
|
|
94
101
|
const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
|
|
@@ -162,8 +169,9 @@ export async function launcher() {
|
|
|
162
169
|
orderId: opts.orderId,
|
|
163
170
|
chain,
|
|
164
171
|
isContinue: opts.continue,
|
|
172
|
+
noWait: opts.noWait,
|
|
165
173
|
});
|
|
166
|
-
if (attachedExitCode !== null)
|
|
174
|
+
if (attachedExitCode !== null) return attachedExitCode;
|
|
167
175
|
|
|
168
176
|
if (startedChain.length && !opts.continue) {
|
|
169
177
|
const last = startedChain[startedChain.length - 1];
|
|
@@ -196,7 +204,7 @@ export async function launcher() {
|
|
|
196
204
|
retention = null;
|
|
197
205
|
}
|
|
198
206
|
|
|
199
|
-
const runDir = makeRunDir(
|
|
207
|
+
const runDir = makeRunDir(runDirPath(projectRunsRoot, opts.slug));
|
|
200
208
|
setRun(runDir, opts.agent);
|
|
201
209
|
|
|
202
210
|
// Written before Codex is even probed. From here on a killed runner leaves a folder that
|
|
@@ -230,7 +238,7 @@ export async function launcher() {
|
|
|
230
238
|
|
|
231
239
|
// Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
|
|
232
240
|
// orchestrator with a folder to look into.
|
|
233
|
-
console.log(`RUN=${runDir}`);
|
|
241
|
+
console.log(`RUN=${runDir} order-id=${opts.orderId}`);
|
|
234
242
|
|
|
235
243
|
// Before requireCodex, so a blocked run costs nothing at all.
|
|
236
244
|
if (busy) {
|
|
@@ -245,7 +253,7 @@ export async function launcher() {
|
|
|
245
253
|
true,
|
|
246
254
|
);
|
|
247
255
|
console.log(reply);
|
|
248
|
-
|
|
256
|
+
return 1;
|
|
249
257
|
}
|
|
250
258
|
|
|
251
259
|
requireCodex(runDir, opts.agent);
|
|
@@ -334,7 +342,7 @@ export async function launcher() {
|
|
|
334
342
|
true,
|
|
335
343
|
);
|
|
336
344
|
console.log(reply);
|
|
337
|
-
|
|
345
|
+
return 1;
|
|
338
346
|
}
|
|
339
347
|
fs.writeFileSync(
|
|
340
348
|
path.join(runDir, 'worker.json'),
|
|
@@ -347,7 +355,7 @@ export async function launcher() {
|
|
|
347
355
|
is_git_repo: isGitRepo,
|
|
348
356
|
launcher_pid: process.pid,
|
|
349
357
|
// The mode's wall-clock budget, resolved here and never re-read by the worker: a run
|
|
350
|
-
// that consulted
|
|
358
|
+
// that consulted config.json twice could end up honouring two different limits.
|
|
351
359
|
budget_minutes: RUN_ENV?.budgets?.[runMode(opts.agent)],
|
|
352
360
|
scope_new: opts.scopeNewPatterns,
|
|
353
361
|
args: codexArgv,
|
|
@@ -366,13 +374,17 @@ export async function launcher() {
|
|
|
366
374
|
windowsHide: true,
|
|
367
375
|
cwd: repoRoot,
|
|
368
376
|
});
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
377
|
+
const started = await new Promise((resolve) => {
|
|
378
|
+
worker.once('spawn', () => resolve(true));
|
|
379
|
+
worker.once('error', (err) => {
|
|
380
|
+
const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
|
|
381
|
+
'Codex was not started; quota was not spent',
|
|
382
|
+
], true);
|
|
383
|
+
console.log(reply);
|
|
384
|
+
resolve(false);
|
|
385
|
+
});
|
|
375
386
|
});
|
|
387
|
+
if (!started) return 1;
|
|
376
388
|
worker.unref();
|
|
377
389
|
writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid, process_started_at: null });
|
|
378
390
|
|
|
@@ -382,5 +394,5 @@ export async function launcher() {
|
|
|
382
394
|
console.log(
|
|
383
395
|
'To get the verdict, repeat the identical command with the same --order-id; it will attach to this run and will not start a second run.',
|
|
384
396
|
);
|
|
385
|
-
|
|
397
|
+
return 0;
|
|
386
398
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Keeps the runner and producer hook on one definition of which task owns an order id. */
|
|
2
|
+
|
|
3
|
+
const byStartThenName = (left, right) => {
|
|
4
|
+
const leftAt = String(left.status.started_at || '');
|
|
5
|
+
const rightAt = String(right.status.started_at || '');
|
|
6
|
+
if (leftAt !== rightAt) return leftAt < rightAt ? -1 : 1;
|
|
7
|
+
if (String(left.run) === String(right.run)) return 0;
|
|
8
|
+
return String(left.run) < String(right.run) ? -1 : 1;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function runsForOrder(runs, orderId) {
|
|
12
|
+
const wantedOrderId = String(orderId ?? '');
|
|
13
|
+
if (!wantedOrderId) return [];
|
|
14
|
+
return runs
|
|
15
|
+
.filter(({ status }) => String(status?.order_id ?? '') === wantedOrderId)
|
|
16
|
+
.sort(byStartThenName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* On 2026-08-15 plan42-run3 reused plan42-run2's order id and received run2's verdict.
|
|
21
|
+
* Compare only known fingerprints so runs predating task_hash remain fail-open.
|
|
22
|
+
*/
|
|
23
|
+
export function conflictingOrderOwner(runs, orderId, taskHash) {
|
|
24
|
+
const owner = runsForOrder(runs, orderId).at(-1);
|
|
25
|
+
const ownerHash = String(owner?.status.task_hash ?? '').trim().toLowerCase();
|
|
26
|
+
const incomingHash = String(taskHash ?? '').trim().toLowerCase();
|
|
27
|
+
return ownerHash && incomingHash && ownerHash !== incomingHash ? owner : null;
|
|
28
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* fixture ran the task and cost 23k.
|
|
10
10
|
*
|
|
11
11
|
* Not hardcoded, because "never" is the wrong contract: `node run-config.mjs hooks on`
|
|
12
|
-
* turns either of them back on. A broken
|
|
12
|
+
* turns either of them back on. A broken config.json stops the run instead of quietly
|
|
13
13
|
* falling back — a typo must not decide what environment a run gets.
|
|
14
14
|
*/
|
|
15
15
|
import { readRunConfig, disableFlags } from '../run-config.mjs';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses the task document without knowing how it reached the runner.
|
|
3
|
+
*
|
|
4
|
+
* The 2026-08-15 permission-rule incident showed that model-written questions and verification
|
|
5
|
+
* commands cannot safely travel through a shell command line. Recognised sections keep that free
|
|
6
|
+
* text in the file while the headingless form preserves every existing task file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SECTION_HEADING = /^ {0,3}(#{1,6})[ \t]+(.+?)[ \t]*$/;
|
|
10
|
+
const LIST_ITEM = /^\s*(?:[-*]|\d+\.)[ \t]+(.+?)\s*$/;
|
|
11
|
+
const SECTION_NAMES = new Map([
|
|
12
|
+
['task', 'task'],
|
|
13
|
+
['questions', 'questions'],
|
|
14
|
+
['verify', 'verify'],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A heading, its level, and the section it opens when the runner recognises its text.
|
|
19
|
+
*
|
|
20
|
+
* The level is what closes a section. Treating every unrecognised heading as ordinary content
|
|
21
|
+
* made the first task file written against this format unreadable: a `## Constraints` heading
|
|
22
|
+
* after `## Verify` was swallowed into the verification command, and the document was refused
|
|
23
|
+
* for holding more than one command. A sibling or shallower heading ends the section it follows;
|
|
24
|
+
* only a deeper one belongs to it.
|
|
25
|
+
*/
|
|
26
|
+
function headingFrom(line) {
|
|
27
|
+
const match = SECTION_HEADING.exec(line);
|
|
28
|
+
if (!match) return undefined;
|
|
29
|
+
const text = match[2].replace(/[ \t]+#+[ \t]*$/, '').trim().toLowerCase();
|
|
30
|
+
return { level: match[1].length, section: SECTION_NAMES.get(text) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sectionFrom(line) {
|
|
34
|
+
return headingFrom(line)?.section;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseTaskDocument(text) {
|
|
38
|
+
const lines = String(text).split(/\r?\n/);
|
|
39
|
+
const hasSections = lines.some((line) => sectionFrom(line) !== undefined);
|
|
40
|
+
if (!hasSections) return { task: String(text).trim(), questions: [], verify: undefined };
|
|
41
|
+
|
|
42
|
+
const taskLines = [];
|
|
43
|
+
const questionLines = [];
|
|
44
|
+
const verifyLines = [];
|
|
45
|
+
let section = 'task';
|
|
46
|
+
let sectionLevel = 0;
|
|
47
|
+
let sawQuestions = false;
|
|
48
|
+
let sawVerify = false;
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
const heading = headingFrom(line);
|
|
52
|
+
if (heading?.section !== undefined) {
|
|
53
|
+
section = heading.section;
|
|
54
|
+
sectionLevel = heading.level;
|
|
55
|
+
if (section === 'questions') sawQuestions = true;
|
|
56
|
+
if (section === 'verify') sawVerify = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// An unrecognised heading at the section's own level or above ends it; the heading itself is
|
|
60
|
+
// part of the task statement from there on. A deeper heading stays inside the section.
|
|
61
|
+
if (heading && section !== 'task' && heading.level <= sectionLevel) {
|
|
62
|
+
section = 'task';
|
|
63
|
+
sectionLevel = 0;
|
|
64
|
+
}
|
|
65
|
+
if (section === 'questions') questionLines.push(line);
|
|
66
|
+
else if (section === 'verify') verifyLines.push(line);
|
|
67
|
+
else taskLines.push(line);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const questions = [];
|
|
71
|
+
for (const line of questionLines) {
|
|
72
|
+
if (!line.trim()) continue;
|
|
73
|
+
const item = LIST_ITEM.exec(line);
|
|
74
|
+
if (!item) {
|
|
75
|
+
throw new Error(`Questions section contains a non-list line: ${JSON.stringify(line.trim())}`);
|
|
76
|
+
}
|
|
77
|
+
questions.push(item[1].trim());
|
|
78
|
+
}
|
|
79
|
+
if (sawQuestions && !questions.length) throw new Error('Questions section is empty');
|
|
80
|
+
|
|
81
|
+
const verifyContent = verifyLines.filter((line) => line.trim());
|
|
82
|
+
if (sawVerify && !verifyContent.length) throw new Error('Verify section is empty');
|
|
83
|
+
if (verifyContent.length > 1) {
|
|
84
|
+
throw new Error('Verify section must contain exactly one non-empty line');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
task: taskLines.join('\n').trim(),
|
|
89
|
+
questions,
|
|
90
|
+
verify: sawVerify ? verifyContent[0].trim() : undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settles the order's task, sub-questions and verification command from the two channels that
|
|
3
|
+
* can carry them, and refuses before a run folder or a paid process can exist.
|
|
4
|
+
*
|
|
5
|
+
* Kept apart from the launcher because it is the one place that has seen both channels: argument
|
|
6
|
+
* parsing runs before the task file has been read, and the file is read without knowing which
|
|
7
|
+
* flags were passed. Deciding it in either of them alone is how a scout order carrying its
|
|
8
|
+
* questions in the file got refused for not passing --question.
|
|
9
|
+
*/
|
|
10
|
+
import { die, readTaskDocument } from './args.mjs';
|
|
11
|
+
|
|
12
|
+
export function settleTaskInput(opts) {
|
|
13
|
+
const { task, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
|
|
14
|
+
opts.questions ??= fileQuestions.length ? fileQuestions : undefined;
|
|
15
|
+
opts.verify ??= fileVerify;
|
|
16
|
+
// Still before the run folder exists and before a token of someone else's quota is touched,
|
|
17
|
+
// which is where every refusal of this kind belongs.
|
|
18
|
+
if (opts.agent === 'codex-scout' && !opts.questions?.length) {
|
|
19
|
+
die(
|
|
20
|
+
'a sub-question is required for codex-scout: put one Markdown list item per sub-question ' +
|
|
21
|
+
'under a `Questions` heading in the task file, or repeat --question for manual calls. ' +
|
|
22
|
+
'The runner will not infer questions from the task text; no quota was spent.',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return task;
|
|
26
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`. Written there: those five plus
|
|
7
7
|
* `slug`, `launcher_pid` and `scope_new`, which nothing here needs but a run folder read back
|
|
8
8
|
* months later does. The launcher must write the selected mode's budget into worker.json before
|
|
9
|
-
* spawning this half, so the worker never re-reads
|
|
9
|
+
* spawning this half, so the worker never re-reads config.json and one run cannot acquire two
|
|
10
10
|
* configurations.
|
|
11
11
|
* Neither side may change the shape alone.
|
|
12
12
|
*/
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Plan_19 had to repair drift between duplicated CLI spellings, so shell-unsafe sequences must never be listed anywhere else. */
|
|
2
|
+
|
|
3
|
+
export const SHELL_UNSAFE_SEQUENCES = Object.freeze([
|
|
4
|
+
'`',
|
|
5
|
+
'$(',
|
|
6
|
+
'${',
|
|
7
|
+
'&&',
|
|
8
|
+
'||',
|
|
9
|
+
'|',
|
|
10
|
+
';',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
/** Returns the first forbidden sequence in reading order, preferring a compound operator at a tie. */
|
|
14
|
+
export function firstShellUnsafeSequence(value) {
|
|
15
|
+
const text = String(value);
|
|
16
|
+
let first = null;
|
|
17
|
+
let firstIndex = -1;
|
|
18
|
+
for (const sequence of SHELL_UNSAFE_SEQUENCES) {
|
|
19
|
+
const index = text.indexOf(sequence);
|
|
20
|
+
if (index !== -1 && (firstIndex === -1 || index < firstIndex)) {
|
|
21
|
+
first = sequence;
|
|
22
|
+
firstIndex = index;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return first;
|
|
26
|
+
}
|
|
@@ -30,7 +30,18 @@ import { writeStatus } from './meta/run-state.mjs';
|
|
|
30
30
|
import { resolveStatus } from './meta/verdict.mjs';
|
|
31
31
|
import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
function readRunnerVersion() {
|
|
34
|
+
try {
|
|
35
|
+
return readJsonFileSync(new URL('../package.json', import.meta.url)).version;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// package.json is the one file npm requires outside the literal home image. The installed
|
|
38
|
+
// image has it beside lib/, while the repository has src/home between the module and manifest.
|
|
39
|
+
if (error.code !== 'ENOENT') throw error;
|
|
40
|
+
return readJsonFileSync(new URL('../../../package.json', import.meta.url)).version;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const RUNNER_VERSION = readRunnerVersion();
|
|
34
45
|
|
|
35
46
|
export { expandDeclared, globToRegExp, readJson } from './meta/paths.mjs';
|
|
36
47
|
export { chainBaseline, chainRuns, taskFingerprint } from './meta/chain.mjs';
|
package/src/hooks/order-gate.mjs
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* PreToolUse gate for the Codex dispatchers (codex-scout / codex-build / codex-review).
|
|
4
|
-
*
|
|
5
|
-
* The runner rejects a dispatcher immediately when order id (and scope for codex-build) is
|
|
6
|
-
* absent, but the old requirement lived only in the prompt read by the dispatcher. The caller
|
|
7
|
-
* therefore had no enforced place to provide it and codex-build died before doing work. This
|
|
8
|
-
* gate checks the producer's task text while it can still be corrected.
|
|
9
|
-
*
|
|
10
|
-
* Input is Claude Code's hook JSON on stdin. The last payload is retained for diagnostics so a
|
|
11
|
-
* future host schema change can be inspected instead of guessed at. Any uncertain shape passes
|
|
12
|
-
* silently because a diagnostic guard must never break an unrelated tool call.
|
|
13
|
-
*/
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import os from 'node:os';
|
|
16
|
-
import path from 'node:path';
|
|
17
|
-
import { diagnoseInput, missingInputs } from '../required-inputs.mjs';
|
|
18
|
-
import { SUBAGENT_TOOLS } from '../hook-definitions.mjs';
|
|
19
|
-
|
|
20
|
-
const HOME = os.homedir();
|
|
21
|
-
const LOG_DIR = path.join(HOME, '.claude', 'logs');
|
|
22
|
-
const GUARDED = new Set(['codex-scout', 'codex-build', 'codex-review']);
|
|
23
|
-
/**
|
|
24
|
-
* Both spellings of the subagent-launching tool, from the same list the installer builds its
|
|
25
|
-
* matcher from. Recognising only the name this host happens to use would make the gate silent
|
|
26
|
-
* on every other host, and a gate that is silent for an unknown reason is worse than none.
|
|
27
|
-
*/
|
|
28
|
-
const SUBAGENT_TOOL_NAMES = new Set(SUBAGENT_TOOLS);
|
|
29
|
-
|
|
30
|
-
const pass = () => process.exit(0);
|
|
31
|
-
|
|
32
|
-
let input;
|
|
33
|
-
try {
|
|
34
|
-
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
35
|
-
} catch {
|
|
36
|
-
pass();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
41
|
-
fs.writeFileSync(path.join(LOG_DIR, 'codex-order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
|
|
42
|
-
} catch {
|
|
43
|
-
// Diagnostics are a convenience, never a reason to fail the turn.
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
47
|
-
if (!SUBAGENT_TOOL_NAMES.has(input.tool_name)) pass();
|
|
48
|
-
|
|
49
|
-
const toolInput = input.tool_input;
|
|
50
|
-
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
51
|
-
if (!GUARDED.has(toolInput.subagent_type) || typeof toolInput.prompt !== 'string') pass();
|
|
52
|
-
|
|
53
|
-
const missing = missingInputs(toolInput.subagent_type, toolInput.prompt);
|
|
54
|
-
if (!missing.length) pass();
|
|
55
|
-
|
|
56
|
-
const reason = [
|
|
57
|
-
'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
|
|
58
|
-
'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
|
|
59
|
-
...missing.flatMap((entry) => {
|
|
60
|
-
const lines = [`- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`];
|
|
61
|
-
const diagnosis = diagnoseInput(toolInput.prompt, entry.label);
|
|
62
|
-
if (diagnosis) lines.push(` found \`${diagnosis.line}\`, ${diagnosis.reason}`);
|
|
63
|
-
return lines;
|
|
64
|
-
}),
|
|
65
|
-
].join('\n');
|
|
66
|
-
|
|
67
|
-
process.stdout.write(JSON.stringify({
|
|
68
|
-
hookSpecificOutput: {
|
|
69
|
-
hookEventName: 'PreToolUse',
|
|
70
|
-
permissionDecision: 'deny',
|
|
71
|
-
permissionDecisionReason: reason,
|
|
72
|
-
},
|
|
73
|
-
}));
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|