@lyupro/codex-bridge 0.6.12 → 0.6.15

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +10 -8
  3. package/cli/command-registry.mjs +59 -18
  4. package/cli/doctor-installation.mjs +132 -111
  5. package/cli/doctor-load-witness.mjs +75 -0
  6. package/cli/doctor-runs.mjs +41 -6
  7. package/cli/doctor-sandbox.mjs +64 -0
  8. package/cli/doctor.mjs +26 -8
  9. package/cli/guidance.mjs +42 -0
  10. package/cli/host-inspection.mjs +1 -0
  11. package/cli/hosts.mjs +4 -3
  12. package/cli/install-record.mjs +4 -2
  13. package/cli/manifest.mjs +6 -3
  14. package/cli/read.mjs +1 -2
  15. package/cli/removal-outcomes.mjs +5 -2
  16. package/cli/removal-plan.mjs +4 -1
  17. package/cli/remove-layout.mjs +4 -4
  18. package/cli/run-lookup.mjs +14 -2
  19. package/cli/runs-inventory.mjs +10 -2
  20. package/cli/runs-move-copy.mjs +123 -0
  21. package/cli/runs-move-history.mjs +78 -0
  22. package/cli/runs-move-remove.mjs +92 -0
  23. package/cli/runs-move.mjs +96 -0
  24. package/cli/stop.mjs +1 -2
  25. package/cli/uninstall-purge.mjs +1 -0
  26. package/cli/uninstall.mjs +2 -0
  27. package/cli/update.mjs +12 -7
  28. package/package.json +4 -2
  29. package/scripts/home-write-inventory.mjs +17 -2
  30. package/scripts/isolated-roots.mjs +2 -1
  31. package/src/{agents → claude/agents}/codex-advisor.md +8 -7
  32. package/src/{agents → claude/agents}/codex-build.md +7 -6
  33. package/src/{agents → claude/agents}/codex-review.md +6 -5
  34. package/src/{agents → claude/agents}/codex-scout.md +7 -6
  35. package/src/{commands → claude/commands}/usage.md +9 -5
  36. package/src/claude/rules/core.md +29 -0
  37. package/src/{rules → codex/rules}/codex-bridge.rules +1 -1
  38. package/src/home/guidance/advisor.md +42 -0
  39. package/src/home/guidance/budget.md +37 -0
  40. package/src/home/guidance/concurrency.md +17 -0
  41. package/src/home/guidance/order.md +68 -0
  42. package/src/home/guidance/records.md +32 -0
  43. package/src/home/hooks/live-runs.mjs +2 -1
  44. package/src/home/hooks/load-witness.mjs +23 -0
  45. package/src/home/lib/claude-layout.mjs +1 -0
  46. package/src/home/lib/codex-home.mjs +8 -0
  47. package/src/home/lib/home-registry.mjs +21 -0
  48. package/src/home/lib/hook-definitions.mjs +2 -0
  49. package/src/home/lib/host-version.mjs +33 -14
  50. package/src/home/lib/load-witness.mjs +113 -0
  51. package/src/home/lib/meta/paths.mjs +6 -1
  52. package/src/home/lib/meta/reply.mjs +1 -1
  53. package/src/home/lib/runner/codex-availability.mjs +11 -2
  54. package/src/home/lib/runner/exit-codes.mjs +1 -1
  55. package/src/home/lib/runner/launcher.mjs +10 -4
  56. package/src/home/lib/runner/preflight.mjs +12 -4
  57. package/src/home/lib/runner/prompts.mjs +2 -2
  58. package/src/home/lib/runner/retired-roots.mjs +81 -0
  59. package/src/home/lib/runner/run-autocommit.mjs +109 -0
  60. package/src/home/lib/runner/runs-root.mjs +47 -7
  61. package/src/home/lib/runner/sandbox-diagnosis.mjs +180 -0
  62. package/src/home/lib/runner/sandbox-probe.mjs +29 -1
  63. package/src/home/lib/runner/task-input.mjs +30 -0
  64. package/src/home/lib/runner/worker.mjs +5 -0
  65. /package/src/{commands → claude/commands}/env.md +0 -0
  66. /package/src/{commands → claude/commands}/model.md +0 -0
