@lyupro/codex-bridge 0.6.2 → 0.6.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.
@@ -15,7 +15,6 @@ import {
15
15
  writeStatus,
16
16
  markAbandoned,
17
17
  abandonedBranchDrift,
18
- activeRunDetails,
19
18
  chainRuns,
20
19
  startedRuns,
21
20
  taskFingerprint,
@@ -34,12 +33,14 @@ import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-s
34
33
  import { agentRole } from '../agents.mjs';
35
34
  import { codexArgs, runProfile } from './codex-args.mjs';
36
35
  import { writeWorkerOrder } from './worker-order.mjs';
37
- import { requireCodex, unsafeForCmd } from './codex-cmd.mjs';
36
+ import { unsafeForCmd } from './codex-cmd.mjs';
38
37
  import { runsRoot } from './runs-root.mjs';
39
38
  import { resolveProjectRunsDir } from './project-dir.mjs';
40
39
  import { cleanupRetention } from '../retention.mjs';
41
40
  import { renderConventions } from './conventions.mjs';
42
41
  import { validateScope } from './scope-check.mjs';
42
+ import { probeSandbox, sandboxRefusal } from './sandbox-probe.mjs';
43
+ import { preflightRefusal } from './preflight.mjs';
43
44
 
44
45
  /**
45
46
  * The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
@@ -86,7 +87,7 @@ export const runDirPath = (root, slug, runStamp = stamp()) => {
86
87
  /**
87
88
  * Everything a run needs before a single token of someone else's quota is spent, and every
88
89
  * refusal that costs nothing: bad arguments, an open chain without --continue, a busy
89
- * worktree, a missing Codex CLI. All of it happens here, in the process the caller is free
90
+ * worktree, a dead Codex sandbox, a missing Codex CLI. All in the process the caller is free
90
91
  * to kill — so a killed caller can only ever interrupt a run that was already paid for.
91
92
  */
92
93
  export async function launcher(argv = process.argv.slice(2)) {
@@ -187,10 +188,19 @@ export async function launcher(argv = process.argv.slice(2)) {
187
188
  );
188
189
  }
189
190
 
190
- // Asked before this run registers itself, so it cannot find itself. Two writing runs share
191
- // one worktree with no isolation: the second one's before/after snapshot picks up the
192
- // first one's edits, and an honest run gets failed for work it never did.
193
- const busy = opts.agent === 'codex-build' ? activeRunDetails(projectRunsRoot, repoRoot) : null;
191
+ // Review 2026-09-17: the slow probe widened the window between the busy check and registration.
192
+ // Probe first so the busy check sees writers that registered while it waited.
193
+ // Keep dead refusal before retention to preserve old run artifacts.
194
+ const sandboxProbe = await probeSandbox({ agent: opts.agent, repo: repoRoot });
195
+ if (sandboxProbe.outcome === 'dead') die(sandboxRefusal(sandboxProbe));
196
+
197
+ // Everything that can refuse without touching the tree is asked here, after the probe so the
198
+ // busy check sees writers that registered while it waited, and before makeRunDir below: on
199
+ // 2026-09-19 a busy refusal left its folder inside ~/.claude and the live run's witness spent
200
+ // every tool call demanding the orchestrator revert a directory the tool itself had created.
201
+ // Exit 1, not the usage code 2: the order was correct, the host or the tree was not.
202
+ const preflightError = preflightRefusal({ agent: opts.agent, projectRunsRoot, repoRoot });
203
+ if (preflightError) die(preflightError, 1);
194
204
 
195
205
  let retention = null;
196
206
  try {
@@ -204,7 +214,7 @@ export async function launcher(argv = process.argv.slice(2)) {
204
214
  const runDir = makeRunDir(runDirPath(projectRunsRoot, opts.slug));
205
215
  setRun(runDir, opts.agent);
206
216
 
207
- // Written before Codex is even probed. From here on a killed runner leaves a folder that
217
+ // Written before the worker can start. From here on a killed runner leaves a folder that
208
218
  // says what it was and whose pid to check, instead of a folder that says nothing — the run
209
219
  // itself takes 20-25 minutes, far longer than the caller's default timeout, so being killed
210
220
  // mid-run is the normal way for this to end, not the exotic one.
@@ -221,7 +231,7 @@ export async function launcher(argv = process.argv.slice(2)) {
221
231
  slug: opts.slug,
222
232
  order_id: opts.orderId,
223
233
  // Fingerprint of the order, so a later run of the same task finds this one whatever it
224
- // calls itself. Written here, before Codex is probed, like everything the chain reads.
234
+ // calls itself. Written here, before Codex starts, like everything the chain reads.
225
235
  task_hash: taskHash,
226
236
  repo: repoRoot,
227
237
  started_at: new Date().toISOString(),
@@ -231,30 +241,13 @@ export async function launcher(argv = process.argv.slice(2)) {
231
241
  // `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
232
242
  ...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
233
243
  ...(retention ? { retention } : {}),
244
+ sandbox_probe: sandboxProbe,
234
245
  });
235
246
 
236
247
  // Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
237
248
  // orchestrator with a folder to look into.
238
249
  console.log(`RUN=${runDir} order-id=${opts.orderId}`);
239
250
 
240
- // Before requireCodex, so a blocked run costs nothing at all.
241
- if (busy) {
242
- const identityNote = busy.identity === 'unverified'
243
- ? '; process identity could not be confirmed'
244
- : '';
245
- const { reply } = writeFailure(
246
- runDir,
247
- opts.agent,
248
- `run ${busy.run} is already active for this repository; two writing runs in one tree are prohibited${identityNote}`,
249
- [`Active run: ${path.join(projectRunsRoot, busy.run)}`, 'Codex was not started; quota was not spent'],
250
- true,
251
- );
252
- console.log(reply);
253
- return 1;
254
- }
255
-
256
- requireCodex(runDir, opts.agent);
257
-
258
251
  const scope = opts.agent === 'codex-review' ? reviewScope(repoRoot, opts.changeset) : null;
259
252
  if (scope) {
260
253
  fs.writeFileSync(
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Refusals that must not touch the tree, decided before a run is registered. On 2026-09-19,
3
+ * a busy-tree refusal left its own folder in the live writer's witness. This boundary knows
4
+ * only the project runs root and existing holders, never the prospective run's directory.
5
+ */
6
+ import path from 'node:path';
7
+ import { activeRunDetails } from '../write-meta.mjs';
8
+ import { codexUnavailableReason } from './codex-cmd.mjs';
9
+
10
+ export function preflightRefusal({ agent, projectRunsRoot, repoRoot }) {
11
+ // Two writing runs share one tree: the second snapshot would include the first one's edits.
12
+ const busy = agent === 'codex-build' ? activeRunDetails(projectRunsRoot, repoRoot) : null;
13
+ if (busy) {
14
+ const identityNote = busy.identity === 'unverified'
15
+ ? '; process identity could not be confirmed'
16
+ : '';
17
+ return [
18
+ `run ${busy.run} is already active for this repository; two writing runs in one tree are prohibited${identityNote}`,
19
+ `Active run: ${path.join(projectRunsRoot, busy.run)}`,
20
+ // One sentence, the same one every other pre-flight refusal ends with. The older wording
21
+ // said the identical thing twice, which is how two copies of a rule start to drift.
22
+ 'The run folder was not created; quota was not spent.',
23
+ ].join('\n');
24
+ }
25
+
26
+ const why = codexUnavailableReason();
27
+ if (why !== null) {
28
+ return [
29
+ `Codex CLI unavailable: ${why}`,
30
+ 'Operator check: codex --version (and codex login if authorization is rejected)',
31
+ 'The run folder was not created; quota was not spent.',
32
+ ].join('\n');
33
+ }
34
+ return null;
35
+ }
@@ -0,0 +1,109 @@
1
+ /** Checks whether this host's Codex sandbox can start a process before a run spends quota. */
2
+ import { performance } from 'node:perf_hooks';
3
+ import { sandboxModeFor } from './codex-args.mjs';
4
+ import { codexSpawnSpec, spawnCaptured } from './codex-cmd.mjs';
5
+ import { platformSandboxArgs } from './sandbox-flags.mjs';
6
+
7
+ export const SANDBOX_PROBE_MARKER = 'codex-bridge-sandbox-ok';
8
+ // Plan_57 D12/D19: a platform is judged only after both outcomes were seen live. Linux joined on
9
+ // 2026-09-17: dead on an Ubuntu 24.04 VPS (bwrap without a user namespace, exit 1, no marker), alive
10
+ // on the same host after the AppArmor repair. macOS has never been observed.
11
+ export const PROBED_PLATFORMS = new Set(['win32', 'linux']);
12
+
13
+ // Ubuntu 23.10+ forbids unprivileged user namespaces unless an AppArmor profile allows bwrap; that
14
+ // is the ordinary state of a fresh server, not an accident like the corrupted Windows state file.
15
+ const LINUX_REPAIR = [
16
+ 'On Ubuntu 23.10 and later this usually means AppArmor does not let bubblewrap create a user namespace.',
17
+ 'Official repair: https://learn.chatgpt.com/docs/sandboxing — the bwrap-userns-restrict AppArmor profile',
18
+ '(Ubuntu 24.04) or the bubblewrap package (25.04 and later). Do not set',
19
+ 'kernel.apparmor_restrict_unprivileged_userns=0: it lifts the restriction for every program on the host.',
20
+ ].join(' ');
21
+
22
+ const echoArgs = (platform) => platform === 'win32'
23
+ ? ['cmd', '/d', '/c', 'echo', SANDBOX_PROBE_MARKER]
24
+ : ['echo', SANDBOX_PROBE_MARKER];
25
+
26
+ function inconclusiveReason(result, form) {
27
+ if (result.error) {
28
+ return result.error.code === 'ETIMEDOUT'
29
+ ? `The ${form} sandbox probe timed out.`
30
+ : `The ${form} sandbox probe could not start or complete.`;
31
+ }
32
+ if (result.signal) return `The ${form} sandbox probe was terminated by ${result.signal}.`;
33
+ if (result.status === 2) return `Codex rejected the ${form} sandbox probe arguments.`;
34
+ if (result.status == null) return `The ${form} sandbox probe returned no exit status.`;
35
+ return null;
36
+ }
37
+
38
+ export async function probeSandbox({
39
+ agent, repo, platform = process.platform, run = spawnCaptured, timeoutMs = 30_000,
40
+ }) {
41
+ if (!PROBED_PLATFORMS.has(platform)) return { outcome: 'skipped' };
42
+ const sandbox = sandboxModeFor(agent);
43
+ if (typeof repo !== 'string' || !repo) throw new Error('sandbox probe requires a repository path');
44
+ const echo = echoArgs(platform);
45
+ const attempts = [];
46
+ const finish = (outcome, reason) => ({ outcome, reason, attempts });
47
+ const attempt = async (form, args) => {
48
+ const spec = codexSpawnSpec(args, platform);
49
+ const started = performance.now();
50
+ const result = await run(spec.command, spec.args, {
51
+ ...spec.options, cwd: repo, encoding: 'utf8', timeout: timeoutMs,
52
+ });
53
+ const marker = String(result.stdout ?? '').includes(SANDBOX_PROBE_MARKER);
54
+ attempts.push({
55
+ form, status: result.status ?? null, marker,
56
+ ms: Math.round(performance.now() - started),
57
+ // D2: stderr is evidence for the operator, never input to the verdict.
58
+ stderrTail: String(result.stderr ?? '').slice(-300),
59
+ });
60
+ return { ...result, marker };
61
+ };
62
+
63
+ // On 2026-09-16 a corrupt deny_read_acl_state.json made every paid run unable to start tools.
64
+ // On 2026-09-17, -C required --permission-profile instead: cwd must be a process option.
65
+ // D16: no single failure exit code proves a dead sandbox; both forms and a live CLI must agree.
66
+ const flagged = await attempt('flagged', [
67
+ 'sandbox', ...platformSandboxArgs(platform), '-c', `sandbox_mode=${sandbox}`, '--', ...echo,
68
+ ]);
69
+ const flaggedReason = inconclusiveReason(flagged, 'flagged');
70
+ if (flaggedReason) return finish('inconclusive', flaggedReason);
71
+ if (flagged.status === 0 && flagged.marker) {
72
+ return finish('alive', 'The Codex sandbox started a process.');
73
+ }
74
+ // D16: the marker proves a process started, so a nonzero exit cannot prove a dead sandbox.
75
+ if (flagged.marker) {
76
+ return finish('inconclusive', `The flagged sandbox probe printed the marker but exited ${flagged.status}.`);
77
+ }
78
+
79
+ const control = await attempt('control', ['sandbox', '--', ...echo]);
80
+ const controlReason = inconclusiveReason(control, 'control');
81
+ if (controlReason) return finish('inconclusive', controlReason);
82
+ if (control.marker) {
83
+ return finish('inconclusive', 'This version of Codex no longer accepts the package sandbox flags.');
84
+ }
85
+
86
+ const version = await attempt('version', ['--version']);
87
+ if (version.error || version.signal || version.status !== 0) {
88
+ // codexUnavailableReason() owns the refusal for an unavailable CLI, in the same pre-flight pass.
89
+ return finish('inconclusive', 'Codex CLI is unavailable.');
90
+ }
91
+ return finish('dead', 'The Codex sandbox on this host cannot start a process.');
92
+ }
93
+
94
+ export function sandboxRefusal(result, platform = process.platform) {
95
+ const control = ['codex', 'sandbox', '--', ...echoArgs(platform)].join(' ');
96
+ const stderr = result.attempts
97
+ .filter(({ form }) => form === 'flagged' || form === 'control')
98
+ .map(({ form, stderrTail }) =>
99
+ `${form} stderr:\n> ${(stderrTail || '(empty)').replace(/\r\n?/g, '\n').replace(/\n/g, '\n> ')}`);
100
+ // Only a dead result is refused, and its reason already is the headline; printing both said it twice.
101
+ return [
102
+ result.reason,
103
+ 'Repair the host sandbox; do not rewrite or retry the order.',
104
+ ...(platform === 'linux' ? [LINUX_REPAIR] : []),
105
+ `Operator check (run from the repository directory): ${control}`,
106
+ ...stderr,
107
+ 'The run folder was not created; quota was not spent.',
108
+ ].join('\n\n');
109
+ }
@@ -24,7 +24,7 @@ function absolutePattern(pattern) {
24
24
  );
25
25
  }
26
26
 
27
- function structuralRefusal(pattern) {
27
+ function structuralRefusal(repoRoot, pattern) {
28
28
  if (absolutePattern(pattern)) {
29
29
  return {
30
30
  reason: 'is an absolute or drive-qualified path',
@@ -43,6 +43,26 @@ function structuralRefusal(pattern) {
43
43
  action: 'remove the .. segment and keep the path relative to the repository root',
44
44
  };
45
45
  }
46
+ // Plan_58 D7: 2026-09-19_191402_ports-infra spent a run on a bare scope-new directory,
47
+ // which the verdict could only match as a file. Refuse that intent before spending a run.
48
+ if (!pattern || /[*?]/.test(pattern)) return null;
49
+ const reason = 'names a directory rather than a file';
50
+ const action = `write ${pattern.replace(/\/$/, '')}/** for everything inside it, or name the file itself`;
51
+ if (pattern.endsWith('/')) return { reason, action };
52
+
53
+ let entry;
54
+ try {
55
+ entry = fs.statSync(path.join(repoRoot, pattern));
56
+ } catch {
57
+ // An unreadable or vanished path is treated as missing, so only its spelling is checked.
58
+ }
59
+ if (entry?.isDirectory()) return { reason, action };
60
+ if (!entry && !pattern.split('/').at(-1).includes('.')) {
61
+ return {
62
+ reason,
63
+ action: `write ${pattern}/** if it is a directory, or declare the new file by its own name with an extension; a new extensionless file is declared through its directory`,
64
+ };
65
+ }
46
66
  return null;
47
67
  }
48
68
 
@@ -133,7 +153,7 @@ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
133
153
  const allPatterns = [...declared, ...newPaths];
134
154
 
135
155
  for (const pattern of allPatterns) {
136
- const refusal = structuralRefusal(pattern);
156
+ const refusal = structuralRefusal(repoRoot, pattern);
137
157
  if (refusal) return { pattern, ...refusal };
138
158
  if (!pattern) return noMatchRefusal(pattern);
139
159
  }
@@ -29,6 +29,7 @@ import { readEvents } from './meta/events.mjs';
29
29
  import { writeStatus } from './meta/run-state.mjs';
30
30
  import { resolveStatus } from './meta/verdict.mjs';
31
31
  import { AGENTS, failReply, limitReply, withProfileRow } from './meta/reply.mjs';
32
+ import { withLaunchRows } from './meta/launch-rows.mjs';
32
33
 
33
34
  function readRunnerVersion() {
34
35
  try {
@@ -149,7 +150,10 @@ export function collect(runDir, agent, exitCode) {
149
150
  };
150
151
  const reply =
151
152
  withProfileRow(
152
- status === 'OK' ? cfg.reply(ctx) : status === 'LIMIT' ? limitReply(ctx, meta) : failReply(ctx, meta),
153
+ withLaunchRows(
154
+ status === 'OK' ? cfg.reply(ctx) : status === 'LIMIT' ? limitReply(ctx, meta) : failReply(ctx, meta),
155
+ runDir,
156
+ ),
153
157
  meta,
154
158
  );
155
159