@lyupro/codex-bridge 0.5.2 → 0.5.4
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 +491 -336
- package/README.md +152 -152
- package/bin/codex-bridge.mjs +188 -174
- package/cli/doctor-format.mjs +23 -0
- package/cli/doctor-hooks.mjs +114 -0
- package/cli/doctor-installation.mjs +111 -0
- package/cli/doctor-runs.mjs +55 -0
- package/cli/doctor.mjs +35 -277
- package/cli/host-contract.mjs +135 -0
- package/cli/hosts.mjs +4 -3
- package/cli/install.mjs +31 -8
- package/cli/probe-contract.mjs +174 -0
- package/cli/settings-merge.mjs +55 -10
- package/package.json +49 -49
- package/scripts/run-tests.mjs +34 -2
- package/scripts/suite-count.mjs +65 -0
- package/src/agents/codex-build.md +265 -265
- package/src/agents/codex-review.md +219 -219
- package/src/agents/codex-scout.md +232 -232
- package/src/home/hooks/shell-write-intent.mjs +120 -0
- package/src/home/hooks/worktree-lock.mjs +109 -77
- package/src/home/hooks/worktree-witness.mjs +113 -0
- package/src/home/lib/brand-home.mjs +39 -0
- package/src/home/lib/heartbeat.mjs +4 -1
- package/src/home/lib/hook-definitions.mjs +10 -1
- package/src/home/lib/meta/reply.mjs +27 -0
- package/src/home/lib/run-config.mjs +18 -6
- package/src/home/lib/runner/codex-args.mjs +154 -0
- package/src/home/lib/runner/codex-cmd.mjs +6 -130
- package/src/home/lib/runner/launcher.mjs +18 -30
- package/src/home/lib/runner/sandbox-flags.mjs +27 -0
- package/src/home/lib/runner/worker-order.mjs +56 -0
- package/src/home/lib/write-meta.mjs +11 -3
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Invoking the `codex` CLI: starting it, stopping its whole tree, telling whether it is there at
|
|
3
|
+
* all, and keeping its JSONL stdout in events.jsonl while stderr stays separate.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `review` subcommand.
|
|
5
|
+
* Which flags a run gets is the neighbouring codex-args.mjs: this file starts the process and
|
|
6
|
+
* stops it, and knows nothing about what the arguments mean. The two were one module until it
|
|
7
|
+
* outgrew the size limit, and the seam is the honest one — "how we run Codex" against "what we
|
|
8
|
+
* ask it to run".
|
|
10
9
|
*/
|
|
11
10
|
import fs from 'node:fs';
|
|
12
11
|
import path from 'node:path';
|
|
@@ -14,7 +13,6 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
14
13
|
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../heartbeat.mjs';
|
|
15
14
|
import { writeFailure } from '../write-meta.mjs';
|
|
16
15
|
import { MAX_LOG } from './git-state.mjs';
|
|
17
|
-
import { CLEAN_ENV, RUN_ENV } from './run-env.mjs';
|
|
18
16
|
|
|
19
17
|
// Thirty seconds, not a fraction of one: after `exit` the pipe still holds whatever Codex wrote
|
|
20
18
|
// last, and that tail carries `item.completed` — the result the verdict is read from. A quarter
|
|
@@ -268,125 +266,3 @@ export function requireCodex(runDir, agent) {
|
|
|
268
266
|
process.exit(1);
|
|
269
267
|
}
|
|
270
268
|
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Which model runs a mode, and how deep it reasons.
|
|
274
|
-
*
|
|
275
|
-
* Three sources, in this order: what the dispatcher asked for this one task, the mode's
|
|
276
|
-
* configured profile, and finally a depth to fall back on. The order is the whole point —
|
|
277
|
-
* pinning a model without its depth leaves every run at the fallback, which is how a mode
|
|
278
|
-
* configured for maximum reasoning would quietly keep working at the shallowest setting.
|
|
279
|
-
*/
|
|
280
|
-
const FALLBACK_EFFORT = 'medium';
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* A run is one worker, not a team it recruits for itself.
|
|
284
|
-
*
|
|
285
|
-
* Codex can spawn subagents when a prompt asks it to, and their edits land in the same
|
|
286
|
-
* worktree — so they arrive in the snapshot as the run's own work, outside the scope check
|
|
287
|
-
* that grades it, on quota nobody budgeted. The prompts say not to; this is why they cannot.
|
|
288
|
-
*/
|
|
289
|
-
const NO_SUBAGENTS = ['-c', 'agents.enabled=false'];
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Which configured mode an agent is. One table, because the launcher needs the same answer to
|
|
293
|
-
* pick a run's time budget: a second copy of this mapping is a second place to forget a mode.
|
|
294
|
-
*/
|
|
295
|
-
export const runMode = (agent) =>
|
|
296
|
-
({
|
|
297
|
-
'codex-scout': 'scout',
|
|
298
|
-
'codex-build': 'build',
|
|
299
|
-
'codex-review': 'review',
|
|
300
|
-
})[agent];
|
|
301
|
-
|
|
302
|
-
function runProfile(opts) {
|
|
303
|
-
const mode = runMode(opts.agent);
|
|
304
|
-
// opts.models is how tests state a configuration; a real run reads the one loaded for it.
|
|
305
|
-
const configured = (opts.models || RUN_ENV?.models || {})[mode] || {};
|
|
306
|
-
return {
|
|
307
|
-
model: configured.model || '',
|
|
308
|
-
effort: opts.effort || configured.effort || FALLBACK_EFFORT,
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
export function codexArgs(opts, runDir, isGitRepo) {
|
|
313
|
-
const schema = path.join(runDir, 'schema.json');
|
|
314
|
-
const profile = runProfile(opts);
|
|
315
|
-
const effort = `model_reasoning_effort=${profile.effort}`;
|
|
316
|
-
const modelArgs = profile.model ? ['-m', profile.model] : [];
|
|
317
|
-
if (opts.agent === 'codex-scout') {
|
|
318
|
-
return [
|
|
319
|
-
'exec',
|
|
320
|
-
'--json',
|
|
321
|
-
...CLEAN_ENV,
|
|
322
|
-
'--ignore-user-config',
|
|
323
|
-
'-c',
|
|
324
|
-
effort,
|
|
325
|
-
...NO_SUBAGENTS,
|
|
326
|
-
...modelArgs,
|
|
327
|
-
'--sandbox',
|
|
328
|
-
'read-only',
|
|
329
|
-
'--skip-git-repo-check',
|
|
330
|
-
'-C',
|
|
331
|
-
opts.repo,
|
|
332
|
-
'--output-schema',
|
|
333
|
-
schema,
|
|
334
|
-
'-o',
|
|
335
|
-
path.join(runDir, 'result.json'),
|
|
336
|
-
'-',
|
|
337
|
-
];
|
|
338
|
-
}
|
|
339
|
-
if (opts.agent === 'codex-build') {
|
|
340
|
-
// --ignore-user-config is deliberately absent: with it Codex forces read-only and
|
|
341
|
-
// every edit is rejected ("writing is blocked by read-only sandbox").
|
|
342
|
-
return [
|
|
343
|
-
'exec',
|
|
344
|
-
'--json',
|
|
345
|
-
...CLEAN_ENV,
|
|
346
|
-
'-c',
|
|
347
|
-
effort,
|
|
348
|
-
...NO_SUBAGENTS,
|
|
349
|
-
...modelArgs,
|
|
350
|
-
'--sandbox',
|
|
351
|
-
'workspace-write',
|
|
352
|
-
'-C',
|
|
353
|
-
opts.repo,
|
|
354
|
-
...(isGitRepo ? [] : ['--skip-git-repo-check']),
|
|
355
|
-
'--output-schema',
|
|
356
|
-
schema,
|
|
357
|
-
'-o',
|
|
358
|
-
path.join(runDir, 'result.json'),
|
|
359
|
-
'-',
|
|
360
|
-
];
|
|
361
|
-
}
|
|
362
|
-
// codex-review deliberately does NOT use the `review` subcommand. Two of its traits
|
|
363
|
-
// break the contract this design rests on: it rejects a scope flag together with a
|
|
364
|
-
// prompt ("the argument '--uncommitted' cannot be used with '[PROMPT]'"), so task.md
|
|
365
|
-
// was silently discarded and the review rules never arrived; and it ignores
|
|
366
|
-
// --output-schema, writing prose into -o, so the old JSON parsing of review.json could
|
|
367
|
-
// only ever fail — which is what pushed a dispatcher into reviewing the diff itself.
|
|
368
|
-
//
|
|
369
|
-
// Plain `codex exec` honours the schema (proven by codex-scout), so the review comes
|
|
370
|
-
// back as machine-checkable JSON. Scope lives in the prompt as a file list plus the
|
|
371
|
-
// exact diff command computed by reviewScope(). Read-only through
|
|
372
|
-
// --ignore-user-config, same as scout: review never needs write access.
|
|
373
|
-
return [
|
|
374
|
-
'exec',
|
|
375
|
-
'--json',
|
|
376
|
-
...CLEAN_ENV,
|
|
377
|
-
'--ignore-user-config',
|
|
378
|
-
'-c',
|
|
379
|
-
effort,
|
|
380
|
-
...NO_SUBAGENTS,
|
|
381
|
-
...modelArgs,
|
|
382
|
-
'--sandbox',
|
|
383
|
-
'read-only',
|
|
384
|
-
'-C',
|
|
385
|
-
opts.repo,
|
|
386
|
-
'--output-schema',
|
|
387
|
-
schema,
|
|
388
|
-
'-o',
|
|
389
|
-
path.join(runDir, 'review.json'),
|
|
390
|
-
'-',
|
|
391
|
-
];
|
|
392
|
-
}
|
|
@@ -2,15 +2,9 @@
|
|
|
2
2
|
* The half the caller can kill: every preparation and every refusal that costs no quota,
|
|
3
3
|
* then the spawn of the worker and the immediate return; a repeated call waits for the reply.
|
|
4
4
|
*
|
|
5
|
-
* The order it leaves for the worker is worker.json in the run folder — after the split
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* and `args`;
|
|
9
|
-
* worker.mjs reads `repo`, `agent`, `args`, `is_git_repo` and `budget_minutes`. The extra fields
|
|
10
|
-
* are deliberate: `slug`, `order_id`, `launcher_pid` and `scope_new` are what a run folder read
|
|
11
|
-
* back months later needs in order to explain itself — which order it belonged to and which new
|
|
12
|
-
* paths it declared included. Nothing may be dropped from this shape without changing worker.mjs
|
|
13
|
-
* and saying so out loud.
|
|
5
|
+
* The order it leaves for the worker is worker.json in the run folder — after the split that file
|
|
6
|
+
* is the only connection between the two halves. Its shape and the reason for every field live in
|
|
7
|
+
* worker-order.mjs, which writes it.
|
|
14
8
|
*/
|
|
15
9
|
import fs from 'node:fs';
|
|
16
10
|
import path from 'node:path';
|
|
@@ -37,7 +31,9 @@ import { continuationRefusal } from './continuation.mjs';
|
|
|
37
31
|
import { SCHEMAS } from './schemas.mjs';
|
|
38
32
|
import { INSTRUCTIONS } from './prompts.mjs';
|
|
39
33
|
import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
|
|
40
|
-
import { codexArgs,
|
|
34
|
+
import { codexArgs, runMode, runProfile } from './codex-args.mjs';
|
|
35
|
+
import { writeWorkerOrder } from './worker-order.mjs';
|
|
36
|
+
import { requireCodex, unsafeForCmd } from './codex-cmd.mjs';
|
|
41
37
|
import { runsRoot } from './runs-root.mjs';
|
|
42
38
|
import { resolveProjectRunsDir } from './project-dir.mjs';
|
|
43
39
|
import { cleanupRetention } from '../retention.mjs';
|
|
@@ -344,26 +340,18 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
344
340
|
console.log(reply);
|
|
345
341
|
return 1;
|
|
346
342
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
budget_minutes: RUN_ENV?.budgets?.[runMode(opts.agent)],
|
|
360
|
-
scope_new: opts.scopeNewPatterns,
|
|
361
|
-
args: codexArgv,
|
|
362
|
-
},
|
|
363
|
-
null,
|
|
364
|
-
2,
|
|
365
|
-
)}\n`,
|
|
366
|
-
);
|
|
343
|
+
writeWorkerOrder(runDir, {
|
|
344
|
+
agent: opts.agent,
|
|
345
|
+
slug: opts.slug,
|
|
346
|
+
orderId: opts.orderId,
|
|
347
|
+
repo: repoRoot,
|
|
348
|
+
isGitRepo,
|
|
349
|
+
launcherPid: process.pid,
|
|
350
|
+
budgetMinutes: RUN_ENV?.budgets?.[runMode(opts.agent)],
|
|
351
|
+
scopeNew: opts.scopeNewPatterns,
|
|
352
|
+
profile: runProfile({ ...opts, repo: repoRoot }),
|
|
353
|
+
args: codexArgv,
|
|
354
|
+
});
|
|
367
355
|
|
|
368
356
|
// detached + unref + no stdio: the worker leaves the caller's process group, so a Ctrl+C
|
|
369
357
|
// or a timeout kill aimed at the dispatcher's shell does not reach it, and it holds no
|
|
@@ -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,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order the launcher leaves for the worker: worker.json in the run folder.
|
|
3
|
+
*
|
|
4
|
+
* After the launcher/worker split this file is the only connection between the two halves, and the
|
|
5
|
+
* worker never re-reads the CLI or the configuration — a run that consulted config.json twice could
|
|
6
|
+
* end up honouring two different limits. It carries `agent`, `slug`, `order_id`, `repo`,
|
|
7
|
+
* `is_git_repo`, `launcher_pid`, `budget_minutes`, `scope_new`, `profile` and `args`; worker.mjs
|
|
8
|
+
* reads `repo`, `agent`, `args`, `is_git_repo` and `budget_minutes`. The rest is deliberate: those
|
|
9
|
+
* fields are what a run folder read back months later needs in order to explain itself — which
|
|
10
|
+
* order it belonged to, which new paths it declared included, and which worker was actually
|
|
11
|
+
* ordered. Nothing may be dropped from this shape without changing worker.mjs and saying so out
|
|
12
|
+
* loud; `docs/worker-contract.md` is the document that says it.
|
|
13
|
+
*
|
|
14
|
+
* It lives beside launcher.mjs rather than inside it because writing this contract is its own job,
|
|
15
|
+
* and the launcher had grown past the size limit while the contract was buried in the middle of it.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
export function workerOrder({
|
|
21
|
+
agent,
|
|
22
|
+
slug,
|
|
23
|
+
orderId,
|
|
24
|
+
repo,
|
|
25
|
+
isGitRepo,
|
|
26
|
+
launcherPid,
|
|
27
|
+
budgetMinutes,
|
|
28
|
+
scopeNew,
|
|
29
|
+
profile,
|
|
30
|
+
args,
|
|
31
|
+
}) {
|
|
32
|
+
return {
|
|
33
|
+
agent,
|
|
34
|
+
slug,
|
|
35
|
+
order_id: orderId,
|
|
36
|
+
repo,
|
|
37
|
+
is_git_repo: isGitRepo,
|
|
38
|
+
launcher_pid: launcherPid,
|
|
39
|
+
// The mode's wall-clock budget, resolved by the launcher and never re-read by the worker.
|
|
40
|
+
budget_minutes: budgetMinutes,
|
|
41
|
+
scope_new: scopeNew,
|
|
42
|
+
// Which worker was ordered, and where each half of that answer came from. Recorded separately
|
|
43
|
+
// from args because an absent flag says nothing: a run without `-m` looked exactly like a run
|
|
44
|
+
// whose configured model had never been read, which is how a pinned profile went unnoticed for
|
|
45
|
+
// three releases (2026-08-26).
|
|
46
|
+
profile,
|
|
47
|
+
args,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function writeWorkerOrder(runDir, fields) {
|
|
52
|
+
fs.writeFileSync(
|
|
53
|
+
path.join(runDir, 'worker.json'),
|
|
54
|
+
`${JSON.stringify(workerOrder(fields), null, 2)}\n`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -28,7 +28,7 @@ import { splitRunChanges } from './meta/environment.mjs';
|
|
|
28
28
|
import { readEvents } from './meta/events.mjs';
|
|
29
29
|
import { writeStatus } from './meta/run-state.mjs';
|
|
30
30
|
import { resolveStatus } from './meta/verdict.mjs';
|
|
31
|
-
import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
|
|
31
|
+
import { AGENTS, failReply, limitReply, withProfileRow } from './meta/reply.mjs';
|
|
32
32
|
|
|
33
33
|
function readRunnerVersion() {
|
|
34
34
|
try {
|
|
@@ -122,7 +122,12 @@ export function collect(runDir, agent, exitCode) {
|
|
|
122
122
|
tokens: events.tokens,
|
|
123
123
|
tokens_reported: events.tokens !== null,
|
|
124
124
|
usage: events.usage,
|
|
125
|
-
|
|
125
|
+
// The ordered profile, straight from worker.json; null for runs made before it was recorded.
|
|
126
|
+
// `model` stays for readers that already know it, and now answers for a run that pinned
|
|
127
|
+
// nothing instead of going quiet: an absent -m used to be indistinguishable from a model
|
|
128
|
+
// the operator had configured and never received (2026-08-26).
|
|
129
|
+
profile: worker?.profile ?? null,
|
|
130
|
+
model: worker?.profile?.model || argValue('-m'),
|
|
126
131
|
sandbox: argValue('--sandbox'),
|
|
127
132
|
// Null for runs made before env.json existed: absence is not the same as "both off".
|
|
128
133
|
env: readJson(path.join(runDir, 'env.json')),
|
|
@@ -143,7 +148,10 @@ export function collect(runDir, agent, exitCode) {
|
|
|
143
148
|
file: (name) => path.join(runDir, name),
|
|
144
149
|
};
|
|
145
150
|
const reply =
|
|
146
|
-
|
|
151
|
+
withProfileRow(
|
|
152
|
+
status === 'OK' ? cfg.reply(ctx) : status === 'LIMIT' ? limitReply(ctx, meta) : failReply(ctx, meta),
|
|
153
|
+
meta,
|
|
154
|
+
);
|
|
147
155
|
|
|
148
156
|
return { meta, reply: reply.join('\n') };
|
|
149
157
|
}
|