@@ -0,0 +1,180 @@
1
+ /** Owns read-only sandbox log evidence and repair formatting after 2026-10-10 TradeForge orders exposed only "setup refresh had errors". */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const LOG_LIMIT = 64 * 1024;
6
+ const SETUP_LIMIT = 8 * 1024;
7
+ const ERROR_LINE = /setup error|setup refresh completed with errors|failed|error/i;
8
+ // A successful refresh logs `errors=[]`; on the 2026-10-10 log five of them pushed the real error out.
9
+ const CLEAN_REFRESH = /errors=\[\]/;
10
+ const NO_REPAIR = 'No verified repair signature; inspect this log.';
11
+
12
+ // Plan_78 A1: a repair needs all incident fragments on one fresh line, not a generic error word.
13
+ const SIGNATURES = Object.freeze([
14
+ Object.freeze({
15
+ id: 'runtime-file-locked',
16
+ observed: Object.freeze({ date: '2026-10-10', codexCli: '0.162.1' }),
17
+ predicate: (line) => ['runtime read/execute validation failed',
18
+ 'open ACL target for root-only update', 'os error 32'].every((fragment) => line.includes(fragment)),
19
+ summary: 'A file under the Codex runtimes folder is held open by another process, so sandbox setup cannot update its access rights (openai/codex #51634).',
20
+ repair: () => 'Find and close the process holding the file named in the line (on 2026-10-10, the Codex desktop app\'s computer-use helper), then rerun codex-bridge doctor; on Codex CLI 0.162.x the proven workaround is npm install -g @openai/codex@0.160.1.',
21
+ }),
22
+ Object.freeze({
23
+ id: 'deny-read-state-corrupt',
24
+ observed: Object.freeze({ date: '2026-09-16' }),
25
+ predicate: (line) => ['deny_read_acl_state.json', 'expected value at line 1 column 1']
26
+ .every((fragment) => line.includes(fragment)),
27
+ summary: 'The sandbox state file is empty or corrupt.',
28
+ repair: (codexHome) => `Back up and rename ${path.join(codexHome, '.sandbox', 'deny_read_acl_state.json')}, then rerun codex-bridge doctor; the helper recreates it.`,
29
+ }),
30
+ ]);
31
+
32
+ function logPath(codexHome, date) {
33
+ const day = [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'),
34
+ String(date.getDate()).padStart(2, '0')].join('-');
35
+ return path.join(codexHome, '.sandbox', `sandbox.${day}.log`);
36
+ }
37
+
38
+ function sandboxState(codexHome) {
39
+ try {
40
+ // Plan_78 B4a: a linked sandbox folder can redirect every evidence read outside CODEX_HOME.
41
+ return fs.lstatSync(path.join(codexHome, '.sandbox')).isDirectory() ? 'ready' : 'unknown';
42
+ } catch (error) {
43
+ return error.code === 'ENOENT' ? 'missing' : 'unknown';
44
+ }
45
+ }
46
+
47
+ function fileSize(file, folderState) {
48
+ if (folderState !== 'ready') return folderState === 'missing' ? 0 : null;
49
+ try {
50
+ const stat = fs.lstatSync(file);
51
+ return stat.isFile() ? stat.size : null;
52
+ } catch (error) {
53
+ // Only ENOENT proves absence; permission failures must not become a zero-byte baseline.
54
+ return error.code === 'ENOENT' ? 0 : null;
55
+ }
56
+ }
57
+
58
+ function readRange(file, start, limit, tail, folderState) {
59
+ if (folderState !== 'ready') return { state: folderState };
60
+ let fd;
61
+ try {
62
+ // Plan_78 B4a: opening a FIFO can block; links must not redirect sandbox evidence reads.
63
+ if (!fs.lstatSync(file).isFile()) return { state: 'unknown' };
64
+ fd = fs.openSync(file, 'r');
65
+ const stat = fs.fstatSync(fd);
66
+ if (!stat.isFile()) return { state: 'unknown' };
67
+ const offset = tail ? Math.max(start, stat.size - limit) : start;
68
+ const length = Math.min(limit, Math.max(0, stat.size - offset));
69
+ const buffer = Buffer.alloc(length);
70
+ let read = 0;
71
+ while (read < length) {
72
+ const count = fs.readSync(fd, buffer, read, length - read, offset + read);
73
+ if (count === 0) break;
74
+ read += count;
75
+ }
76
+ let text = buffer.subarray(0, read).toString('utf8');
77
+ // A capped tail can begin inside a signature; that fragment is not a complete error line.
78
+ if (tail && offset > start) {
79
+ const newline = text.indexOf('\n');
80
+ text = newline < 0 ? '' : text.slice(newline + 1);
81
+ }
82
+ return { state: 'read', text, bytes: read, size: stat.size, mtime: stat.mtimeMs };
83
+ } catch (error) {
84
+ return { state: error.code === 'ENOENT' ? 'missing' : 'unknown' };
85
+ } finally {
86
+ if (fd !== undefined) {
87
+ try { fs.closeSync(fd); } catch { /* Evidence collection must not hide the dead-probe refusal. */ }
88
+ }
89
+ }
90
+ }
91
+
92
+ function readSetupError(file, folderState) {
93
+ const result = readRange(file, 0, SETUP_LIMIT, false, folderState);
94
+ return {
95
+ state: result.state,
96
+ text: result.state === 'read' ? result.text : result.state === 'missing' ? null
97
+ : `Unknown: unable to read ${file}.`,
98
+ };
99
+ }
100
+
101
+ export function snapshotSandboxLogs({ codexHome, now = new Date() }) {
102
+ const folderState = sandboxState(codexHome);
103
+ const previous = new Date(now);
104
+ // Local calendar arithmetic survives DST; subtracting 24 hours can choose the wrong day.
105
+ previous.setDate(previous.getDate() - 1);
106
+ return {
107
+ logs: [now, previous].map((date) => {
108
+ const file = logPath(codexHome, date);
109
+ return { path: file, size: fileSize(file, folderState) };
110
+ }),
111
+ setupError: readSetupError(path.join(codexHome, '.sandbox', 'setup_error.json'), folderState),
112
+ };
113
+ }
114
+
115
+ export function diagnoseSandbox({ codexHome, before, now = new Date() }) {
116
+ const folderState = sandboxState(codexHome);
117
+ const baselines = new Map(before.logs.map((log) => [log.path, log.size]));
118
+ const paths = [...new Set([...baselines.keys(), logPath(codexHome, now)])].sort();
119
+ const sources = [];
120
+ const appended = [];
121
+ const recent = [];
122
+ const unknown = [];
123
+ let attributed = false;
124
+ for (const file of paths) {
125
+ const baseline = baselines.has(file) ? baselines.get(file) : 0;
126
+ const result = readRange(file, baseline ?? 0, LOG_LIMIT, true, folderState);
127
+ if (result.state === 'missing') continue;
128
+ sources.push(file);
129
+ if (result.state === 'unknown') {
130
+ unknown.push(`Unknown: unable to read ${file}.`);
131
+ continue;
132
+ }
133
+ if (baseline !== null && result.bytes > 0) {
134
+ attributed = true;
135
+ appended.push(result.text);
136
+ }
137
+ recent.push({ file, mtime: result.mtime });
138
+ if (baseline === null) unknown.push(`Unknown: pre-probe size unavailable for ${file}.`);
139
+ }
140
+
141
+ let text = appended.join('\n');
142
+ if (!attributed && recent.length) {
143
+ const newest = recent.sort((a, b) => b.mtime - a.mtime || b.file.localeCompare(a.file))[0];
144
+ const fallback = readRange(newest.file, 0, LOG_LIMIT, true, folderState);
145
+ if (fallback.state === 'read') text = fallback.text;
146
+ else unknown.push(`Unknown: unable to read ${newest.file}.`);
147
+ }
148
+ const errors = text.split(/\r?\n/)
149
+ .filter((line) => ERROR_LINE.test(line) && !CLEAN_REFRESH.test(line)).slice(-5);
150
+ const match = attributed && [...errors].reverse()
151
+ .map((line) => SIGNATURES.find((signature) => signature.predicate(line))).find(Boolean);
152
+ const setupPath = path.join(codexHome, '.sandbox', 'setup_error.json');
153
+ const setup = readSetupError(setupPath, folderState);
154
+ if (setup.state !== 'missing') sources.push(setupPath);
155
+ return {
156
+ codexHome, sources, attributed,
157
+ lines: [...errors, ...unknown].map((line) => line.slice(0, 400)),
158
+ setupError: {
159
+ text: setup.text,
160
+ changed: setup.state === 'unknown' || before.setupError.state === 'unknown'
161
+ ? null : setup.text !== before.setupError.text,
162
+ },
163
+ signature: match ? { id: match.id, summary: match.summary, repair: match.repair(codexHome) } : null,
164
+ };
165
+ }
166
+
167
+ export function formatSandboxDiagnosis(diagnosis) {
168
+ return [
169
+ ...diagnosis.sources.map((source) => `Sandbox diagnosis source: ${source}`),
170
+ diagnosis.attributed ? 'Sandbox log lines attributed to this probe:'
171
+ : 'Recent sandbox log lines only; not attributed to this probe:',
172
+ ...diagnosis.lines.map((line) => `> ${line}`),
173
+ ...(diagnosis.setupError.text === null ? [] : [
174
+ `setup_error.json changed since snapshot: ${diagnosis.setupError.changed === null
175
+ ? 'unknown' : diagnosis.setupError.changed ? 'yes' : 'no'}`,
176
+ ...diagnosis.setupError.text.split(/\r?\n/).map((line) => `> ${line}`),
177
+ ]),
178
+ ...(diagnosis.signature ? [diagnosis.signature.summary, diagnosis.signature.repair] : [NO_REPAIR]),
179
+ ];
180
+ }
@@ -3,6 +3,8 @@ import { performance } from 'node:perf_hooks';
3
3
  import { sandboxModeFor } from './codex-args.mjs';
