@codedology/code-forge 0.1.0
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 +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker start/stop and signer rows (plan §2.3, §4.8, §8.6; block B13b).
|
|
3
|
+
*
|
|
4
|
+
* The probe makes a throwaway run in a throwaway git repo (the project's `.code-forge.yml` copied
|
|
5
|
+
* in): B8 `startRun` generates the run key, B8 `signer` signs a row and verifies it (and refuses
|
|
6
|
+
* a byte-flipped copy), B11 `launchWorker` starts `code-forge worker` detached and waits for its
|
|
7
|
+
* queue announcement, then the worker is stopped (SIGTERM, SIGKILL of its group after 5 s). The
|
|
8
|
+
* run's record, key, lock and temp root are removed afterwards: doctor leaves no run behind.
|
|
9
|
+
*
|
|
10
|
+
* The `signer` row is printed on every full run: `signer: same-user boundary only` (§8.6) — the
|
|
11
|
+
* MAC is tamper evidence within ordinary tool use, never a boundary against a same-user process.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { runsDir } from '../state/paths.mjs';
|
|
17
|
+
import { startRun } from '../state/run.mjs';
|
|
18
|
+
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
19
|
+
import { exec } from '../util/exec.mjs';
|
|
20
|
+
import { isAlive, registerPid, unregisterPid } from '../util/reaper.mjs';
|
|
21
|
+
import { pidsDir, tmpBase } from '../util/tmp.mjs';
|
|
22
|
+
import { launchWorker } from '../worker/loop.mjs';
|
|
23
|
+
import { liveWorker } from '../worker/queue.mjs';
|
|
24
|
+
import { gitChildEnv } from '../worker/ticket.mjs';
|
|
25
|
+
import { row } from './rows.mjs';
|
|
26
|
+
|
|
27
|
+
/** @typedef {import('./rows.mjs').Row} Row */
|
|
28
|
+
|
|
29
|
+
export const SIGNER_ROW = Object.freeze(row('signer', 'INFO', 'signer', 'same-user boundary only'));
|
|
30
|
+
|
|
31
|
+
const STOP_WAIT_MS = 5000;
|
|
32
|
+
|
|
33
|
+
/** @param {() => boolean} cond @param {number} ms */
|
|
34
|
+
async function waitFor(cond, ms) {
|
|
35
|
+
const until = Date.now() + ms;
|
|
36
|
+
while (Date.now() < until) {
|
|
37
|
+
if (cond()) return true;
|
|
38
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
39
|
+
}
|
|
40
|
+
return cond();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* SIGTERM the worker, then SIGKILL its group when it is still alive after `STOP_WAIT_MS`.
|
|
45
|
+
* @param {number} pid @returns {Promise<boolean>} true when it stopped on SIGTERM.
|
|
46
|
+
*/
|
|
47
|
+
async function stopWorker(pid) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 'SIGTERM');
|
|
50
|
+
} catch {
|
|
51
|
+
return true; // already gone
|
|
52
|
+
}
|
|
53
|
+
if (await waitFor(() => !isAlive(pid), STOP_WAIT_MS)) return true;
|
|
54
|
+
try {
|
|
55
|
+
process.kill(-pid, 'SIGKILL');
|
|
56
|
+
} catch {
|
|
57
|
+
// gone meanwhile
|
|
58
|
+
}
|
|
59
|
+
await waitFor(() => !isAlive(pid), 2000);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {string} runId remove the probe run's record, key, lock and anything else it left in the runs dir. */
|
|
64
|
+
function removeRun(runId) {
|
|
65
|
+
let names = [];
|
|
66
|
+
try {
|
|
67
|
+
names = readdirSync(runsDir());
|
|
68
|
+
} catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const name of names) if (name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {{cfg: Record<string, any>, configFile: string, workDir: string, runRootDir: string, env: NodeJS.ProcessEnv, runId: string}} ctx
|
|
76
|
+
* @returns {Promise<Row[]>} the `signer key` row, the `worker` row and the `signer` line.
|
|
77
|
+
*/
|
|
78
|
+
export async function probeWorkerAndSigner(ctx) {
|
|
79
|
+
const { runId, env } = ctx;
|
|
80
|
+
const repo = path.join(ctx.workDir, 'worker-repo');
|
|
81
|
+
const rows = [];
|
|
82
|
+
/** @type {number | null} */
|
|
83
|
+
let pid = null;
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(repo, { recursive: true, mode: 0o700 });
|
|
86
|
+
const init = await exec(['git', 'init', '-q'], { cwd: repo, env: gitChildEnv(env), timeoutMs: 20000 });
|
|
87
|
+
if (init.result !== 'ok') throw Object.assign(new Error('git init failed'), { code: 'git-init' });
|
|
88
|
+
copyFileSync(ctx.configFile, path.join(repo, '.code-forge.yml'));
|
|
89
|
+
await startRun({ workspace: repo, project: 'doctor-probe', config: ctx.cfg, runId, writeRow: async () => {} });
|
|
90
|
+
|
|
91
|
+
const key = await loadKey(runId);
|
|
92
|
+
const signed = signRow({ event: 'doctor.probe', run: runId }, key);
|
|
93
|
+
const flipped = { ...signed, event: 'doctor.probf' };
|
|
94
|
+
const signerOk = verifyRow(signed, key).ok && verifyRow(flipped, key).reason === 'mismatch';
|
|
95
|
+
rows.push(row('signer-key', signerOk ? 'OK' : 'FAIL', 'signer key', signerOk ? 'generated; a signed row verifies, a changed one is refused' : 'sign/verify round trip failed'));
|
|
96
|
+
|
|
97
|
+
const started = await launchWorker({ runId, workspace: repo, env });
|
|
98
|
+
pid = started.pid;
|
|
99
|
+
registerPid(pidsDir(ctx.runRootDir), pid, 'code-forge worker'); // a killed doctor's worker is reaped by the next sweep
|
|
100
|
+
const announced = liveWorker(started.repoRoot)?.pid === pid;
|
|
101
|
+
const clean = await stopWorker(pid);
|
|
102
|
+
unregisterPid(pidsDir(ctx.runRootDir), pid);
|
|
103
|
+
const workerPid = pid;
|
|
104
|
+
pid = null;
|
|
105
|
+
const gone = liveWorker(started.repoRoot) === null;
|
|
106
|
+
rows.push(announced && clean && gone
|
|
107
|
+
? row('worker', 'OK', 'worker', `pid ${workerPid} started, announced itself and stopped on SIGTERM`)
|
|
108
|
+
: row('worker', 'FAIL', 'worker', `pid ${workerPid} start/stop incomplete (announced=${announced}, stopped on SIGTERM=${clean}, retracted=${gone})`));
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (!rows.some((r) => r.id === 'signer-key')) rows.push(row('signer-key', 'FAIL', 'signer key', `run key could not be generated (${err?.code ?? err?.name ?? 'error'})`));
|
|
111
|
+
rows.push(row('worker', 'FAIL', 'worker', `could not start (${err?.code ?? err?.name ?? 'error'})`));
|
|
112
|
+
} finally {
|
|
113
|
+
if (pid !== null) await stopWorker(pid);
|
|
114
|
+
removeRun(runId);
|
|
115
|
+
const cfgRoot = typeof ctx.cfg?.tmp?.root === 'string' ? ctx.cfg.tmp.root : undefined;
|
|
116
|
+
try {
|
|
117
|
+
rmSync(path.join(tmpBase(cfgRoot), runId), { recursive: true, force: true }); // the worker's own run root
|
|
118
|
+
} catch {
|
|
119
|
+
// tmp.root invalid: the tmp row reports it
|
|
120
|
+
}
|
|
121
|
+
rmSync(repo, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
rows.push(SIGNER_ROW);
|
|
124
|
+
return rows;
|
|
125
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Agent-tool alias map (plan §5.2): under `engine: harness` (no Solo, R2 default) the
|
|
3
|
+
* orchestrator calls `Agent(subagent_type: general-purpose, model: <alias>, prompt: <pointer>)` —
|
|
4
|
+
* the Agent tool takes a FAMILY ALIAS (`sonnet|opus|haiku|fable`, per the pinned Claude `--model`
|
|
5
|
+
* help text: "Provide an alias for the latest model (e.g. 'fable', 'opus', or 'sonnet')"), never a
|
|
6
|
+
* full model id, and it has no `effort` parameter at all. So this module does two things: (1) maps
|
|
7
|
+
* a configured model id to its family alias by prefix, and (2) always reports
|
|
8
|
+
* `effort_effective: null` for an Agent-tool dispatch, so the ledger and `doctor` can show that an
|
|
9
|
+
* effort setting silently had no effect there (plan §5.2: "the ledger records `effort_effective:
|
|
10
|
+
* null` and `doctor` prints it" — note the plan's own field name is snake_case; fix round 1
|
|
11
|
+
* corrected this module's key from `effortEffective` to match it exactly).
|
|
12
|
+
*
|
|
13
|
+
* **Fix round 1 (isolated per-file review):**
|
|
14
|
+
* - The prefix check now requires a WORD boundary: `model === prefix` or `model.startsWith(prefix
|
|
15
|
+
* + '-')`. A bare `model.startsWith(prefix)` accepted typos like `claude-opusx` or
|
|
16
|
+
* `claude-sonnetfoo`, which goes against this module's own stated rule ("refuse rather than
|
|
17
|
+
* guess when an id is mistyped").
|
|
18
|
+
* - `toAgentToolDispatch` destructures with a default so `undefined`/`null` params produce the
|
|
19
|
+
* same clear `TypeError` `resolveClaudeAlias` gives for a bad `model`, instead of a generic
|
|
20
|
+
* property-access crash before that check ever runs.
|
|
21
|
+
* - The dispatch object's key is now `effort_effective` (was `effortEffective`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Model-id prefix -> Agent-tool alias, matched longest-prefix-first so a future 2-word family name
|
|
26
|
+
* cannot be shadowed by a shorter one that happens to be a prefix of it.
|
|
27
|
+
* @type {ReadonlyArray<[string, string]>}
|
|
28
|
+
*/
|
|
29
|
+
const FAMILY_ALIASES = Object.freeze(
|
|
30
|
+
/** @type {[string, string][]} */ ([
|
|
31
|
+
['claude-haiku', 'haiku'],
|
|
32
|
+
['claude-sonnet', 'sonnet'],
|
|
33
|
+
['claude-opus', 'opus'],
|
|
34
|
+
['claude-fable', 'fable'],
|
|
35
|
+
]).sort((a, b) => b[0].length - a[0].length),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} model
|
|
40
|
+
* @param {string} prefix
|
|
41
|
+
* @returns {boolean} true only for an EXACT family match or `prefix` followed by `-` — never a
|
|
42
|
+
* bare substring prefix (fix round 1, MINOR: refuses `claude-opusx`, `claude-sonnetfoo`, …).
|
|
43
|
+
*/
|
|
44
|
+
function matchesFamily(model, prefix) {
|
|
45
|
+
return model === prefix || model.startsWith(`${prefix}-`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} model - a resolved Claude model id, e.g. `"claude-opus-5-5"`.
|
|
50
|
+
* @returns {string} the Agent-tool alias for `model`'s family.
|
|
51
|
+
* @throws {Error} when `model` matches no known family prefix — refusing rather than guessing
|
|
52
|
+
* keeps a typo'd or brand-new id from silently dispatching to the wrong family.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveClaudeAlias(model) {
|
|
55
|
+
if (typeof model !== 'string' || model.length === 0) {
|
|
56
|
+
throw new TypeError('resolveClaudeAlias: model must be a non-empty string');
|
|
57
|
+
}
|
|
58
|
+
const match = FAMILY_ALIASES.find(([prefix]) => matchesFamily(model, prefix));
|
|
59
|
+
if (!match) {
|
|
60
|
+
throw new Error(`resolveClaudeAlias: no known family alias for model "${model}"`);
|
|
61
|
+
}
|
|
62
|
+
return match[1];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @typedef {{subagentType: "general-purpose", model: string, effort_effective: null}} AgentToolDispatch */
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{model?: string, effort?: string}} [params] - `effort` is accepted (a caller may pass the
|
|
69
|
+
* resolved level's effort through unconditionally) but is never reflected in the Agent-tool
|
|
70
|
+
* call — see the module doc. Defaults to `{}` so a missing/`undefined` `params` still reaches
|
|
71
|
+
* {@link resolveClaudeAlias}'s own clear `model` error, instead of crashing on `params.model`
|
|
72
|
+
* here first.
|
|
73
|
+
* @returns {AgentToolDispatch}
|
|
74
|
+
* @throws {Error} propagated from {@link resolveClaudeAlias} for an unknown/missing family.
|
|
75
|
+
*/
|
|
76
|
+
export function toAgentToolDispatch({ model } = {}) {
|
|
77
|
+
return { subagentType: 'general-purpose', model: resolveClaudeAlias(/** @type {string} */ (model)), effort_effective: null };
|
|
78
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code 2.1.282 argv builder (plan §5.2, pinned against `test/fixtures/help/claude-2.1.282.txt`
|
|
3
|
+
* — B0's byte copy of `claude --help` on this Mac, 2026-09-24). Pure: no I/O, no CLI spawning
|
|
4
|
+
* (`src/util/exec.mjs` is the one place that spawns; B9's `spawn.mjs` calls it with this argv).
|
|
5
|
+
*
|
|
6
|
+
* Six roles collapse to two shapes: `coder` (writes files, cwd = the project tree) and everything
|
|
7
|
+
* else — `reviewer`/`judge`/`s2`/`author`/`facts` (closed-book, cwd = an empty temp dir with the
|
|
8
|
+
* packet). `facts` is the one role Claude treats specially even within the closed-book shape
|
|
9
|
+
* (§0.6.7, §8.4): `--tools ""` becomes `--tools "Bash"`, and `--disallowedTools` + `--restricted`
|
|
10
|
+
* are added so the facts delegate can run read-only shell commands but nothing that writes.
|
|
11
|
+
*
|
|
12
|
+
* **Return contract (fix round 3, root ruling — corrects plan §5.2's "<brief pointer>" for the
|
|
13
|
+
* closed-book shape):** `{cli, role, argv, cwd, stdinFile?, forbiddenRendered?}`.
|
|
14
|
+
* - `coder`: the brief is a POINTER — the coder has tools and opens it itself. argv ends with a
|
|
15
|
+
* literal `--` then `promptPath`, so no variadic flag (`--disallowedTools <tools...>`) can ever
|
|
16
|
+
* swallow it. No `stdinFile`.
|
|
17
|
+
* - closed-book (`reviewer`/`judge`/`s2`/`author`/`facts`): a role built with `--tools ""` cannot
|
|
18
|
+
* open a path, so a pointer would leave the model reviewing nothing. The argv carries NO prompt
|
|
19
|
+
* positional (and no `--`); `stdinFile === promptPath` names the packet file whose CONTENT the
|
|
20
|
+
* spawner (B9) must pipe to the child's stdin. `claude -p` with no prompt argument reads the
|
|
21
|
+
* prompt from stdin (`--input-format` default "text", help fixture line "--input-format <format>
|
|
22
|
+
* Input format (only works with --print)"); the root verified the stdin form against the real
|
|
23
|
+
* `claude -p --safe-mode --tools ""` on this Mac (2026-09-24). `facts` has `Bash` and could
|
|
24
|
+
* read the file, but it takes the same stdin path so every closed-book role has ONE delivery
|
|
25
|
+
* mechanism.
|
|
26
|
+
*
|
|
27
|
+
* **Doctor probe item for B13 (no network in B4's tests):** `claude: closed-book stdin delivery` —
|
|
28
|
+
* spawn this module's `reviewer` argv with `stdinFile` piped in, the packet containing a unique
|
|
29
|
+
* canary token and the instruction "reply with the canary"; PASS when the JSON `result` contains
|
|
30
|
+
* the canary, FAIL (`claude: closed-book packet not delivered`) otherwise.
|
|
31
|
+
*
|
|
32
|
+
* **Forbidden-list rendering is a PARAMETER** (`renderedForbidden`, default
|
|
33
|
+
* `renderForClaude(FORBIDDEN)` from B8's landed `src/util/forbidden.mjs`): this module only
|
|
34
|
+
* flattens whatever render it is given, so it never hard-codes a rule-string shape (seam C17).
|
|
35
|
+
* Both `coder` and `facts` refuse an empty render (0 usable rules) — a renderer regression must
|
|
36
|
+
* be a thrown error, never a silently weaker deny list.
|
|
37
|
+
*
|
|
38
|
+
* `--fallback-model <model>` takes exactly ONE model id in the pinned help. Only the first
|
|
39
|
+
* same-provider (`anthropic`) fallback whose model DIFFERS from the main `--model` is used —
|
|
40
|
+
* Claude Code refuses to start when the two are equal.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { FORBIDDEN, mergeForbidden, renderForClaude } from '../../util/forbidden.mjs';
|
|
44
|
+
import { flattenRuleStrings, hasAnyRenderedRule } from '../render-rules.mjs';
|
|
45
|
+
import { assertBaseParams } from './validate-params.mjs';
|
|
46
|
+
|
|
47
|
+
export const CLI = 'claude';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {{id: string, rules: ReadonlyArray<string>, enforced: boolean}} RenderedForbiddenEntry
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {{provider: string, model: string, effort?: string}} FallbackEntry
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {object} ClaudeBuildParams
|
|
59
|
+
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
60
|
+
* @property {string} model - the resolved model id (already picked by `resolveLevel`, B1).
|
|
61
|
+
* @property {string} [effort]
|
|
62
|
+
* @property {string} promptPath - the brief file (coder: passed as a pointer) or the packet file
|
|
63
|
+
* (closed-book: returned as `stdinFile`, its content is piped to stdin by the spawner).
|
|
64
|
+
* @property {string} cwd - the project tree (coder) or an empty isolated temp dir (closed-book).
|
|
65
|
+
* @property {ReadonlyArray<RenderedForbiddenEntry>} [renderedForbidden] - used by the coder role
|
|
66
|
+
* (default `renderForClaude(mergeForbidden())`, B4.2) and the `facts` role (default
|
|
67
|
+
* `renderForClaude(FORBIDDEN)`, unchanged).
|
|
68
|
+
* @property {ReadonlyArray<FallbackEntry>} [fallback] - coder role only; see the module doc.
|
|
69
|
+
* @property {number} [maxBudgetUsd] - must be a finite number > 0 when given.
|
|
70
|
+
* @property {Record<string, any>} [schema] - compiled JSON Schema object; closed-book roles only,
|
|
71
|
+
* sent inline (`--json-schema`, help text example is an inline JSON string, not a file path).
|
|
72
|
+
* @property {string} [systemPromptText] - the lens preamble; closed-book roles only.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {object} BuiltCoder
|
|
77
|
+
* @property {"claude"} cli
|
|
78
|
+
* @property {"coder"} role
|
|
79
|
+
* @property {string[]} argv - ends with `--`, promptPath.
|
|
80
|
+
* @property {string} cwd
|
|
81
|
+
* @property {ReadonlyArray<RenderedForbiddenEntry>} forbiddenRendered
|
|
82
|
+
* @property {undefined} [stdinFile] - never set: the coder opens its brief itself.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {object} BuiltClosedBook
|
|
87
|
+
* @property {"claude"} cli
|
|
88
|
+
* @property {"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
89
|
+
* @property {string[]} argv - no prompt positional.
|
|
90
|
+
* @property {string} cwd
|
|
91
|
+
* @property {string} stdinFile - the packet file the spawner pipes to stdin.
|
|
92
|
+
* @property {ReadonlyArray<RenderedForbiddenEntry>} [forbiddenRendered] - present only for `facts`.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extra `--disallowedTools` rules the `facts` role adds on top of the forbidden list (plan §5.2
|
|
97
|
+
* "Facts role only"): the two literal examples the plan names (`Bash(rm*)`, `Bash(git push*)`)
|
|
98
|
+
* plus the handful of other ordinary write verbs a read-only facts delegate never needs.
|
|
99
|
+
* @type {ReadonlyArray<string>}
|
|
100
|
+
*/
|
|
101
|
+
export const FACTS_EXTRA_WRITE_VERB_RULES = Object.freeze([
|
|
102
|
+
'Bash(rm*)',
|
|
103
|
+
'Bash(git push*)',
|
|
104
|
+
'Bash(git commit*)',
|
|
105
|
+
'Bash(git merge*)',
|
|
106
|
+
'Bash(mv*)',
|
|
107
|
+
'Bash(cp*)',
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {unknown} fallback
|
|
112
|
+
* @throws {TypeError} unless `fallback` is `undefined` or an array of objects whose `provider` and
|
|
113
|
+
* `model` are non-empty strings. The message names the index only, never the entry's values.
|
|
114
|
+
*/
|
|
115
|
+
function assertValidFallback(fallback) {
|
|
116
|
+
if (fallback === undefined) return;
|
|
117
|
+
if (!Array.isArray(fallback)) {
|
|
118
|
+
throw new TypeError('buildClaudeArgv: fallback must be an array when given');
|
|
119
|
+
}
|
|
120
|
+
fallback.forEach((entry, index) => {
|
|
121
|
+
const valid =
|
|
122
|
+
entry !== null &&
|
|
123
|
+
typeof entry === 'object' &&
|
|
124
|
+
typeof entry.provider === 'string' &&
|
|
125
|
+
entry.provider.length > 0 &&
|
|
126
|
+
typeof entry.model === 'string' &&
|
|
127
|
+
entry.model.length > 0;
|
|
128
|
+
if (!valid) {
|
|
129
|
+
throw new TypeError(`buildClaudeArgv: fallback[${index}] must be an object with non-empty string provider and model`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {ReadonlyArray<FallbackEntry> | undefined} fallback
|
|
136
|
+
* @param {string} provider
|
|
137
|
+
* @param {string} model - the main `--model`; a fallback equal to it is skipped.
|
|
138
|
+
* @returns {string | undefined} the FIRST same-provider fallback model id that differs from `model`.
|
|
139
|
+
*/
|
|
140
|
+
function firstSameProviderFallbackId(fallback, provider, model) {
|
|
141
|
+
return (fallback ?? []).find((f) => f.provider === provider && f.model !== model)?.model;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {unknown} maxBudgetUsd
|
|
146
|
+
* @param {string} builderName
|
|
147
|
+
* @throws {TypeError} unless `maxBudgetUsd` is `undefined` or a finite number > 0.
|
|
148
|
+
*/
|
|
149
|
+
function assertValidBudget(maxBudgetUsd, builderName) {
|
|
150
|
+
if (maxBudgetUsd !== undefined && !(typeof maxBudgetUsd === 'number' && Number.isFinite(maxBudgetUsd) && maxBudgetUsd > 0)) {
|
|
151
|
+
throw new TypeError(`${builderName}: maxBudgetUsd must be a finite number > 0 when given, got ${JSON.stringify(maxBudgetUsd)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {ClaudeBuildParams} params
|
|
157
|
+
* @returns {BuiltCoder}
|
|
158
|
+
*/
|
|
159
|
+
function buildCoderArgv(params) {
|
|
160
|
+
// B4.2: a coder's deny list is `mergeForbidden()` — FORBIDDEN plus the coder-only entries
|
|
161
|
+
// (`code-forge block waive`, `--no-require-reviews`). A `contains` entry (`--no-require-reviews`)
|
|
162
|
+
// renders 0 rules (no pinned CLI has a match-anywhere rule), so it stays with the pre-spawn
|
|
163
|
+
// `isForbidden` check and the transcript grep, as before.
|
|
164
|
+
const { model, effort, promptPath, cwd, fallback, maxBudgetUsd, renderedForbidden = renderForClaude(mergeForbidden()) } = params;
|
|
165
|
+
assertValidBudget(maxBudgetUsd, 'buildClaudeArgv');
|
|
166
|
+
assertValidFallback(fallback);
|
|
167
|
+
if (!hasAnyRenderedRule(renderedForbidden)) {
|
|
168
|
+
throw new Error('buildClaudeArgv: coder role requires a non-empty forbidden-list render (got 0 usable rules)');
|
|
169
|
+
}
|
|
170
|
+
const argv = ['claude', '-p', '--model', model];
|
|
171
|
+
if (effort) argv.push('--effort', effort);
|
|
172
|
+
argv.push('--permission-mode', 'bypassPermissions');
|
|
173
|
+
argv.push('--output-format', 'json');
|
|
174
|
+
argv.push('--no-session-persistence');
|
|
175
|
+
if (maxBudgetUsd !== undefined) argv.push('--max-budget-usd', String(maxBudgetUsd));
|
|
176
|
+
argv.push('--disallowedTools', ...flattenRuleStrings(renderedForbidden));
|
|
177
|
+
const sameProviderId = firstSameProviderFallbackId(fallback, 'anthropic', model);
|
|
178
|
+
if (sameProviderId !== undefined) argv.push('--fallback-model', sameProviderId);
|
|
179
|
+
argv.push('--', promptPath);
|
|
180
|
+
return { cli: CLI, role: 'coder', argv, cwd, forbiddenRendered: renderedForbidden };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {ClaudeBuildParams} params
|
|
185
|
+
* @returns {BuiltClosedBook}
|
|
186
|
+
*/
|
|
187
|
+
function buildClosedBookArgv(params) {
|
|
188
|
+
const { model, effort, promptPath, cwd, schema, systemPromptText, maxBudgetUsd, renderedForbidden = renderForClaude(FORBIDDEN) } = params;
|
|
189
|
+
assertValidBudget(maxBudgetUsd, 'buildClaudeArgv');
|
|
190
|
+
// The dispatch in `buildClaudeArgv` never reaches this branch with role 'coder' — a runtime
|
|
191
|
+
// invariant the parameter type alone cannot prove, so the narrowing is explicit.
|
|
192
|
+
const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
|
|
193
|
+
const isFacts = role === 'facts';
|
|
194
|
+
if (isFacts && !hasAnyRenderedRule(renderedForbidden)) {
|
|
195
|
+
throw new Error('buildClaudeArgv: facts role requires a non-empty forbidden-list render (got 0 usable rules)');
|
|
196
|
+
}
|
|
197
|
+
const argv = ['claude', '-p', '--model', model];
|
|
198
|
+
if (effort) argv.push('--effort', effort);
|
|
199
|
+
argv.push('--safe-mode');
|
|
200
|
+
argv.push('--tools', isFacts ? 'Bash' : '');
|
|
201
|
+
argv.push('--strict-mcp-config');
|
|
202
|
+
argv.push('--no-session-persistence');
|
|
203
|
+
if (systemPromptText !== undefined) argv.push('--system-prompt', systemPromptText);
|
|
204
|
+
argv.push('--output-format', 'json');
|
|
205
|
+
if (schema !== undefined) argv.push('--json-schema', JSON.stringify(schema));
|
|
206
|
+
if (maxBudgetUsd !== undefined) argv.push('--max-budget-usd', String(maxBudgetUsd));
|
|
207
|
+
argv.push('--permission-mode', 'dontAsk');
|
|
208
|
+
if (!isFacts) {
|
|
209
|
+
return { cli: CLI, role, argv, cwd, stdinFile: promptPath };
|
|
210
|
+
}
|
|
211
|
+
argv.push('--disallowedTools', ...flattenRuleStrings(renderedForbidden), ...FACTS_EXTRA_WRITE_VERB_RULES);
|
|
212
|
+
// `--restricted` is boolean and last: it also terminates the variadic list above.
|
|
213
|
+
argv.push('--restricted');
|
|
214
|
+
return { cli: CLI, role, argv, cwd, stdinFile: promptPath, forbiddenRendered: renderedForbidden };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {ClaudeBuildParams} params
|
|
219
|
+
* @returns {BuiltCoder | BuiltClosedBook}
|
|
220
|
+
*/
|
|
221
|
+
export function buildClaudeArgv(params) {
|
|
222
|
+
assertBaseParams('buildClaudeArgv', params);
|
|
223
|
+
return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
|
|
224
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex 0.155.1 argv builder (plan §5.2, pinned against `test/fixtures/help/codex-0.155.1.txt`).
|
|
3
|
+
* No spawning; the coder role writes its per-session rules file (see "Execpolicy rules" below). NOT fully deterministic: when `outPath` is omitted, a default is drawn from
|
|
4
|
+
* `os.tmpdir()` + `randomBytes` — pass `outPath` for a reproducible argv (every snapshot test does).
|
|
5
|
+
*
|
|
6
|
+
* **Return contract (fix round 3):** `{cli, role, argv, cwd, outPath, stdinFile?, ...}`.
|
|
7
|
+
* - `outPath` is ALWAYS returned — the effective `-o` target, worked out once per call, so the
|
|
8
|
+
* caller reads Codex's last message from it and deletes it afterwards (the builder never
|
|
9
|
+
* creates the file; Codex does).
|
|
10
|
+
* - `coder`: the brief is a POINTER (the last argv token is `promptPath`); the coder has tools.
|
|
11
|
+
* - closed-book (`reviewer`/`judge`/`s2`/`author`/`facts`): the last argv token is the literal
|
|
12
|
+
* `-`, which the pinned help (`codex exec [OPTIONS] [PROMPT]`: "If not provided as an argument
|
|
13
|
+
* (or if `-` is used), instructions are read from stdin") defines as "read the prompt from
|
|
14
|
+
* stdin". `stdinFile === promptPath` names the packet file whose CONTENT the spawner (B9)
|
|
15
|
+
* pipes to stdin — a closed-book reviewer must receive the packet itself, never a path it may
|
|
16
|
+
* not be able to open. The explicit `-` form was chosen over omitting the positional because
|
|
17
|
+
* it is documented and does not depend on stdin-is-piped detection.
|
|
18
|
+
*
|
|
19
|
+
* **Doctor probe item for B13 (no network in B4's tests):** `codex: closed-book stdin delivery` —
|
|
20
|
+
* run this module's `reviewer` argv with `stdinFile` piped in, the packet carrying a unique canary
|
|
21
|
+
* and "reply with the canary"; PASS when the file at `outPath` contains the canary, FAIL
|
|
22
|
+
* (`codex: closed-book packet not delivered`) otherwise.
|
|
23
|
+
*
|
|
24
|
+
* Unlike Claude, the flag table gives Codex only TWO command shapes: coder, and "reviewer / judge
|
|
25
|
+
* / S2 / author / facts" — there is no Codex-specific "facts role only" carve-out anywhere in the
|
|
26
|
+
* plan's flag table, so `facts` here is simply routed through the same closed-book branch as the
|
|
27
|
+
* other four roles.
|
|
28
|
+
*
|
|
29
|
+
* **Execpolicy rules (B4.1, §0.6.3, [A19]).** Codex 0.155.1 `exec` has no flag and no `-c` key
|
|
30
|
+
* naming a rules file; it loads the "user" rules from `$CODEX_HOME/rules/*.rules` (`--ignore-rules`
|
|
31
|
+
* help: "Do not load user or project execpolicy `.rules` files"). So the coder build creates a
|
|
32
|
+
* per-session Codex home under the run temp root (`../codex-home.mjs`, `codexHome` param or
|
|
33
|
+
* `defaultCodexHome()`), writes `rules/code-forge.rules` there (execpolicy Starlark, one
|
|
34
|
+
* `prefix_rule(..., decision="forbidden")` per `renderForCodex` pattern) and returns
|
|
35
|
+
* `env: {CODEX_HOME}` — the spawner MUST merge `env` into the child's environment. The home is
|
|
36
|
+
* never the user's real `~/.codex` (a `codexHome` there throws). This is the one builder that
|
|
37
|
+
* writes a file: the rules must exist before Codex starts, and the builder is the one place that
|
|
38
|
+
* holds the render.
|
|
39
|
+
*
|
|
40
|
+
* **Fix round 1 (isolated per-file review):**
|
|
41
|
+
* - Unknown `role` now throws (was silently routed to closed-book) — `assertBaseParams`.
|
|
42
|
+
* - `cwd` is now required and validated, same as `model`/`promptPath` — `assertBaseParams`.
|
|
43
|
+
* - A coder-role render with 0 usable patterns now throws (see `claude.mjs`'s identical guard).
|
|
44
|
+
* - The default `-o`/output-last-message path no longer lands INSIDE `cwd`: for the coder that
|
|
45
|
+
* put a stray file in the project tree (picked up by later diffs); for a closed-book role it
|
|
46
|
+
* silently broke the "isolation dir lists exactly 1 file" invariant. It now defaults to a path
|
|
47
|
+
* under `os.tmpdir()`, keyed by role and a random suffix, always outside `cwd`.
|
|
48
|
+
* - `effort` is now checked against the same four values the alias map / Claude `--effort` help
|
|
49
|
+
* text documents (`low|medium|high|xhigh|max` minus `xhigh`/`max`, which Codex's own
|
|
50
|
+
* `model_reasoning_effort` TOML values don't claim — kept to the portable subset
|
|
51
|
+
* `minimal|low|medium|high`) before being written into a `-c` config override; Codex parses
|
|
52
|
+
* that value as TOML, so an unchecked string could smuggle a quote or a nested key.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import os from 'node:os';
|
|
56
|
+
import path from 'node:path';
|
|
57
|
+
import { randomBytes } from 'node:crypto';
|
|
58
|
+
import { mergeForbidden, renderForCodex } from '../../util/forbidden.mjs';
|
|
59
|
+
import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } from '../codex-home.mjs';
|
|
60
|
+
import { hasAnyRenderedRule } from '../render-rules.mjs';
|
|
61
|
+
import { assertBaseParams } from './validate-params.mjs';
|
|
62
|
+
|
|
63
|
+
export const CLI = 'codex';
|
|
64
|
+
|
|
65
|
+
/** The rules file Codex loads from `<CODEX_HOME>/rules/` (B4.1). */
|
|
66
|
+
export const RULES_FILE_NAME = CODEX_RULES_FILE_NAME;
|
|
67
|
+
|
|
68
|
+
/** `-c` overrides that keep `$TMPDIR` and `/tmp` out of the coder's writable roots (B4.1). */
|
|
69
|
+
export const SANDBOX_TMP_EXCLUSIONS = Object.freeze([
|
|
70
|
+
'-c',
|
|
71
|
+
'sandbox_workspace_write.exclude_tmpdir_env_var=true',
|
|
72
|
+
'-c',
|
|
73
|
+
'sandbox_workspace_write.exclude_slash_tmp=true',
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The coder's approval policy (B4.2). Codex 0.155.1 rejects `-s workspace-write` together with
|
|
78
|
+
* `--approve-for-me` (clap: "the argument '--sandbox <SANDBOX_MODE>' cannot be used with
|
|
79
|
+
* '--approve-for-me'"), so the coder drops `--approve-for-me` and keeps the sandbox. `codex exec`
|
|
80
|
+
* has no `-a/--ask-for-approval` flag (that one is on the interactive `codex` only), so the
|
|
81
|
+
* policy is set through the `approval_policy` config key, whose valid values the 0.155.1 config
|
|
82
|
+
* loader lists as `untrusted`, `on-failure`, `on-request`, `granular`, `never`. `never` = "Never
|
|
83
|
+
* ask for user approval. Execution failures are immediately returned to the model" (`codex
|
|
84
|
+
* --help`): a sandbox-denied command simply fails, nothing escalates out of the sandbox.
|
|
85
|
+
*/
|
|
86
|
+
export const CODER_APPROVAL_POLICY = Object.freeze(['-c', 'approval_policy="never"']);
|
|
87
|
+
|
|
88
|
+
/** The `model_reasoning_effort` values Codex's TOML config is willing to receive from this builder. */
|
|
89
|
+
export const VALID_EFFORTS = Object.freeze(['minimal', 'low', 'medium', 'high']);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {{id: string, patterns: string[][], decision: "forbidden", enforced: boolean, description: string}} RenderedCodexEntry
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @typedef {object} CodexBuildParams
|
|
97
|
+
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
98
|
+
* @property {string} model
|
|
99
|
+
* @property {string} [effort] - one of {@link VALID_EFFORTS} when given.
|
|
100
|
+
* @property {string} promptPath - the brief file (coder: passed as a pointer) or the packet file
|
|
101
|
+
* (closed-book: returned as `stdinFile`, piped to stdin by the spawner).
|
|
102
|
+
* @property {string} cwd - the project tree (coder) or an empty isolated temp dir (closed-book).
|
|
103
|
+
* @property {string} [outPath] - `-o` target; defaults to a fresh path under `os.tmpdir()`, NEVER
|
|
104
|
+
* inside `cwd` (fix round 1).
|
|
105
|
+
* @property {string} [schemaPath] - closed-book only: a FILE PATH (`--output-schema <FILE>` takes
|
|
106
|
+
* a path, unlike Claude/Grok's inline `--json-schema` string — see `test/fixtures/help/
|
|
107
|
+
* codex-0.155.1.txt`).
|
|
108
|
+
* @property {ReadonlyArray<RenderedCodexEntry>} [renderedForbidden] - coder role only; defaults to
|
|
109
|
+
* `renderForCodex(mergeForbidden())` (B4.2: the coder-only entries too; a `contains` entry
|
|
110
|
+
* renders 0 patterns, so it is left to the pre-spawn check and the transcript grep).
|
|
111
|
+
* @property {string} [codexHome] - coder role only: the per-session Codex home to create (absolute,
|
|
112
|
+
* must not exist as the real `~/.codex` or inside it); defaults to `defaultCodexHome()` under the
|
|
113
|
+
* run temp root.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} role
|
|
118
|
+
* @returns {string} a fresh path under `os.tmpdir()`, never inside the caller's `cwd`.
|
|
119
|
+
*/
|
|
120
|
+
function defaultOutPath(role) {
|
|
121
|
+
return path.join(os.tmpdir(), `code-forge-${role}-output-${randomBytes(6).toString('hex')}.json`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {unknown} outPath
|
|
126
|
+
* @param {string} role
|
|
127
|
+
* @returns {string} the effective `-o` target: the caller's `outPath`, or one fresh default.
|
|
128
|
+
* @throws {TypeError} unless `outPath` is `undefined` or a non-empty string.
|
|
129
|
+
*/
|
|
130
|
+
function effectiveOutPath(outPath, role) {
|
|
131
|
+
if (outPath === undefined) return defaultOutPath(role);
|
|
132
|
+
if (typeof outPath !== 'string' || outPath.length === 0) {
|
|
133
|
+
throw new TypeError('buildCodexArgv: outPath must be a non-empty string when given');
|
|
134
|
+
}
|
|
135
|
+
return outPath;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string | undefined} effort
|
|
140
|
+
* @throws {TypeError} unless `effort` is `undefined` or one of {@link VALID_EFFORTS}.
|
|
141
|
+
*/
|
|
142
|
+
function assertValidEffort(effort) {
|
|
143
|
+
if (effort !== undefined && !VALID_EFFORTS.includes(effort)) {
|
|
144
|
+
throw new TypeError(`buildCodexArgv: effort must be one of ${VALID_EFFORTS.join(', ')} when given, got ${JSON.stringify(effort)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The Codex coder argv itself — pure, no I/O, no rules file (B4.2). `buildCoderArgv` uses it, and
|
|
150
|
+
* the doctor's flags probe builds it with placeholder values for its static conflict check.
|
|
151
|
+
* @param {{model: string, effort?: string, cwd: string, outPath: string, promptPath: string}} p
|
|
152
|
+
* @returns {string[]}
|
|
153
|
+
*/
|
|
154
|
+
export function coderFlagArgv({ model, effort, cwd, outPath, promptPath }) {
|
|
155
|
+
const argv = ['codex', 'exec', '-m', model];
|
|
156
|
+
if (effort) argv.push('-c', `model_reasoning_effort=${effort}`);
|
|
157
|
+
argv.push('-s', 'workspace-write');
|
|
158
|
+
// INVARIANT (B4.1 fix round 1): the session CODEX_HOME lives under the run temp root, so the
|
|
159
|
+
// sandbox must not make $TMPDIR or /tmp writable — the project cwd is then the only writable
|
|
160
|
+
// root, and a home inside the cwd is refused below. See `../codex-home.mjs`.
|
|
161
|
+
argv.push(...SANDBOX_TMP_EXCLUSIONS);
|
|
162
|
+
// B4.2: NO `--approve-for-me` (Codex 0.155.1 refuses it next to `-s`); see CODER_APPROVAL_POLICY.
|
|
163
|
+
argv.push(...CODER_APPROVAL_POLICY);
|
|
164
|
+
argv.push('-C', cwd);
|
|
165
|
+
argv.push('--json');
|
|
166
|
+
argv.push('-o', outPath);
|
|
167
|
+
argv.push(promptPath);
|
|
168
|
+
return argv;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {CodexBuildParams} params
|
|
173
|
+
* @returns {{cli: "codex", role: "coder", argv: string[], cwd: string, outPath: string, env: {CODEX_HOME: string}, forbiddenRendered: ReadonlyArray<RenderedCodexEntry>, rulesFile: {fileName: string, path: string, content: string, count: number}}}
|
|
174
|
+
*/
|
|
175
|
+
function buildCoderArgv(params) {
|
|
176
|
+
const { model, effort, promptPath, cwd, renderedForbidden = renderForCodex(mergeForbidden()) } = params;
|
|
177
|
+
assertValidEffort(effort);
|
|
178
|
+
const outPath = effectiveOutPath(params.outPath, 'coder');
|
|
179
|
+
if (!hasAnyRenderedRule(renderedForbidden)) {
|
|
180
|
+
throw new Error('buildCodexArgv: coder role requires a non-empty forbidden-list render (got 0 usable patterns)');
|
|
181
|
+
}
|
|
182
|
+
const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath });
|
|
183
|
+
const codexHome = params.codexHome ?? defaultCodexHome();
|
|
184
|
+
// Fix round 4: a non-absolute home is refused here, before any I/O, and the containment check
|
|
185
|
+
// below then runs EVERY time (no `isAbsolute` short-circuit that could skip it).
|
|
186
|
+
if (typeof codexHome !== 'string' || !path.isAbsolute(codexHome)) {
|
|
187
|
+
throw new TypeError('buildCodexArgv: codexHome must be an absolute path');
|
|
188
|
+
}
|
|
189
|
+
// `isInside` realpaths BOTH sides through their nearest existing ancestor (fix round 3): a
|
|
190
|
+
// `/var/...` cwd and a `/private/var/...` home are the same tree on macOS, and a symlinked cwd
|
|
191
|
+
// is compared by its target, so a string-different alias cannot slip a home into the cwd.
|
|
192
|
+
if (isInside(codexHome, cwd)) {
|
|
193
|
+
throw new Error('buildCodexArgv: the session CODEX_HOME must not be inside the coder cwd (the sandbox can write there)');
|
|
194
|
+
}
|
|
195
|
+
const home = prepareCodexHome(codexHome, renderedForbidden);
|
|
196
|
+
return {
|
|
197
|
+
cli: CLI,
|
|
198
|
+
role: 'coder',
|
|
199
|
+
argv,
|
|
200
|
+
cwd,
|
|
201
|
+
outPath,
|
|
202
|
+
env: { CODEX_HOME: home.codexHome },
|
|
203
|
+
forbiddenRendered: renderedForbidden,
|
|
204
|
+
rulesFile: { fileName: RULES_FILE_NAME, path: home.rulesPath, content: home.content, count: home.count },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {CodexBuildParams} params
|
|
210
|
+
* @returns {{cli: "codex", role: "reviewer"|"judge"|"s2"|"author"|"facts", argv: string[], cwd: string, outPath: string, stdinFile: string}}
|
|
211
|
+
*/
|
|
212
|
+
function buildClosedBookArgv(params) {
|
|
213
|
+
const { model, effort, promptPath, cwd, schemaPath } = params;
|
|
214
|
+
assertValidEffort(effort);
|
|
215
|
+
// See `builders/claude.mjs`'s identical cast: the dispatch below never reaches this branch
|
|
216
|
+
// with role 'coder', but that is a runtime invariant, not provable from the parameter type.
|
|
217
|
+
const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
|
|
218
|
+
const outPath = effectiveOutPath(params.outPath, role);
|
|
219
|
+
const argv = ['codex', 'exec', '-m', model];
|
|
220
|
+
if (effort) argv.push('-c', `model_reasoning_effort=${effort}`);
|
|
221
|
+
argv.push('-s', 'read-only');
|
|
222
|
+
argv.push('--ephemeral');
|
|
223
|
+
argv.push('--ignore-rules');
|
|
224
|
+
argv.push('--ignore-user-config');
|
|
225
|
+
argv.push('-C', cwd);
|
|
226
|
+
argv.push('--skip-git-repo-check');
|
|
227
|
+
if (schemaPath !== undefined) argv.push('--output-schema', schemaPath);
|
|
228
|
+
argv.push('-o', outPath);
|
|
229
|
+
argv.push('--json');
|
|
230
|
+
// `-` = "read the prompt from stdin" (pinned help); the packet CONTENT arrives there.
|
|
231
|
+
argv.push('-');
|
|
232
|
+
return { cli: CLI, role, argv, cwd, outPath, stdinFile: promptPath };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {CodexBuildParams} params
|
|
237
|
+
* @returns {ReturnType<typeof buildCoderArgv> | ReturnType<typeof buildClosedBookArgv>}
|
|
238
|
+
*/
|
|
239
|
+
export function buildCodexArgv(params) {
|
|
240
|
+
assertBaseParams('buildCodexArgv', params);
|
|
241
|
+
return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
|
|
242
|
+
}
|