@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.
@@ -1,12 +1,11 @@
1
1
  /**
2
- * Everything about invoking the `codex` CLI: which flags a run gets, whether the CLI is
3
- * there at all, and how its JSONL stdout reaches events.jsonl while stderr stays separate.
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
- * The flag sets below are the ones the three agent files carried before, verbatim.
6
- * Sandboxes especially must not drift: codex-scout is read-only through
7
- * --ignore-user-config, codex-build needs workspace-write and must NOT get
8
- * --ignore-user-config, codex-review needs an explicit --sandbox read-only before the
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
- * that file is the only connection between the two halves. It is written here with
7
- * `agent`, `slug`, `order_id`, `repo`, `is_git_repo`, `launcher_pid`, `budget_minutes`, `scope_new`
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, requireCodex, runMode, unsafeForCmd } from './codex-cmd.mjs';
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
- fs.writeFileSync(
348
- path.join(runDir, 'worker.json'),
349
- `${JSON.stringify(
350
- {
351
- agent: opts.agent,
352
- slug: opts.slug,
353
- order_id: opts.orderId,
354
- repo: repoRoot,
355
- is_git_repo: isGitRepo,
356
- launcher_pid: process.pid,
357
- // The mode's wall-clock budget, resolved here and never re-read by the worker: a run
358
- // that consulted config.json twice could end up honouring two different limits.
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
- model: argValue('-m'),
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
- status === 'OK' ? cfg.reply(ctx) : status === 'LIMIT' ? limitReply(ctx, meta) : failReply(ctx, meta);
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
  }