4
4
  import { codexSpawnSpec, spawnCaptured } from './codex-cmd.mjs';
5
5
  import { platformSandboxArgs } from './sandbox-flags.mjs';
6
+ import { resolveCodexHome } from '../codex-home.mjs';
7
+ import { snapshotSandboxLogs, diagnoseSandbox, formatSandboxDiagnosis } from './sandbox-diagnosis.mjs';
6
8
 
7
9
  export const SANDBOX_PROBE_MARKER = 'codex-bridge-sandbox-ok';
8
10
  // Plan_57 D12/D19: a platform is judged only after both outcomes were seen live. Linux joined on
@@ -37,13 +39,37 @@ function inconclusiveReason(result, form) {
37
39
 
38
40
  export async function probeSandbox({
39
41
  agent, repo, platform = process.platform, run = spawnCaptured, timeoutMs = 30_000,
42
+ codexHome = resolveCodexHome(), clock = () => new Date(),
40
43
  }) {
41
44
  if (!PROBED_PLATFORMS.has(platform)) return { outcome: 'skipped' };
42
45
  const sandbox = sandboxModeFor(agent);
43
46
  if (typeof repo !== 'string' || !repo) throw new Error('sandbox probe requires a repository path');
44
47
  const echo = echoArgs(platform);
45
48
  const attempts = [];
46
- const finish = (outcome, reason) => ({ outcome, reason, attempts });
49
+ // The 2026-10-10 TradeForge refusals showed only "setup refresh had errors"; capture the
50
+ // baseline before any attempt so old log errors cannot prescribe a repair for this probe.
51
+ let before = null;
52
+ try {
53
+ before = snapshotSandboxLogs({ codexHome, now: clock() });
54
+ } catch {
55
+ // Same rule as the diagnosis below: a log that cannot be read never stops the probe.
56
+ }
57
+ const finish = (outcome, reason) => {
58
+ const result = { outcome, reason, attempts };
59
+ if (outcome === 'dead') {
60
+ if (before === null) {
61
+ result.diagnosis = null;
62
+ return result;
63
+ }
64
+ // D2: logs, like stderr, are operator evidence, never input to the verdict.
65
+ try {
66
+ result.diagnosis = diagnoseSandbox({ codexHome, before, now: clock() });
67
+ } catch {
68
+ result.diagnosis = null;
69
+ }
70
+ }
71
+ return result;
72
+ };
47
73
  const attempt = async (form, args) => {
48
74
  const spec = codexSpawnSpec(args, platform);
49
75
  const started = performance.now();
@@ -104,6 +130,8 @@ export function sandboxRefusal(result, platform = process.platform) {
104
130
  ...(platform === 'linux' ? [LINUX_REPAIR] : []),
105
131
  `Operator check (run from the repository directory): ${control}`,
106
132
  ...stderr,
133
+ ...(result.diagnosis && typeof result.diagnosis === 'object'
134
+ ? [formatSandboxDiagnosis(result.diagnosis).join('\n')] : []),
107
135
  'The run folder was not created; quota was not spent.',
108
136
  ].join('\n\n');
109
137
  }
@@ -4,11 +4,37 @@
4
4
  *
5
5
  * Plan_63 D1/D8: transport arguments carry no order fields; file and stdin share this boundary.
6
6
  */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
7
9
  import { die, readTaskDocument } from './args.mjs';
8
10
  import { questionKindRefusal } from './question-kind.mjs';
9
11
  import { orderFromHeader, orderInputName, renderOrderProblems } from '../order-schema.mjs';
10
12
  import { orderOptions } from './order-options.mjs';
11
13
 
14
+ /**
15
+ * OW-061: outside git the launcher keeps `repository:` as given and the project folder takes its
16
+ * last segment, so a dispatcher that folded its own instructions into the value left run folders
17
+ * named `Obsidian. Run the scope phase. Return ≤5 lines and the run folder path.` and
18
+ * `tradeforge.loc (do not cd into codex-runs).` — names with a trailing dot that Windows cannot
19
+ * open through an ordinary path. Only an existing absolute directory whose segments Windows keeps
20
+ * verbatim may name the repository.
21
+ */
22
+ export function repositoryRefusal(value) {
23
+ if (!path.isAbsolute(value)) {
24
+ return `must be an absolute path to the repository; got ${JSON.stringify(value)}.`;
25
+ }
26
+ const altered = value.split(/[\\/]/).find((segment) => /[. ]$/.test(segment) && segment !== '.' && segment !== '..');
27
+ if (altered !== undefined) {
28
+ return `has a path segment ending in a dot or a space (${JSON.stringify(altered)}), which Windows strips; ` +
29
+ `got ${JSON.stringify(value)}.`;
30
+ }
31
+ let isDirectory = false;
32
+ try {
33
+ isDirectory = fs.statSync(value).isDirectory();
34
+ } catch {}
35
+ return isDirectory ? null : `must name an existing directory; ${JSON.stringify(value)} is not one.`;
36
+ }
37
+
12
38
  export function settleTaskInput(opts, { cwd = process.cwd() } = {}) {
13
39
  const { task, header, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
14
40
  const { order, problems } = orderFromHeader(opts.agent, header);
@@ -19,6 +45,10 @@ export function settleTaskInput(opts, { cwd = process.cwd() } = {}) {
19
45
  if (normalized.problems.length) {
20
46
  die(normalized.problems.map(({ label, reason }) => `${orderInputName(label)} ${reason}`).join('\n'));
21
47
  }
48
+ const repositoryReason = order.has('repository') ? repositoryRefusal(order.get('repository')) : null;
49
+ if (repositoryReason) {
50
+ die(`${orderInputName('repository')} ${repositoryReason}\nThe run folder was not created; quota was not spent.`);
51
+ }
22
52
  Object.assign(opts, normalized.options);
23
53
  opts.continue = Boolean(header.grant);
24
54
  opts.questions = fileQuestions.length ? fileQuestions : undefined;
@@ -21,6 +21,7 @@ import { runCodex } from './codex-cmd.mjs';
21
21
  import { admitWorker } from './worker-admission.mjs';
22
22
  import { orderClaimHolderText } from './order-claim.mjs';
23
23
  import { EXIT } from './exit-codes.mjs';
24
+ import { commitRunRecord } from './run-autocommit.mjs';
24
25
 
25
26
  /**
26
27
  * Runs the order in runDir and answers into reply.txt, never to a console — nothing here
@@ -39,6 +40,8 @@ export async function worker(runDir) {
39
40
  [`Claim held by ${orderClaimHolderText(admission.holder)}`, 'Codex was not started; quota was not spent'],
40
41
  );
41
42
  emitReply(reply);
43
+ // Plan_77 B6: publish the verdict before Git can wait on a lock or repository hook.
44
+ commitRunRecord({ runDir });
42
45
  }
43
46
  process.exit(EXIT.FAIL);
44
47
  }
@@ -78,5 +81,7 @@ export async function worker(runDir) {
78
81
  // seeing a reply is proof the verdict behind it is already on disk.
79
82
  const { meta, reply } = collect(runDir, cfg.agent, run.exit);
80
83
  emitReply(reply);
84
+ // Plan_77 B6: the launcher must receive its reply before any autocommit delay.
85
+ commitRunRecord({ runDir });
81
86
  process.exit(exitCodeFor(meta.status));
82
87
  }
File without changes
File without changes