@lyupro/codex-bridge 0.5.1 → 0.5.3
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 +420 -252
- package/README.md +21 -4
- package/bin/codex-bridge.mjs +4 -0
- package/cli/doctor.mjs +399 -293
- 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 +2 -2
- package/cli/run-lookup.mjs +3 -3
- package/cli/runs-inventory.mjs +3 -3
- package/cli/settings-merge.mjs +56 -11
- 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/scripts/run-tests.mjs +30 -1
- package/scripts/suite-count.mjs +65 -0
- 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/home/hooks/shell-write-intent.mjs +120 -0
- package/src/{hooks → home/hooks}/stop-guard.mjs +3 -3
- package/src/home/hooks/worktree-lock.mjs +109 -0
- package/src/home/hooks/worktree-witness.mjs +113 -0
- package/src/home/lib/dispatcher-order.mjs +36 -0
- package/src/{heartbeat.mjs → home/lib/heartbeat.mjs} +4 -1
- package/src/{hook-definitions.mjs → home/lib/hook-definitions.mjs} +11 -2
- 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}/codex-cmd.mjs +398 -392
- 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/sandbox-flags.mjs +27 -0
- 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/hooks/worktree-lock.mjs +0 -77
- /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/{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}/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
|
@@ -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,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform flags a delegated run needs before its sandbox can start a process at all.
|
|
3
|
+
*
|
|
4
|
+
* codex-cli 0.149.0 on Windows runs sandboxed commands under a restricted token, and which token
|
|
5
|
+
* it builds is `windows.sandbox` — `elevated` or `unelevated`. The unelevated form refuses
|
|
6
|
+
* CreateProcess outright: every command the model tries comes back as
|
|
7
|
+
* `Rejected("… rejected: blocked by policy")` before the process exists. That setting lives in the
|
|
8
|
+
* operator's own config.toml, which `--ignore-user-config` deliberately drops, so on 2026-08-23
|
|
9
|
+
* every codex-scout and codex-review run died without reading a single file while codex-build
|
|
10
|
+
* kept working — not because writing is exempt, but because it is the one agent that does not pass
|
|
11
|
+
* `--ignore-user-config` and so inherited the operator's key by luck. Four probes on the installed
|
|
12
|
+
* CLI, differing by this flag alone, separate the two: with the key a run prints its command output
|
|
13
|
+
* and exits 0, without it both read-only AND workspace-write are refused.
|
|
14
|
+
*
|
|
15
|
+
* Naming the flag here keeps the isolation intact — a run still ignores the operator's config —
|
|
16
|
+
* and stops the package from depending on a line in a personal file it does not own.
|
|
17
|
+
*
|
|
18
|
+
* The value carries no quotes on purpose: unsafeForCmd() refuses any argument containing `"`,
|
|
19
|
+
* because the whole command line goes through cmd.exe. Codex parses `elevated` as a bare TOML
|
|
20
|
+
* literal, and rejects an unknown variant loudly, so the unquoted form is checked, not assumed.
|
|
21
|
+
*/
|
|
22
|
+
export const WINDOWS_SANDBOX = ['-c', 'windows.sandbox=elevated'];
|
|
23
|
+
|
|
24
|
+
/** Empty off Windows: no other platform has this setting, and passing it would be an error. */
|
|
25
|
+
export function platformSandboxArgs(platform = process.platform) {
|
|
26
|
+
return platform === 'win32' ? [...WINDOWS_SANDBOX] : [];
|
|
27
|
+
}
|
|
@@ -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
|
-
}));
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* PreToolUse lock for file-writing tools in the Claude Code host.
|
|
4
|
-
*
|
|
5
|
-
* A live codex-build runner snapshots its repository before and after editing. Letting the
|
|
6
|
-
* orchestrator write during that window folds unrelated work into the runner's result, exactly
|
|
7
|
-
* as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
|
|
8
|
-
* build run's repository; every malformed or unreadable input passes so diagnostics never break
|
|
9
|
-
* unrelated host work.
|
|
10
|
-
*/
|
|
11
|
-
import fs from 'node:fs';
|
|
12
|
-
import path from 'node:path';
|
|
13
|
-
import { formatSilence, heartbeatAge } from '../heartbeat.mjs';
|
|
14
|
-
import { WRITE_TOOLS } from '../hook-definitions.mjs';
|
|
15
|
-
import { runsRoot } from '../runner/runs-root.mjs';
|
|
16
|
-
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
17
|
-
|
|
18
|
-
const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
|
|
19
|
-
const pass = () => process.exit(0);
|
|
20
|
-
|
|
21
|
-
let input;
|
|
22
|
-
try {
|
|
23
|
-
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
24
|
-
} catch {
|
|
25
|
-
pass();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
29
|
-
if (!WRITE_TOOL_NAMES.has(input.tool_name)) pass();
|
|
30
|
-
|
|
31
|
-
const toolInput = input.tool_input;
|
|
32
|
-
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
33
|
-
const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
|
|
34
|
-
const rawPath = toolInput[pathField];
|
|
35
|
-
if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
|
|
36
|
-
|
|
37
|
-
let targetPath;
|
|
38
|
-
try {
|
|
39
|
-
if (path.isAbsolute(rawPath)) targetPath = rawPath;
|
|
40
|
-
else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
|
|
41
|
-
else pass();
|
|
42
|
-
} catch {
|
|
43
|
-
pass();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const target = normalizePath(targetPath);
|
|
47
|
-
if (!target) pass();
|
|
48
|
-
|
|
49
|
-
let liveRuns;
|
|
50
|
-
try {
|
|
51
|
-
liveRuns = allLiveRuns(runsRoot());
|
|
52
|
-
} catch {
|
|
53
|
-
pass();
|
|
54
|
-
}
|
|
55
|
-
const owner = liveRuns.find(({ status }) => {
|
|
56
|
-
if (status.agent !== 'codex-build') return false;
|
|
57
|
-
const repository = normalizePath(status.repo);
|
|
58
|
-
return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
|
|
59
|
-
});
|
|
60
|
-
if (!owner) pass();
|
|
61
|
-
|
|
62
|
-
const { status, dir } = owner;
|
|
63
|
-
process.stdout.write(JSON.stringify({
|
|
64
|
-
hookSpecificOutput: {
|
|
65
|
-
hookEventName: 'PreToolUse',
|
|
66
|
-
permissionDecision: 'deny',
|
|
67
|
-
permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${targetPath} is inside repository `
|
|
68
|
-
+ `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
|
|
69
|
-
// "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
|
|
70
|
-
// run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
|
|
71
|
-
// first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
|
|
72
|
-
// healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
|
|
73
|
-
// work they meant to keep.
|
|
74
|
-
+ `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
|
|
75
|
-
+ `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
|
|
76
|
-
},
|
|
77
|
-
}));
|
|
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
|