@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,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok 1.0.34 argv builder (plan §5.2, pinned against `test/fixtures/help/grok-1.0.34.txt`). Pure:
|
|
3
|
+
* no I/O, no CLI spawning.
|
|
4
|
+
*
|
|
5
|
+
* Like Codex, Grok's flag table has only two command shapes — coder, and "reviewer / judge / S2 /
|
|
6
|
+
* author / facts" — so `facts` routes through the same closed-book branch as the other four roles
|
|
7
|
+
* (Claude is the only provider with a facts-specific carve-out).
|
|
8
|
+
*
|
|
9
|
+
* The coder command has **no `-p`**: Grok's `-p`/`--single` flag is a different, single-turn mode
|
|
10
|
+
* the coder role must not use (the plan calls this out explicitly). The forbidden list arrives the
|
|
11
|
+
* same way Claude's does — as a `renderedForbidden` parameter, defaulting to
|
|
12
|
+
* `renderForGrok(FORBIDDEN)` from B8's landed extensions, never a hard dependency baked in.
|
|
13
|
+
*
|
|
14
|
+
* Grok's `--deny <RULE>` fixture help shows a single value, not a variadic `<RULE...>` — unlike
|
|
15
|
+
* Claude's documented `<tools...>` for `--disallowedTools`. This builder repeats `--deny <rule>`
|
|
16
|
+
* once per rendered rule string — a scalar (single-value) flag repeated, never a variadic-list
|
|
17
|
+
* swallow risk the way `claude.mjs`'s BLOCKER fix had to guard against, since each `--deny`
|
|
18
|
+
* occurrence's value slot is exactly one token wide.
|
|
19
|
+
*
|
|
20
|
+
* **Return contract (fix round 3):** `{cli, role, argv, cwd, forbiddenRendered?}` — never a
|
|
21
|
+
* `stdinFile`. Unlike Claude/Codex closed-book runs, Grok's `--prompt-file <PATH>` ("Single-turn
|
|
22
|
+
* prompt from a file", pinned help) makes the CLI itself read the packet and send its CONTENT as
|
|
23
|
+
* the prompt, so even a closed-book run with every tool denied receives the packet text; the
|
|
24
|
+
* spawner pipes nothing to stdin.
|
|
25
|
+
*
|
|
26
|
+
* Effort is sent as the canonical `--reasoning-effort <EFFORT>` (the pinned help lists `--effort`
|
|
27
|
+
* only as an alias), so the flag the builder emits is the exact one `probe.mjs` checks (fix round 3,
|
|
28
|
+
* found by the widened all-roles probe-coverage test).
|
|
29
|
+
*
|
|
30
|
+
* **Fix round 1 (isolated per-file review):**
|
|
31
|
+
* - Unknown `role` now throws (was silently routed to closed-book) — `assertBaseParams`.
|
|
32
|
+
* - `cwd` is now required and validated, same as `model`/`promptPath` — `assertBaseParams`.
|
|
33
|
+
* - A coder-role render with 0 usable rules now throws (see `claude.mjs`'s identical guard).
|
|
34
|
+
* - `effort`, when given, must be a non-empty string (was pushed into argv unchecked).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { mergeForbidden, renderForGrok } from '../../util/forbidden.mjs';
|
|
38
|
+
import { flattenRuleStrings, hasAnyRenderedRule } from '../render-rules.mjs';
|
|
39
|
+
import { assertBaseParams } from './validate-params.mjs';
|
|
40
|
+
|
|
41
|
+
export const CLI = 'grok';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Built-in tool names the closed-book command denies wholesale via `--disallowed-tools` (in
|
|
45
|
+
* addition to the wildcard `--deny "*"`, per plan §5.2). Not independently verified against a
|
|
46
|
+
* machine-readable Grok tool list (none is documented in the pinned `--help`) — a reasonable,
|
|
47
|
+
* conservative superset, flagged in the facts diff.
|
|
48
|
+
* @type {ReadonlyArray<string>}
|
|
49
|
+
*/
|
|
50
|
+
export const GROK_ALL_BUILTIN_TOOLS = Object.freeze(['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebFetch', 'WebSearch']);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {{id: string, rules: ReadonlyArray<string>, enforced: boolean}} RenderedGrokEntry
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {object} GrokBuildParams
|
|
58
|
+
* @property {"coder"|"reviewer"|"judge"|"s2"|"author"|"facts"} role
|
|
59
|
+
* @property {string} model
|
|
60
|
+
* @property {string} [effort] - a non-empty string when given.
|
|
61
|
+
* @property {string} promptPath - `--prompt-file` value (brief for coder, packet for closed-book).
|
|
62
|
+
* @property {string} cwd
|
|
63
|
+
* @property {ReadonlyArray<RenderedGrokEntry>} [renderedForbidden] - coder role only; defaults to
|
|
64
|
+
* `renderForGrok(mergeForbidden())` (B4.2).
|
|
65
|
+
* @property {Record<string, any>} [schema] - closed-book only; sent inline like Claude's.
|
|
66
|
+
* @property {string} [systemPromptText] - closed-book only (`--system-prompt-override`).
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {unknown} effort
|
|
71
|
+
* @throws {TypeError} unless `effort` is `undefined` or a non-empty string.
|
|
72
|
+
*/
|
|
73
|
+
function assertValidEffort(effort) {
|
|
74
|
+
if (effort !== undefined && (typeof effort !== 'string' || effort.length === 0)) {
|
|
75
|
+
throw new TypeError(`buildGrokArgv: effort must be a non-empty string when given, got ${JSON.stringify(effort)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {GrokBuildParams} params
|
|
81
|
+
* @returns {{cli: "grok", role: "coder", argv: string[], cwd: string, forbiddenRendered: ReadonlyArray<RenderedGrokEntry>}}
|
|
82
|
+
*/
|
|
83
|
+
function buildCoderArgv(params) {
|
|
84
|
+
// B4.2: the coder's deny list is `mergeForbidden()` (FORBIDDEN + the coder-only entries). A
|
|
85
|
+
// `contains` entry renders 0 `--deny` rules; the pre-spawn check and the transcript grep keep it.
|
|
86
|
+
const { model, effort, promptPath, cwd, renderedForbidden = renderForGrok(mergeForbidden()) } = params;
|
|
87
|
+
assertValidEffort(effort);
|
|
88
|
+
if (!hasAnyRenderedRule(renderedForbidden)) {
|
|
89
|
+
throw new Error('buildGrokArgv: coder role requires a non-empty forbidden-list render (got 0 usable rules)');
|
|
90
|
+
}
|
|
91
|
+
const argv = ['grok', '--prompt-file', promptPath, '-m', model];
|
|
92
|
+
if (effort) argv.push('--reasoning-effort', effort);
|
|
93
|
+
argv.push('--permission-mode', 'bypassPermissions');
|
|
94
|
+
argv.push('--cwd', cwd);
|
|
95
|
+
for (const rule of flattenRuleStrings(renderedForbidden)) {
|
|
96
|
+
argv.push('--deny', rule);
|
|
97
|
+
}
|
|
98
|
+
return { cli: CLI, role: 'coder', argv, cwd, forbiddenRendered: renderedForbidden };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {GrokBuildParams} params
|
|
103
|
+
* @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts", argv: string[], cwd: string}}
|
|
104
|
+
*/
|
|
105
|
+
function buildClosedBookArgv(params) {
|
|
106
|
+
const { model, effort, promptPath, cwd, schema, systemPromptText } = params;
|
|
107
|
+
assertValidEffort(effort);
|
|
108
|
+
// See `builders/claude.mjs`'s identical cast: the dispatch below never reaches this branch
|
|
109
|
+
// with role 'coder', but that is a runtime invariant, not provable from the parameter type.
|
|
110
|
+
const role = /** @type {"reviewer"|"judge"|"s2"|"author"|"facts"} */ (params.role);
|
|
111
|
+
const argv = ['grok', '--prompt-file', promptPath, '-m', model];
|
|
112
|
+
if (effort) argv.push('--reasoning-effort', effort);
|
|
113
|
+
if (schema !== undefined) argv.push('--json-schema', JSON.stringify(schema));
|
|
114
|
+
argv.push('--disallowed-tools', GROK_ALL_BUILTIN_TOOLS.join(','));
|
|
115
|
+
argv.push('--deny', '*');
|
|
116
|
+
argv.push('--no-plan');
|
|
117
|
+
argv.push('--no-subagents');
|
|
118
|
+
argv.push('--max-turns', '1');
|
|
119
|
+
argv.push('--permission-mode', 'dontAsk');
|
|
120
|
+
argv.push('--cwd', cwd);
|
|
121
|
+
if (systemPromptText !== undefined) argv.push('--system-prompt-override', systemPromptText);
|
|
122
|
+
return { cli: CLI, role, argv, cwd };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {GrokBuildParams} params
|
|
127
|
+
* @returns {ReturnType<typeof buildCoderArgv> | ReturnType<typeof buildClosedBookArgv>}
|
|
128
|
+
*/
|
|
129
|
+
export function buildGrokArgv(params) {
|
|
130
|
+
assertBaseParams('buildGrokArgv', params);
|
|
131
|
+
return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
|
|
132
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic per-provider argv-builder dispatch (plan §5.2). A later block (B9's `spawn.mjs`) calls
|
|
3
|
+
* `buildArgv({provider, ...})` rather than importing all three provider builders and branching
|
|
4
|
+
* itself.
|
|
5
|
+
*
|
|
6
|
+
* Every builder returns `{cli, role, argv, cwd, stdinFile?, outPath?, ...}` (fix round 3). The
|
|
7
|
+
* spawner MUST honour two fields: when `stdinFile` is set, pipe that file's CONTENT to the child's
|
|
8
|
+
* stdin (Claude and Codex closed-book roles — their argv carries no prompt pointer); when
|
|
9
|
+
* `outPath` is set (Codex, always), read the result from it and delete it afterwards.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { buildClaudeArgv } from './claude.mjs';
|
|
13
|
+
import { buildCodexArgv } from './codex.mjs';
|
|
14
|
+
import { buildGrokArgv } from './grok.mjs';
|
|
15
|
+
|
|
16
|
+
export { buildClaudeArgv } from './claude.mjs';
|
|
17
|
+
export { buildCodexArgv } from './codex.mjs';
|
|
18
|
+
export { buildGrokArgv } from './grok.mjs';
|
|
19
|
+
|
|
20
|
+
/** @type {Readonly<Record<"anthropic"|"openai"|"xai", (params: any) => any>>} */
|
|
21
|
+
const BUILDERS = Object.freeze({ anthropic: buildClaudeArgv, openai: buildCodexArgv, xai: buildGrokArgv });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{provider: "anthropic"|"openai"|"xai"} & Record<string, any>} params
|
|
25
|
+
* @returns {any} whichever provider builder's return shape (`{cli, role, argv, cwd, stdinFile?, outPath?, ...}`).
|
|
26
|
+
*/
|
|
27
|
+
export function buildArgv(params) {
|
|
28
|
+
if (!params || !Object.hasOwn(BUILDERS, params.provider ?? '')) {
|
|
29
|
+
// Fix round 1 (MINOR): the message is now built from BUILDERS itself — the same object the
|
|
30
|
+
// check above uses — so it can never name a provider the check doesn't actually accept (the
|
|
31
|
+
// old version imported a SEPARATE `PROVIDER_CLI_NAMES` map for the message, which could drift).
|
|
32
|
+
throw new TypeError(`buildArgv: unknown provider "${params?.provider}" (expected one of ${Object.keys(BUILDERS).join(', ')})`);
|
|
33
|
+
}
|
|
34
|
+
return BUILDERS[/** @type {"anthropic"|"openai"|"xai"} */ (params.provider)](params);
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parameter validation for the three provider builders (fix round 1, findings #2 MAJOR ×2
|
|
3
|
+
* + cwd MAJOR). Written once so `claude.mjs`/`codex.mjs`/`grok.mjs` cannot drift from each other on
|
|
4
|
+
* what counts as a valid `role`/`model`/`promptPath`/`cwd` — the review found `codex.mjs` and
|
|
5
|
+
* `grok.mjs` silently routed ANY non-'coder' string (a typo, `undefined`, a stray level name like
|
|
6
|
+
* `'L2'`) into the closed-book branch instead of refusing it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The six roles the plan's engine adapters section (§5.2) declares.
|
|
11
|
+
* @type {ReadonlyArray<"coder"|"reviewer"|"judge"|"s2"|"author"|"facts">}
|
|
12
|
+
*/
|
|
13
|
+
export const VALID_ROLES = Object.freeze(/** @type {const} */ (['coder', 'reviewer', 'judge', 's2', 'author', 'facts']));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} builderName - for the thrown message, e.g. `"buildClaudeArgv"`.
|
|
17
|
+
* @param {unknown} params
|
|
18
|
+
* @throws {TypeError} for a missing/invalid `role`, `model`, `promptPath` or `cwd`.
|
|
19
|
+
*/
|
|
20
|
+
export function assertBaseParams(builderName, params) {
|
|
21
|
+
const p = /** @type {Record<string, unknown> | null | undefined} */ (params);
|
|
22
|
+
if (!p || typeof p !== 'object' || !VALID_ROLES.includes(/** @type {any} */ (p.role))) {
|
|
23
|
+
throw new TypeError(`${builderName}: params.role must be one of ${VALID_ROLES.join(', ')}, got ${JSON.stringify(p?.role)}`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof p.model !== 'string' || p.model.length === 0) {
|
|
26
|
+
throw new TypeError(`${builderName}: params.model is required (non-empty string)`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof p.promptPath !== 'string' || p.promptPath.length === 0) {
|
|
29
|
+
throw new TypeError(`${builderName}: params.promptPath is required (non-empty string)`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof p.cwd !== 'string' || p.cwd.length === 0) {
|
|
32
|
+
throw new TypeError(`${builderName}: params.cwd is required (non-empty string)`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session `CODEX_HOME` for the Codex coder (block B4.1, plan §5.2 Codex row, §8.4, [A19]).
|
|
3
|
+
*
|
|
4
|
+
* Facts (Codex 0.155.1, read-only checks on this Mac):
|
|
5
|
+
* - `codex exec --help`: `--ignore-rules Do not load user or project execpolicy \`.rules\` files`
|
|
6
|
+
* — `exec` loads execpolicy rules, but has NO flag naming a rules file, and `ConfigToml` has no
|
|
7
|
+
* rules-path key, so `-c` cannot name one either.
|
|
8
|
+
* - The "user" rules are `$CODEX_HOME/rules/*.rules` (`~/.codex/rules/default.rules` on this Mac;
|
|
9
|
+
* the binary logs "loaded … .rules files in …"). `CODEX_HOME` is therefore the one per-run
|
|
10
|
+
* handle: the coder runs with `CODEX_HOME=<run temp root>/…/codex-home`, whose
|
|
11
|
+
* `rules/code-forge.rules` holds the rendered forbidden list. The user's real `~/.codex` (and
|
|
12
|
+
* its own `rules/`) is never written.
|
|
13
|
+
* - The file format is execpolicy Starlark, one `prefix_rule(pattern=[…], decision="forbidden",
|
|
14
|
+
* justification="…")` per token-array prefix (`codex execpolicy check --rules <PATH> <COMMAND>…`
|
|
15
|
+
* parses the same files; the doctor/facts evidence ran it against a rendered file).
|
|
16
|
+
* - `--ignore-user-config`'s help says "auth still uses `CODEX_HOME`", so a fresh home has no
|
|
17
|
+
* login. The real home's `auth.json` is COPIED in (0600; the bytes pass through one Buffer that
|
|
18
|
+
* is never decoded, logged or kept), never symlinked: a symlink would let a write inside the
|
|
19
|
+
* session home reach the real file. Both files the session home receives (the rules file and
|
|
20
|
+
* the auth copy) are written with `writeFreshFile`: the destination is `lstat`ed and refused
|
|
21
|
+
* when ANYTHING already exists there (a planted symlink included), then created with
|
|
22
|
+
* `O_CREAT|O_EXCL|O_NOFOLLOW`, so a token can never be written through a link (L3 patch).
|
|
23
|
+
* Tradeoff: a token refresh inside the session is NOT written back to the real
|
|
24
|
+
* `auth.json`; if Codex rotates the refresh token, the user's own Codex may have to log in
|
|
25
|
+
* again. With no `auth.json` present, Codex uses its env auth (`CODEX_API_KEY` /
|
|
26
|
+
* `OPENAI_API_KEY`). The user's Codex TOML config is NOT linked: the coder argv carries every
|
|
27
|
+
* setting it needs, and the user's own allow-rules stay out of a coder session.
|
|
28
|
+
*
|
|
29
|
+
* INVARIANT (fix round 1): the coder's sandbox can never write the session home. The home lives
|
|
30
|
+
* under the run temp root; the builder runs the coder with `-s workspace-write` PLUS
|
|
31
|
+
* `-c sandbox_workspace_write.exclude_tmpdir_env_var=true` and
|
|
32
|
+
* `-c sandbox_workspace_write.exclude_slash_tmp=true` (both keys are in the 0.155.1 binary), so the
|
|
33
|
+
* only writable root is the project `-C <cwd>` — and a home inside that cwd is refused. On top, the
|
|
34
|
+
* rules file is 0444. The directories stay 0700, not 0555: a 0555 directory makes the run-root
|
|
35
|
+
* sweep's `rmSync(…, {recursive, force})` fail (ENOTEMPTY, checked on this Mac), which would leave
|
|
36
|
+
* the whole run root — the auth copy included — behind. Tradeoff of the tmp exclusion: the coder's
|
|
37
|
+
* own commands cannot write `$TMPDIR` or `/tmp` either.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { randomBytes } from 'node:crypto';
|
|
41
|
+
import { closeSync, constants, existsSync, fchmodSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
|
|
42
|
+
import os from 'node:os';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { currentRunRoot } from '../util/tmp.mjs';
|
|
45
|
+
|
|
46
|
+
/** The rules file name inside `<codexHome>/rules/`. Codex loads every `*.rules` file there. */
|
|
47
|
+
export const CODEX_RULES_FILE_NAME = 'code-forge.rules';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {{id: string, patterns: ReadonlyArray<ReadonlyArray<string>>, enforced: boolean, description?: string}} CodexRenderedEntry
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The user's real Codex home: `$CODEX_HOME` when set, else `$HOME/.codex` (`os.homedir()` without `HOME`).
|
|
55
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
56
|
+
* @returns {string} an absolute path.
|
|
57
|
+
*/
|
|
58
|
+
export function realCodexHome(env = process.env) {
|
|
59
|
+
const fromEnv = env.CODEX_HOME;
|
|
60
|
+
const home = typeof env.HOME === 'string' && env.HOME.length > 0 ? env.HOME : os.homedir();
|
|
61
|
+
return path.resolve(typeof fromEnv === 'string' && fromEnv.length > 0 ? fromEnv : path.join(home, '.codex'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {string} p @returns {string} the realpath of the longest existing ancestor + the rest. */
|
|
65
|
+
function resolveExisting(p) {
|
|
66
|
+
let head = path.resolve(p);
|
|
67
|
+
const tail = [];
|
|
68
|
+
while (!existsSync(head)) {
|
|
69
|
+
const parent = path.dirname(head);
|
|
70
|
+
if (parent === head) break;
|
|
71
|
+
tail.unshift(path.basename(head));
|
|
72
|
+
head = parent;
|
|
73
|
+
}
|
|
74
|
+
return path.join(realpathSync(head), ...tail);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether `candidate` is `dir` or inside it (both sides symlink-resolved).
|
|
79
|
+
* @param {string} candidate @param {string} dir @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function isInside(candidate, dir) {
|
|
82
|
+
const rel = path.relative(resolveExisting(dir), resolveExisting(candidate));
|
|
83
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether `candidate` is the real Codex home or inside it (both sides symlink-resolved).
|
|
88
|
+
* @param {string} candidate @param {NodeJS.ProcessEnv} [env] @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function isInsideRealCodexHome(candidate, env = process.env) {
|
|
91
|
+
return isInside(candidate, realCodexHome(env));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Execpolicy Starlark for a `renderForCodex` render: one `prefix_rule` per pattern, in entry
|
|
96
|
+
* order. `JSON.stringify` of a string is a valid Starlark string literal for these ASCII tokens.
|
|
97
|
+
* @param {ReadonlyArray<CodexRenderedEntry>} rendered
|
|
98
|
+
* @returns {{content: string, count: number}}
|
|
99
|
+
*/
|
|
100
|
+
export function renderCodexRules(rendered) {
|
|
101
|
+
const lines = ['# code-forge forbidden list (src/util/forbidden.mjs). Generated per session; do not edit.'];
|
|
102
|
+
let count = 0;
|
|
103
|
+
for (const entry of rendered) {
|
|
104
|
+
for (const pattern of entry.patterns) {
|
|
105
|
+
const tokens = pattern.map((t) => JSON.stringify(String(t))).join(', ');
|
|
106
|
+
lines.push(`prefix_rule(pattern=[${tokens}], decision="forbidden", justification=${JSON.stringify(`code-forge: ${entry.id}`)})`);
|
|
107
|
+
count += 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { content: `${lines.join('\n')}\n`, count };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The name of the run-root directory every session home lives in. */
|
|
114
|
+
export const CODEX_HOMES_DIR_NAME = 'codex-homes';
|
|
115
|
+
|
|
116
|
+
/** @returns {string} `<currentRunRoot()>/codex-homes`, the one parent of every session home. */
|
|
117
|
+
export function sessionCodexHomesDir() {
|
|
118
|
+
return path.join(currentRunRoot(), CODEX_HOMES_DIR_NAME);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether `candidate` is STRICTLY inside `<currentRunRoot()>/codex-homes/` (both sides
|
|
123
|
+
* symlink-resolved through their nearest existing ancestor). The directory itself, the run root,
|
|
124
|
+
* the real Codex home, `$HOME` and any sibling are all outside.
|
|
125
|
+
* @param {string} candidate @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
export function isSessionCodexHome(candidate) {
|
|
128
|
+
if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return false;
|
|
129
|
+
const rel = path.relative(resolveExisting(sessionCodexHomesDir()), resolveExisting(candidate));
|
|
130
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A fresh per-session Codex home path under the run temp root.
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function defaultCodexHome() {
|
|
138
|
+
return path.join(sessionCodexHomesDir(), `coder-${randomBytes(6).toString('hex')}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create `<codexHome>/rules/code-forge.rules` (dirs 0700, file 0444) and copy the real home's
|
|
143
|
+
* `auth.json` (0600) when it exists.
|
|
144
|
+
* @param {string} codexHome - absolute; never the real Codex home or inside it.
|
|
145
|
+
* @param {ReadonlyArray<CodexRenderedEntry>} rendered
|
|
146
|
+
* @param {{env?: NodeJS.ProcessEnv}} [opts]
|
|
147
|
+
* @returns {{codexHome: string, rulesPath: string, count: number, content: string}}
|
|
148
|
+
* @throws {Error} when `codexHome` is relative, is (or is inside) the real Codex home, or the
|
|
149
|
+
* render yields 0 rules.
|
|
150
|
+
*/
|
|
151
|
+
export function prepareCodexHome(codexHome, rendered, opts = {}) {
|
|
152
|
+
const env = opts.env ?? process.env;
|
|
153
|
+
if (typeof codexHome !== 'string' || !path.isAbsolute(codexHome)) {
|
|
154
|
+
throw new TypeError('prepareCodexHome: codexHome must be an absolute path');
|
|
155
|
+
}
|
|
156
|
+
if (isInsideRealCodexHome(codexHome, env)) {
|
|
157
|
+
throw new Error('prepareCodexHome: codexHome must not be the real Codex home or inside it');
|
|
158
|
+
}
|
|
159
|
+
// Fix round 4: an ANCESTOR of the real home (`$HOME`, `/`) is not "inside" it, yet a home there
|
|
160
|
+
// would put `rules/` and an auth copy where the user lives. The only place a session home may be
|
|
161
|
+
// is strictly inside `<run root>/codex-homes/` — the same confinement `removeCodexHome` enforces.
|
|
162
|
+
if (!isSessionCodexHome(codexHome)) {
|
|
163
|
+
throw new Error(`prepareCodexHome: codexHome must be strictly inside <run root>/${CODEX_HOMES_DIR_NAME}/`);
|
|
164
|
+
}
|
|
165
|
+
const { content, count } = renderCodexRules(rendered);
|
|
166
|
+
if (count === 0) throw new Error('prepareCodexHome: the forbidden-list render has 0 rules');
|
|
167
|
+
const rulesDir = path.join(codexHome, 'rules');
|
|
168
|
+
const rulesPath = path.join(rulesDir, CODEX_RULES_FILE_NAME);
|
|
169
|
+
const auth = path.join(realCodexHome(env), 'auth.json');
|
|
170
|
+
const copy = path.join(codexHome, 'auth.json');
|
|
171
|
+
// L3 patch: both destinations are checked BEFORE anything is written, so a planted entry (a
|
|
172
|
+
// symlink at either path) makes this throw with the home left exactly as found.
|
|
173
|
+
refuseExisting(rulesPath);
|
|
174
|
+
refuseExisting(copy);
|
|
175
|
+
mkdirSync(rulesDir, { recursive: true, mode: 0o700 });
|
|
176
|
+
if (lstatSync(rulesDir).isSymbolicLink()) throw new Error('prepareCodexHome: refusing a symlinked rules directory');
|
|
177
|
+
writeFreshFile(rulesPath, Buffer.from(content, 'utf8'), 0o444);
|
|
178
|
+
if (existsSync(auth)) {
|
|
179
|
+
writeFreshFile(copy, readFileSync(auth), 0o600);
|
|
180
|
+
}
|
|
181
|
+
return { codexHome, rulesPath, count, content };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Throw when anything at all (file, directory, symlink — dangling or not) already exists at `dest`.
|
|
186
|
+
* @param {string} dest
|
|
187
|
+
*/
|
|
188
|
+
function refuseExisting(dest) {
|
|
189
|
+
try {
|
|
190
|
+
lstatSync(dest);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`prepareCodexHome: refusing to write ${path.basename(dest)}: something already exists at the destination`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create `dest` exclusively, never following a link (`O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW`, mode
|
|
200
|
+
* 0600), write `bytes`, set the final `mode` on the open descriptor, close. A path that gained an
|
|
201
|
+
* entry between `refuseExisting` and this open fails on `O_EXCL` — nothing is ever written through it.
|
|
202
|
+
* @param {string} dest @param {Buffer} bytes @param {number} mode
|
|
203
|
+
*/
|
|
204
|
+
function writeFreshFile(dest, bytes, mode) {
|
|
205
|
+
const { O_WRONLY, O_CREAT, O_EXCL, O_NOFOLLOW } = constants;
|
|
206
|
+
const fd = openSync(dest, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600);
|
|
207
|
+
try {
|
|
208
|
+
let offset = 0;
|
|
209
|
+
while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset);
|
|
210
|
+
fchmodSync(fd, mode);
|
|
211
|
+
} finally {
|
|
212
|
+
closeSync(fd);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Remove a session home (the auth copy with it). The ONLY recursive delete of this module, and it
|
|
218
|
+
* is confined (fix round 3): `codexHome` must be strictly inside `<currentRunRoot()>/codex-homes/`
|
|
219
|
+
* after realpath resolution, or this throws and deletes nothing — the real `~/.codex`, `$HOME`,
|
|
220
|
+
* the run root itself and any sibling directory are refused. No-op when the (confined) path does
|
|
221
|
+
* not exist.
|
|
222
|
+
* @param {string} codexHome
|
|
223
|
+
* @throws {Error} when `codexHome` is not strictly inside the run root's `codex-homes/`.
|
|
224
|
+
*/
|
|
225
|
+
export function removeCodexHome(codexHome) {
|
|
226
|
+
if (!isSessionCodexHome(codexHome)) {
|
|
227
|
+
throw new Error(`removeCodexHome: refusing to delete a path outside <run root>/${CODEX_HOMES_DIR_NAME}/`);
|
|
228
|
+
}
|
|
229
|
+
rmSync(resolveExisting(codexHome), { recursive: true, force: true });
|
|
230
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The empty-cwd temp dir every closed-book session (reviewer, judge, S2, author, facts) runs in
|
|
3
|
+
* (plan §4.3, §5.2). A fresh `os.tmpdir()`-rooted directory holding EXACTLY the packet/brief file
|
|
4
|
+
* a caller writes into it — no project source, no `CLAUDE.md`/`AGENTS.md`, nothing a closed-book
|
|
5
|
+
* session could read besides what it was explicitly handed. `doctor`'s isolation probe (B13, §2.3)
|
|
6
|
+
* asks a closed-book session to echo the first line of the project's `CLAUDE.md` — this module is
|
|
7
|
+
* why that should come back empty: the session's cwd never contained one.
|
|
8
|
+
*
|
|
9
|
+
* **Fix round 1 (isolated per-file review):**
|
|
10
|
+
* - `fileName` now also refuses `.` and `..` (previously only a `/`/`\` check — `path.join(dir,
|
|
11
|
+
* '..')` points OUTSIDE the fresh dir; the old code didn't escape, since `writeFile` on a
|
|
12
|
+
* directory path fails with `EISDIR`, but the caller got a vague filesystem error instead of
|
|
13
|
+
* the documented `TypeError`).
|
|
14
|
+
* - A `writeFile` failure (bad content type, disk full, …) after `mkdtemp` no longer leaks an
|
|
15
|
+
* empty `code-forge-isolated-*` directory: the dir is removed before rethrowing.
|
|
16
|
+
* - `cleanupIsolatedWorkspace` now also requires `dir`'s PARENT to be the temp root (or the
|
|
17
|
+
* `baseDir` the caller passes, matching `createIsolatedWorkspace`'s own override) — the old
|
|
18
|
+
* basename-prefix-only check would recursively delete ANY directory anywhere on disk that
|
|
19
|
+
* happened to be named `code-forge-isolated-*`, not only ones this module actually created.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
/** Directory name prefix for every isolated workspace this module creates. */
|
|
27
|
+
const DIR_PREFIX = 'code-forge-isolated-';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} fileName - a plain basename, e.g. `"packet.json"` — never a path (a caller
|
|
31
|
+
* trying to escape the fresh temp dir is a bug in the caller, not something to silently "fix").
|
|
32
|
+
* @param {string} content
|
|
33
|
+
* @param {{baseDir?: string}} [opts] - `baseDir` overrides `os.tmpdir()` (tests only).
|
|
34
|
+
* @returns {Promise<{dir: string, filePath: string}>}
|
|
35
|
+
* @throws {TypeError} if `fileName` contains a path separator, is empty, or is `.`/`..`.
|
|
36
|
+
*/
|
|
37
|
+
export async function createIsolatedWorkspace(fileName, content, opts = {}) {
|
|
38
|
+
if (
|
|
39
|
+
typeof fileName !== 'string' ||
|
|
40
|
+
fileName.length === 0 ||
|
|
41
|
+
fileName.includes('/') ||
|
|
42
|
+
fileName.includes('\\') ||
|
|
43
|
+
fileName === '.' ||
|
|
44
|
+
fileName === '..'
|
|
45
|
+
) {
|
|
46
|
+
throw new TypeError(`createIsolatedWorkspace: fileName must be a plain basename, got ${JSON.stringify(fileName)}`);
|
|
47
|
+
}
|
|
48
|
+
const dir = await mkdtemp(path.join(opts.baseDir ?? os.tmpdir(), DIR_PREFIX));
|
|
49
|
+
const filePath = path.join(dir, fileName);
|
|
50
|
+
try {
|
|
51
|
+
await writeFile(filePath, content, 'utf8');
|
|
52
|
+
} catch (err) {
|
|
53
|
+
await rm(dir, { recursive: true, force: true });
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
return { dir, filePath };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} dir
|
|
61
|
+
* @returns {Promise<number>} how many entries `dir` currently holds (doctor / tests use this to
|
|
62
|
+
* prove isolation: a freshly created workspace must list exactly 1).
|
|
63
|
+
*/
|
|
64
|
+
export async function countWorkspaceEntries(dir) {
|
|
65
|
+
const entries = await readdir(dir);
|
|
66
|
+
return entries.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} dir - MUST be one this module created: its basename must start with
|
|
71
|
+
* {@link DIR_PREFIX} AND its parent directory must be `baseDir` (default `os.tmpdir()`).
|
|
72
|
+
* @param {{baseDir?: string}} [opts] - MUST match whatever `baseDir` `createIsolatedWorkspace` was
|
|
73
|
+
* called with, when it was overridden.
|
|
74
|
+
* @returns {Promise<void>}
|
|
75
|
+
* @throws {TypeError} if `dir` does not satisfy both checks above.
|
|
76
|
+
*/
|
|
77
|
+
export async function cleanupIsolatedWorkspace(dir, opts = {}) {
|
|
78
|
+
const expectedParent = path.resolve(opts.baseDir ?? os.tmpdir());
|
|
79
|
+
const actualParent = path.dirname(path.resolve(dir));
|
|
80
|
+
if (!path.basename(dir).startsWith(DIR_PREFIX) || actualParent !== expectedParent) {
|
|
81
|
+
throw new TypeError(`cleanupIsolatedWorkspace: refusing to remove a directory this module did not create: ${dir}`);
|
|
82
|
+
}
|
|
83
|
+
await rm(dir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flag probe (plan §5.2, §2.3): the literal flags every builder in `builders/{claude,codex,
|
|
3
|
+
* grok}.mjs` depends on, checked as literal substrings of a `--help` text, WITH a word-boundary
|
|
4
|
+
* check so a shorter required flag can never be satisfied by a longer one that merely starts with
|
|
5
|
+
* it. No pinned CLI documents a machine-readable `--help` format, so this is a substring probe,
|
|
6
|
+
* not a parser — the same standard B0's own header check used ("the Claude fixture contains
|
|
7
|
+
* `--safe-mode` and does not contain `--max-turns`", plan header, B0 acceptance (7)).
|
|
8
|
+
*
|
|
9
|
+
* `doctor` (B13, §2.3) re-runs this probe against the REAL installed CLI's live `--help` output at
|
|
10
|
+
* runtime — this module is what it calls; this block only proves the probe itself works, against
|
|
11
|
+
* the pinned fixtures (which must all pass) and against a deliberately mutilated copy of one
|
|
12
|
+
* (which must fail, naming exactly the flag that was removed).
|
|
13
|
+
*
|
|
14
|
+
* **Fix round 1 (isolated per-file review, MINOR ×2):**
|
|
15
|
+
* - `REQUIRED_FLAGS[provider]` is now `Object.hasOwn`-guarded — `probeHelpText('constructor', …)`
|
|
16
|
+
* used to fall through to `Object.prototype.constructor` (truthy) instead of the documented
|
|
17
|
+
* "unknown provider" `Error`, then crash on `.filter` not being a function.
|
|
18
|
+
* - A match is only counted when the character immediately before and after it is NOT part of an
|
|
19
|
+
* identifier or another flag token (`[A-Za-z0-9-]`) — previously `--json` was satisfied by the
|
|
20
|
+
* unrelated, LONGER `--json-schema`/`--jsonl`, so removing the real `--json` flag while a longer
|
|
21
|
+
* one remained went undetected.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The flags each builder actually emits, one literal phrase per flag as it appears in the pinned
|
|
26
|
+
* `--help` fixture (the exact spelling `probeHelpText` checks with a boundary-aware substring
|
|
27
|
+
* search).
|
|
28
|
+
* @type {Readonly<Record<"claude"|"codex"|"grok", ReadonlyArray<string>>>}
|
|
29
|
+
*/
|
|
30
|
+
export const REQUIRED_FLAGS = Object.freeze({
|
|
31
|
+
claude: Object.freeze([
|
|
32
|
+
'-p, --print',
|
|
33
|
+
'--model <model>',
|
|
34
|
+
'--effort <level>',
|
|
35
|
+
'--permission-mode <mode>',
|
|
36
|
+
'--output-format <format>',
|
|
37
|
+
'--no-session-persistence',
|
|
38
|
+
'--max-budget-usd <amount>',
|
|
39
|
+
'--disallowedTools, --disallowed-tools <tools...>',
|
|
40
|
+
'--fallback-model <model>',
|
|
41
|
+
'--safe-mode',
|
|
42
|
+
'--tools <tools...>',
|
|
43
|
+
'--strict-mcp-config',
|
|
44
|
+
'--json-schema <schema>',
|
|
45
|
+
'--system-prompt <prompt>',
|
|
46
|
+
'--restricted',
|
|
47
|
+
]),
|
|
48
|
+
codex: Object.freeze([
|
|
49
|
+
'-m, --model <MODEL>',
|
|
50
|
+
'-c, --config <key=value>',
|
|
51
|
+
'-s, --sandbox <SANDBOX_MODE>',
|
|
52
|
+
'-C, --cd <DIR>',
|
|
53
|
+
'--json',
|
|
54
|
+
'-o, --output-last-message <FILE>',
|
|
55
|
+
'--ephemeral',
|
|
56
|
+
'--ignore-rules',
|
|
57
|
+
'--ignore-user-config',
|
|
58
|
+
'--skip-git-repo-check',
|
|
59
|
+
'--output-schema <FILE>',
|
|
60
|
+
]),
|
|
61
|
+
grok: Object.freeze([
|
|
62
|
+
'--prompt-file <PATH>',
|
|
63
|
+
'-m, --model <MODEL>',
|
|
64
|
+
'--reasoning-effort <EFFORT>',
|
|
65
|
+
'--permission-mode <MODE>',
|
|
66
|
+
'--cwd <CWD>',
|
|
67
|
+
'--deny <RULE>',
|
|
68
|
+
'--json-schema <SCHEMA>',
|
|
69
|
+
'--disallowed-tools <TOOLS>',
|
|
70
|
+
'--no-plan',
|
|
71
|
+
'--no-subagents',
|
|
72
|
+
'--max-turns <N>',
|
|
73
|
+
'--system-prompt-override <PROMPT>',
|
|
74
|
+
]),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** @typedef {{ok: boolean, missing: string[]}} ProbeResult */
|
|
78
|
+
|
|
79
|
+
/** A char that would extend an identifier/flag token if it sat right next to a match. */
|
|
80
|
+
const WORD_CHAR_RE = /[A-Za-z0-9-]/;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} haystack
|
|
84
|
+
* @param {string} needle
|
|
85
|
+
* @returns {boolean} true only if `needle` occurs in `haystack` with a non-word character (or
|
|
86
|
+
* string start/end) on BOTH sides — so `--json` does not match inside `--json-schema`.
|
|
87
|
+
*/
|
|
88
|
+
function containsAsWholeToken(haystack, needle) {
|
|
89
|
+
let fromIndex = 0;
|
|
90
|
+
for (;;) {
|
|
91
|
+
const at = haystack.indexOf(needle, fromIndex);
|
|
92
|
+
if (at === -1) return false;
|
|
93
|
+
const before = at > 0 ? haystack[at - 1] : '';
|
|
94
|
+
const after = at + needle.length < haystack.length ? haystack[at + needle.length] : '';
|
|
95
|
+
if (!WORD_CHAR_RE.test(before) && !WORD_CHAR_RE.test(after)) return true;
|
|
96
|
+
fromIndex = at + 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {"claude"|"codex"|"grok"} provider
|
|
102
|
+
* @param {string} helpText - the CLI's own `--help` output (or a pinned fixture's contents).
|
|
103
|
+
* @returns {ProbeResult}
|
|
104
|
+
* @throws {Error} for an unknown provider.
|
|
105
|
+
*/
|
|
106
|
+
export function probeHelpText(provider, helpText) {
|
|
107
|
+
if (typeof provider !== 'string' || !Object.hasOwn(REQUIRED_FLAGS, provider)) {
|
|
108
|
+
throw new Error(`probeHelpText: unknown provider "${provider}"`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof helpText !== 'string') {
|
|
111
|
+
throw new TypeError('probeHelpText: helpText must be a string');
|
|
112
|
+
}
|
|
113
|
+
const required = REQUIRED_FLAGS[/** @type {"claude"|"codex"|"grok"} */ (provider)];
|
|
114
|
+
const missing = required.filter((flag) => !containsAsWholeToken(helpText, flag));
|
|
115
|
+
return { ok: missing.length === 0, missing };
|
|
116
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that maps a config `provider` to the CLI binary `engine: subprocess` spawns for
|
|
3
|
+
* it (plan §5.2). `src/cli/validate.mjs` (B1) keeps its own private copy for `hasCliOnPath` — this
|
|
4
|
+
* module is the shared, exported version every `src/engines/**` builder and `src/cli/resolve.mjs`
|
|
5
|
+
* use, so the mapping is written once on this side of the seam (B1 landed first; B4 does not edit
|
|
6
|
+
* B1's file to add an export it didn't already have).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @typedef {"anthropic" | "openai" | "xai"} Provider */
|
|
10
|
+
|
|
11
|
+
/** @type {Readonly<Record<Provider, string>>} */
|
|
12
|
+
export const PROVIDER_CLI_NAMES = Object.freeze({ anthropic: 'claude', openai: 'codex', xai: 'grok' });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `Object.hasOwn`-guarded (see `known-ids.mjs`'s `knownIdsForProvider` for why a bare index lookup
|
|
16
|
+
* is unsafe for a string that might be `"constructor"` or another `Object.prototype` member).
|
|
17
|
+
* @param {string} provider
|
|
18
|
+
* @returns {string | undefined} the CLI binary name, or `undefined` for an unknown provider.
|
|
19
|
+
*/
|
|
20
|
+
export function cliNameForProvider(provider) {
|
|
21
|
+
return typeof provider === 'string' && Object.hasOwn(PROVIDER_CLI_NAMES, provider)
|
|
22
|
+
? PROVIDER_CLI_NAMES[/** @type {Provider} */ (provider)]
|
|
23
|
+
: undefined;
|
|
24
|
+
}
|