@codedology/code-forge 0.15.1 → 0.15.3
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 +62 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +154 -1
- package/docs/reference/config.md +27 -14
- package/package.json +1 -1
- package/schema/code-forge.schema.json +57 -2
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/code.md +15 -0
- package/skill/references/ledger.md +13 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +146 -30
- package/src/cli/context.mjs +17 -7
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +4 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +168 -38
- package/src/cli/run.mjs +3 -2
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/schema-compile.mjs +6 -4
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/local.mjs +22 -4
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +18 -4
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +39 -1
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/packet.mjs +34 -6
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +4 -3
- package/src/review/session-retry.mjs +59 -0
- package/src/session/facts.mjs +18 -4
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +101 -34
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +67 -14
- package/src/worker/queue.mjs +106 -23
- package/src/worker/watch.mjs +2 -2
package/src/context/pack.mjs
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* require_coder_tests: false B63b: close needs the coder's `TESTS … exit=0` line
|
|
12
12
|
*
|
|
13
13
|
* `assemblePack` turns it into ONE pack for a block, in this order: the header (run, block, level,
|
|
14
|
-
* owned files, test files when the block has them, worktree path and branch when it has one
|
|
14
|
+
* owned files, test files when the block has them, worktree path and branch when it has one,
|
|
15
|
+
* code-forge command and version when supplied), the
|
|
15
16
|
* commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), the
|
|
16
|
-
* built-in proof rule, each rules file, then the lessons file — every file section headed by its
|
|
17
|
+
* built-in proof rule, block brief and acceptance, each rules file, then the lessons file — every file section headed by its
|
|
17
18
|
* source path (an empty file: the heading alone). Over `max_lines` the lessons are cut first, then the
|
|
18
19
|
* rules from the last file backwards, each cut section ending in `[cut: N lines dropped]`; the
|
|
19
|
-
* header, commands
|
|
20
|
+
* header, commands, proof rule, brief and acceptance are never cut; all count toward the budget.
|
|
20
21
|
*
|
|
21
22
|
* `reviewDigest` is the ONE builder of the review packets' rules digest (`## project rules`, ≤ 60
|
|
22
23
|
* lines, `packet.mjs`): the rules files' text, then a `### lessons` subheading and the lessons.
|
|
@@ -238,6 +239,41 @@ const argvToken = (token) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(token) ? token : `
|
|
|
238
239
|
/** @param {string[]} argv @returns {string} */
|
|
239
240
|
export const argvText = (argv) => argv.map(argvToken).join(' ');
|
|
240
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Read a block's saved brief from the main tree with the same path checks as other context.
|
|
244
|
+
* Older blocks and blocks opened without --brief have no brief.
|
|
245
|
+
* @param {string} repoRoot @param {Record<string, any>} block
|
|
246
|
+
* @returns {{path: string, text: string} | null}
|
|
247
|
+
*/
|
|
248
|
+
export function readBlockBrief(repoRoot, block) {
|
|
249
|
+
if (typeof block.brief?.path !== 'string') return null;
|
|
250
|
+
return { path: block.brief.path, text: readLines(repoRoot, block.brief.path, 'block.brief').join('\n') };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The task sections shared by packs and coder prompts. A supplied prompt suppresses sections
|
|
255
|
+
* whose contents it already carries, independently of their Markdown formatting.
|
|
256
|
+
* @param {{brief?: {path: string, text: string} | null, acceptance?: import('../gates/acceptance.mjs').AcceptanceClause[]}} block
|
|
257
|
+
* @param {string} [prompt]
|
|
258
|
+
* @returns {string[]}
|
|
259
|
+
*/
|
|
260
|
+
export function blockTaskLines(block, prompt = '') {
|
|
261
|
+
const normalized = prompt.replace(/\s+/g, ' ').trim();
|
|
262
|
+
const contains = (/** @type {string} */ text) => normalized.includes(text.replace(/\s+/g, ' ').trim());
|
|
263
|
+
const lines = [];
|
|
264
|
+
if (block.brief && (!prompt || !contains(block.brief.text))) lines.push(`## brief: ${block.brief.path}`, ...splitLines(block.brief.text));
|
|
265
|
+
const missing = (block.acceptance ?? []).filter((clause) => !prompt || !contains(clause.clause)
|
|
266
|
+
|| (clause.kind === 'human_check' ? !contains('human_check') : !clause.tests.every(contains)));
|
|
267
|
+
if (missing.length > 0) {
|
|
268
|
+
lines.push('## acceptance');
|
|
269
|
+
for (const clause of missing) {
|
|
270
|
+
lines.push(`- ${clause.clause}`);
|
|
271
|
+
lines.push(clause.kind === 'human_check' ? ' kind: human_check' : ` tests: ${clause.tests.join(', ')}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return lines.length === 0 ? [] : splitLines(/** @type {string} */ (redact(lines.join('\n'))));
|
|
275
|
+
}
|
|
276
|
+
|
|
241
277
|
/**
|
|
242
278
|
* @typedef {object} PackBlock
|
|
243
279
|
* @property {string} run
|
|
@@ -246,6 +282,9 @@ export const argvText = (argv) => argv.map(argvToken).join(' ');
|
|
|
246
282
|
* @property {string[]} [owned]
|
|
247
283
|
* @property {string[] | null} [tests]
|
|
248
284
|
* @property {{path: string, branch: string} | null} [worktree]
|
|
285
|
+
* @property {{node: string, bin: string, version: string} | null} [codeForge]
|
|
286
|
+
* @property {{path: string, text: string} | null} [brief]
|
|
287
|
+
* @property {import('../gates/acceptance.mjs').AcceptanceClause[]} [acceptance]
|
|
249
288
|
*/
|
|
250
289
|
|
|
251
290
|
/**
|
|
@@ -259,6 +298,7 @@ export function assemblePack({ settings, sources, block }) {
|
|
|
259
298
|
/** @type {Array<[string, unknown]>} */
|
|
260
299
|
const values = [['level', block.level], ...(block.owned ?? []).map((f) => /** @type {[string, unknown]} */ (['owned file', f])), ...(block.tests ?? []).map((f) => /** @type {[string, unknown]} */ (['test file', f]))];
|
|
261
300
|
if (block.worktree) values.push(['worktree path', block.worktree.path], ['worktree branch', block.worktree.branch]);
|
|
301
|
+
if (block.codeForge) values.push(['code-forge node', block.codeForge.node], ['code-forge bin', block.codeForge.bin], ['code-forge version', block.codeForge.version]);
|
|
262
302
|
for (const [label, v] of values) {
|
|
263
303
|
if (typeof v === 'string' && CONTROL.test(v)) throw new ContextError('control-character', `block ${block.block}: ${label} ${JSON.stringify(v)} contains a control character`);
|
|
264
304
|
}
|
|
@@ -269,6 +309,14 @@ export function assemblePack({ settings, sources, block }) {
|
|
|
269
309
|
for (const f of block.tests) header.push(`- ${f}`);
|
|
270
310
|
}
|
|
271
311
|
if (block.worktree) header.push(`worktree: ${block.worktree.path}`, `branch: ${block.worktree.branch}`);
|
|
312
|
+
if (block.codeForge) {
|
|
313
|
+
header.push(
|
|
314
|
+
'## code-forge',
|
|
315
|
+
`run code-forge verbs with exactly: ${argvText([block.codeForge.node, block.codeForge.bin])}`,
|
|
316
|
+
`version: ${block.codeForge.version}`,
|
|
317
|
+
'do not use another code-forge from PATH',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
272
320
|
if (settings !== null) {
|
|
273
321
|
header.push('## commands (never run by code-forge)');
|
|
274
322
|
const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
|
|
@@ -278,6 +326,8 @@ export function assemblePack({ settings, sources, block }) {
|
|
|
278
326
|
}
|
|
279
327
|
|
|
280
328
|
header.push(...splitLines(PROOF_RULE));
|
|
329
|
+
// The actual task is protected from the optional rules/lessons line budget too.
|
|
330
|
+
header.push(...blockTaskLines(block));
|
|
281
331
|
|
|
282
332
|
/** @type {Array<{heading: string, body: string[], dropped: number}>} */
|
|
283
333
|
const sections = sources.rules.map((r) => ({ heading: `## rules: ${r.path}`, body: [...r.lines], dropped: 0 }));
|
package/src/doctor/local.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
|
24
24
|
import { assertRunId, runsDir } from '../state/paths.mjs';
|
|
25
25
|
import { readRun } from '../state/run.mjs';
|
|
26
26
|
import { loadKey } from '../state/signer.mjs';
|
|
27
|
-
import { listEntries } from '../util/reaper.mjs';
|
|
27
|
+
import { listEntries, sweepProcesses } from '../util/reaper.mjs';
|
|
28
28
|
import { listBadLocks } from '../util/file-locks.mjs';
|
|
29
29
|
import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
|
|
30
30
|
import { parallelTickets } from '../worker/loop.mjs';
|
|
@@ -257,14 +257,15 @@ export const LISTED_MAX = 10;
|
|
|
257
257
|
* @returns {Promise<Row[]>}
|
|
258
258
|
*/
|
|
259
259
|
export async function checkReviewState(cfg) {
|
|
260
|
+
// This awaited local-check slot also supplies process hygiene and memory rows. All are
|
|
261
|
+
// observational; doctor never applies a sweep.
|
|
262
|
+
const rows = await checkSweep(cfg);
|
|
260
263
|
let names = [];
|
|
261
264
|
try {
|
|
262
265
|
names = readdirSync(runsDir()).filter((n) => n.endsWith('.json'));
|
|
263
266
|
} catch {
|
|
264
|
-
return
|
|
267
|
+
return rows;
|
|
265
268
|
}
|
|
266
|
-
/** @type {Row[]} */
|
|
267
|
-
const rows = [];
|
|
268
269
|
/** @type {Map<string, Array<Record<string, any>>>} the ledger rows per project, read once */
|
|
269
270
|
const ledgers = new Map();
|
|
270
271
|
for (const name of names.sort()) {
|
|
@@ -319,6 +320,23 @@ export async function checkReviewState(cfg) {
|
|
|
319
320
|
return rows;
|
|
320
321
|
}
|
|
321
322
|
|
|
323
|
+
/** Read-only process hygiene and RSS rows. @param {Record<string,any>|null} cfg @returns {Promise<Row[]>} */
|
|
324
|
+
export async function checkSweep(cfg) {
|
|
325
|
+
try {
|
|
326
|
+
const report = await sweepProcesses({ root: typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined });
|
|
327
|
+
const workers = report.items.filter((p) => p.kind === 'worker').length;
|
|
328
|
+
const orphans = report.items.filter((p) => p.kind === 'orphan').length;
|
|
329
|
+
const zombies = report.zombies.reduce((n, group) => n + group.count, 0);
|
|
330
|
+
const parents = report.zombies.map((group) => `parent ${group.ppid} ${group.command}`).join('; ');
|
|
331
|
+
const memory = report.memory;
|
|
332
|
+
return [row('sweep', workers + orphans + zombies ? 'WARN' : 'OK', 'sweep',
|
|
333
|
+
`${workers} stale workers, ${orphans} orphans, ${zombies} zombies${parents ? ` (${parents})` : ''}`),
|
|
334
|
+
row('memory', 'INFO', 'memory', `code-forge ${memory.codeForgeMb.toFixed(1)} MB, children ${memory.childrenMb.toFixed(1)} MB, orphans ${memory.orphansMb.toFixed(1)} MB`)];
|
|
335
|
+
} catch {
|
|
336
|
+
return [row('sweep', 'WARN', 'sweep', 'process inspection unavailable'), row('memory', 'INFO', 'memory', 'process inspection unavailable')];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
322
340
|
/**
|
|
323
341
|
* B44: the `review concurrency` INFO row — how many review tickets run at once and each provider's
|
|
324
342
|
* session slots, from the effective config (defaults when a key is unset). Never throws.
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
import os from 'node:os';
|
|
61
61
|
import path from 'node:path';
|
|
62
62
|
import { randomBytes } from 'node:crypto';
|
|
63
|
+
import { execFileSync } from 'node:child_process';
|
|
63
64
|
import { mergeForbidden, renderForCodex } from '../../util/forbidden.mjs';
|
|
64
65
|
import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } from '../codex-home.mjs';
|
|
65
66
|
import { hasAnyRenderedRule } from '../render-rules.mjs';
|
|
@@ -129,7 +130,7 @@ export const VALID_EFFORTS = PROVIDER_EFFORTS.openai;
|
|
|
129
130
|
* `coder.sandbox` config value. Absent ⇒ today's argv. `toolchain` adds one `--add-dir <abs>`
|
|
130
131
|
* per checked `add_dirs` entry; `full` builds `-s danger-full-access`. A refused entry throws
|
|
131
132
|
* a `SandboxDirError` (code `sandbox-dir-refused`) naming the key path, never the value.
|
|
132
|
-
* @property {string} [runRoot] -
|
|
133
|
+
* @property {string} [runRoot] - coder role only: owns the session home and is refused as an `add_dirs` tree.
|
|
133
134
|
*/
|
|
134
135
|
|
|
135
136
|
/**
|
|
@@ -166,7 +167,8 @@ function assertValidEffort(effort) {
|
|
|
166
167
|
* The Codex coder argv itself — pure, no I/O, no rules file (B4.2). `buildCoderArgv` uses it, and
|
|
167
168
|
* the doctor's flags probe builds it with placeholder values for its static conflict check.
|
|
168
169
|
* B93: `profile` `full` swaps `-s workspace-write` for `-s danger-full-access`; `addDirs` (already
|
|
169
|
-
* checked and resolved
|
|
170
|
+
* checked and resolved) follow `-s workspace-write` as `--add-dir <abs>` pairs, including the
|
|
171
|
+
* private Git directory and shared objects/refs/logs needed to commit from a linked worktree.
|
|
170
172
|
* The tmp exclusions stay in every profile: under `danger-full-access` Codex ignores the
|
|
171
173
|
* `sandbox_workspace_write.*` keys, and one argv shape keeps the forbidden-list check the same.
|
|
172
174
|
* @param {{model: string, effort?: string, cwd: string, outPath: string, promptPath: string, profile?: string, addDirs?: ReadonlyArray<string>}} p
|
|
@@ -180,11 +182,11 @@ export function coderFlagArgv({ model, effort, cwd, outPath, promptPath, profile
|
|
|
180
182
|
argv.push('-s', 'danger-full-access');
|
|
181
183
|
} else {
|
|
182
184
|
argv.push('-s', 'workspace-write');
|
|
183
|
-
|
|
185
|
+
for (const dir of addDirs) argv.push('--add-dir', dir);
|
|
184
186
|
}
|
|
185
187
|
// INVARIANT (B4.1 fix round 1): the session CODEX_HOME lives under the run temp root, so the
|
|
186
|
-
// sandbox must not make $TMPDIR or /tmp writable
|
|
187
|
-
//
|
|
188
|
+
// sandbox must not make $TMPDIR or /tmp writable. The project cwd and added directories are
|
|
189
|
+
// writable roots; a home inside the cwd is refused below. See `../codex-home.mjs`.
|
|
188
190
|
argv.push(...SANDBOX_TMP_EXCLUSIONS);
|
|
189
191
|
// B4.2: NO `--approve-for-me` (Codex 0.155.1 refuses it next to `-s`); see CODER_APPROVAL_POLICY.
|
|
190
192
|
argv.push(...CODER_APPROVAL_POLICY);
|
|
@@ -215,6 +217,23 @@ function coderSandbox(sandbox, extra) {
|
|
|
215
217
|
return { profile, addDirs: profile === 'toolchain' ? checked.dirs : [] };
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
/**
|
|
221
|
+
* A linked worktree needs its private gitdir and the shared objects/refs/logs writable.
|
|
222
|
+
* Never grant the common dir itself: its config and hooks can execute code on the host.
|
|
223
|
+
* Normal checkouts (equal Git dirs), non-repositories and placeholder cwds need no extra root.
|
|
224
|
+
* @param {string} cwd @returns {string[]}
|
|
225
|
+
*/
|
|
226
|
+
function worktreeGitDirs(cwd) {
|
|
227
|
+
try {
|
|
228
|
+
const [gitDir, commonDir] = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], {
|
|
229
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000,
|
|
230
|
+
}).trim().split('\n');
|
|
231
|
+
return gitDir !== commonDir ? [gitDir, ...['objects', 'refs', 'logs'].map((name) => path.join(commonDir, name))] : [];
|
|
232
|
+
} catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
218
237
|
/**
|
|
219
238
|
* @param {CodexBuildParams} params
|
|
220
239
|
* @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}}}
|
|
@@ -226,7 +245,7 @@ function buildCoderArgv(params) {
|
|
|
226
245
|
if (!hasAnyRenderedRule(renderedForbidden)) {
|
|
227
246
|
throw new Error('buildCodexArgv: coder role requires a non-empty forbidden-list render (got 0 usable patterns)');
|
|
228
247
|
}
|
|
229
|
-
const codexHome = params.codexHome ?? defaultCodexHome();
|
|
248
|
+
const codexHome = params.codexHome ?? defaultCodexHome(params.runRoot);
|
|
230
249
|
// Fix round 4: a non-absolute home is refused here, before any I/O, and the containment check
|
|
231
250
|
// below then runs EVERY time (no `isAbsolute` short-circuit that could skip it).
|
|
232
251
|
if (typeof codexHome !== 'string' || !path.isAbsolute(codexHome)) {
|
|
@@ -241,8 +260,9 @@ function buildCoderArgv(params) {
|
|
|
241
260
|
// B93 (fix round 1): the sandbox dirs are checked only after the home is known to be absolute
|
|
242
261
|
// and outside the cwd, so the home they are checked against is the one the session will use.
|
|
243
262
|
const sandbox = coderSandbox(params.sandbox, [codexHome, params.runRoot, tmpBase()]);
|
|
263
|
+
if (sandbox.profile !== 'full') sandbox.addDirs = [...new Set([...sandbox.addDirs, ...worktreeGitDirs(cwd)])];
|
|
244
264
|
const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath, ...sandbox });
|
|
245
|
-
const home = prepareCodexHome(codexHome, renderedForbidden);
|
|
265
|
+
const home = prepareCodexHome(codexHome, renderedForbidden, { runRoot: params.runRoot });
|
|
246
266
|
return {
|
|
247
267
|
cli: CLI,
|
|
248
268
|
role: 'coder',
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* swallow risk the way `claude.mjs`'s BLOCKER fix had to guard against, since each `--deny`
|
|
18
18
|
* occurrence's value slot is exactly one token wide.
|
|
19
19
|
*
|
|
20
|
-
* **Return contract (fix round 3):** `{cli, role, argv, cwd, forbiddenRendered?}` — never a
|
|
20
|
+
* **Return contract (fix round 3):** `{cli, role, argv, cwd, env, forbiddenRendered?}` — never a
|
|
21
21
|
* `stdinFile`. Unlike Claude/Codex closed-book runs, Grok's `--prompt-file <PATH>` ("Single-turn
|
|
22
22
|
* prompt from a file", pinned help) makes the CLI itself read the packet and send its CONTENT as
|
|
23
23
|
* the prompt, so even a closed-book run with every tool denied receives the packet text; the
|
|
@@ -41,6 +41,12 @@ import { assertBaseParams } from './validate-params.mjs';
|
|
|
41
41
|
|
|
42
42
|
export const CLI = 'grok';
|
|
43
43
|
|
|
44
|
+
/** Disable automatic loading of Cursor and Claude compatibility MCP servers for every role. */
|
|
45
|
+
export const GROK_NO_COMPAT_MCP_ENV = Object.freeze({
|
|
46
|
+
GROK_CURSOR_MCPS_ENABLED: 'false',
|
|
47
|
+
GROK_CLAUDE_MCPS_ENABLED: 'false',
|
|
48
|
+
});
|
|
49
|
+
|
|
44
50
|
/**
|
|
45
51
|
* Built-in tool names the closed-book command denies wholesale via `--disallowed-tools` (in
|
|
46
52
|
* addition to the wildcard `--deny "*"`, per plan §5.2). Not independently verified against a
|
|
@@ -78,7 +84,7 @@ function assertValidEffort(effort) {
|
|
|
78
84
|
|
|
79
85
|
/**
|
|
80
86
|
* @param {GrokBuildParams} params
|
|
81
|
-
* @returns {{cli: "grok", role: "coder", argv: string[], cwd: string, forbiddenRendered: ReadonlyArray<RenderedGrokEntry>}}
|
|
87
|
+
* @returns {{cli: "grok", role: "coder", argv: string[], cwd: string, env: typeof GROK_NO_COMPAT_MCP_ENV, forbiddenRendered: ReadonlyArray<RenderedGrokEntry>}}
|
|
82
88
|
*/
|
|
83
89
|
function buildCoderArgv(params) {
|
|
84
90
|
// B4.2: the coder's deny list is `mergeForbidden()` (FORBIDDEN + the coder-only entries). A
|
|
@@ -95,12 +101,12 @@ function buildCoderArgv(params) {
|
|
|
95
101
|
for (const rule of flattenRuleStrings(renderedForbidden)) {
|
|
96
102
|
argv.push('--deny', rule);
|
|
97
103
|
}
|
|
98
|
-
return { cli: CLI, role: 'coder', argv, cwd, forbiddenRendered: renderedForbidden };
|
|
104
|
+
return { cli: CLI, role: 'coder', argv, cwd, env: GROK_NO_COMPAT_MCP_ENV, forbiddenRendered: renderedForbidden };
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
/**
|
|
102
108
|
* @param {GrokBuildParams} params
|
|
103
|
-
* @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts"|"delegate", argv: string[], cwd: string}}
|
|
109
|
+
* @returns {{cli: "grok", role: "reviewer"|"judge"|"s2"|"author"|"facts"|"delegate", argv: string[], cwd: string, env: typeof GROK_NO_COMPAT_MCP_ENV}}
|
|
104
110
|
*/
|
|
105
111
|
function buildClosedBookArgv(params) {
|
|
106
112
|
const { model, effort, promptPath, cwd, schema, systemPromptText } = params;
|
|
@@ -119,7 +125,7 @@ function buildClosedBookArgv(params) {
|
|
|
119
125
|
argv.push('--permission-mode', 'dontAsk');
|
|
120
126
|
argv.push('--cwd', cwd);
|
|
121
127
|
if (systemPromptText !== undefined) argv.push('--system-prompt-override', systemPromptText);
|
|
122
|
-
return { cli: CLI, role, argv, cwd };
|
|
128
|
+
return { cli: CLI, role, argv, cwd, env: GROK_NO_COMPAT_MCP_ENV };
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
/**
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
* under the run temp root; the builder runs the coder with `-s workspace-write` PLUS
|
|
31
31
|
* `-c sandbox_workspace_write.exclude_tmpdir_env_var=true` and
|
|
32
32
|
* `-c sandbox_workspace_write.exclude_slash_tmp=true` (both keys are in the 0.155.1 binary), so the
|
|
33
|
-
*
|
|
33
|
+
* writable roots are the project `-C <cwd>` and added sandbox directories (including a linked
|
|
34
|
+
* worktree's common Git directory) — and a home inside that cwd is refused. On top, the
|
|
34
35
|
* rules file is 0444. The directories stay 0700, not 0555: a 0555 directory makes the run-root
|
|
35
36
|
* sweep's `rmSync(…, {recursive, force})` fail (ENOTEMPTY, checked on this Mac), which would leave
|
|
36
37
|
* the whole run root — the auth copy included — behind. Tradeoff of the tmp exclusion: the coder's
|
|
@@ -113,29 +114,30 @@ export function renderCodexRules(rendered) {
|
|
|
113
114
|
/** The name of the run-root directory every session home lives in. */
|
|
114
115
|
export const CODEX_HOMES_DIR_NAME = 'codex-homes';
|
|
115
116
|
|
|
116
|
-
/** @returns {string} `<
|
|
117
|
-
export function sessionCodexHomesDir() {
|
|
118
|
-
return path.join(
|
|
117
|
+
/** @param {string} [runRoot] @returns {string} `<runRoot>/codex-homes`, the parent of the run's session homes. */
|
|
118
|
+
export function sessionCodexHomesDir(runRoot = currentRunRoot()) {
|
|
119
|
+
return path.join(runRoot, CODEX_HOMES_DIR_NAME);
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
/**
|
|
122
|
-
* Whether `candidate` is STRICTLY inside `<
|
|
123
|
+
* Whether `candidate` is STRICTLY inside `<runRoot>/codex-homes/` (both sides
|
|
123
124
|
* symlink-resolved through their nearest existing ancestor). The directory itself, the run root,
|
|
124
125
|
* the real Codex home, `$HOME` and any sibling are all outside.
|
|
125
|
-
* @param {string} candidate @returns {boolean}
|
|
126
|
+
* @param {string} candidate @param {string} [runRoot] @returns {boolean}
|
|
126
127
|
*/
|
|
127
|
-
export function isSessionCodexHome(candidate) {
|
|
128
|
+
export function isSessionCodexHome(candidate, runRoot = currentRunRoot()) {
|
|
128
129
|
if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return false;
|
|
129
|
-
const rel = path.relative(resolveExisting(sessionCodexHomesDir()), resolveExisting(candidate));
|
|
130
|
+
const rel = path.relative(resolveExisting(sessionCodexHomesDir(runRoot)), resolveExisting(candidate));
|
|
130
131
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
/**
|
|
134
135
|
* A fresh per-session Codex home path under the run temp root.
|
|
136
|
+
* @param {string} [runRoot]
|
|
135
137
|
* @returns {string}
|
|
136
138
|
*/
|
|
137
|
-
export function defaultCodexHome() {
|
|
138
|
-
return path.join(sessionCodexHomesDir(), `coder-${randomBytes(6).toString('hex')}`);
|
|
139
|
+
export function defaultCodexHome(runRoot = currentRunRoot()) {
|
|
140
|
+
return path.join(sessionCodexHomesDir(runRoot), `coder-${randomBytes(6).toString('hex')}`);
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
/**
|
|
@@ -143,7 +145,7 @@ export function defaultCodexHome() {
|
|
|
143
145
|
* `auth.json` (0600) when it exists.
|
|
144
146
|
* @param {string} codexHome - absolute; never the real Codex home or inside it.
|
|
145
147
|
* @param {ReadonlyArray<CodexRenderedEntry>} rendered
|
|
146
|
-
* @param {{env?: NodeJS.ProcessEnv}} [opts]
|
|
148
|
+
* @param {{env?: NodeJS.ProcessEnv, runRoot?: string}} [opts]
|
|
147
149
|
* @returns {{codexHome: string, rulesPath: string, count: number, content: string}}
|
|
148
150
|
* @throws {Error} when `codexHome` is relative, is (or is inside) the real Codex home, or the
|
|
149
151
|
* render yields 0 rules.
|
|
@@ -159,7 +161,7 @@ export function prepareCodexHome(codexHome, rendered, opts = {}) {
|
|
|
159
161
|
// Fix round 4: an ANCESTOR of the real home (`$HOME`, `/`) is not "inside" it, yet a home there
|
|
160
162
|
// would put `rules/` and an auth copy where the user lives. The only place a session home may be
|
|
161
163
|
// is strictly inside `<run root>/codex-homes/` — the same confinement `removeCodexHome` enforces.
|
|
162
|
-
if (!isSessionCodexHome(codexHome)) {
|
|
164
|
+
if (!isSessionCodexHome(codexHome, opts.runRoot)) {
|
|
163
165
|
throw new Error(`prepareCodexHome: codexHome must be strictly inside <run root>/${CODEX_HOMES_DIR_NAME}/`);
|
|
164
166
|
}
|
|
165
167
|
const { content, count } = renderCodexRules(rendered);
|
|
@@ -215,15 +217,16 @@ function writeFreshFile(dest, bytes, mode) {
|
|
|
215
217
|
|
|
216
218
|
/**
|
|
217
219
|
* 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 `<
|
|
220
|
+
* is confined (fix round 3): `codexHome` must be strictly inside `<runRoot>/codex-homes/`
|
|
219
221
|
* after realpath resolution, or this throws and deletes nothing — the real `~/.codex`, `$HOME`,
|
|
220
222
|
* the run root itself and any sibling directory are refused. No-op when the (confined) path does
|
|
221
223
|
* not exist.
|
|
222
224
|
* @param {string} codexHome
|
|
225
|
+
* @param {string} [runRoot] - the same run root used to prepare the home.
|
|
223
226
|
* @throws {Error} when `codexHome` is not strictly inside the run root's `codex-homes/`.
|
|
224
227
|
*/
|
|
225
|
-
export function removeCodexHome(codexHome) {
|
|
226
|
-
if (!isSessionCodexHome(codexHome)) {
|
|
228
|
+
export function removeCodexHome(codexHome, runRoot = currentRunRoot()) {
|
|
229
|
+
if (!isSessionCodexHome(codexHome, runRoot)) {
|
|
227
230
|
throw new Error(`removeCodexHome: refusing to delete a path outside <run root>/${CODEX_HOMES_DIR_NAME}/`);
|
|
228
231
|
}
|
|
229
232
|
rmSync(resolveExisting(codexHome), { recursive: true, force: true });
|
package/src/engines/sentinel.mjs
CHANGED
|
@@ -53,6 +53,17 @@ export function parseSentinel(output, blockId) {
|
|
|
53
53
|
if (typeof output !== 'string') {
|
|
54
54
|
throw new TypeError('parseSentinel: output must be a string');
|
|
55
55
|
}
|
|
56
|
+
// CLI reports can contain the answer as a JSON string or as a string field in an envelope.
|
|
57
|
+
// Decode real JSON only: replacing literal \\n would also change ordinary prose and paths.
|
|
58
|
+
try {
|
|
59
|
+
const decoded = JSON.parse(output);
|
|
60
|
+
if (typeof decoded === 'string') output = decoded;
|
|
61
|
+
else if (decoded !== null && typeof decoded === 'object' && !Array.isArray(decoded)) {
|
|
62
|
+
output = Object.values(decoded).filter((value) => typeof value === 'string').join('\n');
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// A plain transcript (or malformed JSON) keeps the existing whole-line parser.
|
|
66
|
+
}
|
|
56
67
|
/** @type {SentinelResult | null} */
|
|
57
68
|
let last = null;
|
|
58
69
|
/** @type {TestsLine | null} */
|
package/src/gates/detect.mjs
CHANGED
|
@@ -20,7 +20,9 @@ import path from 'node:path';
|
|
|
20
20
|
* `run.mjs` treats non-empty stdout as red too, since a bare exit-code check can never fail.
|
|
21
21
|
* B60: a configured gate may be `{argv, env}` — `env` adds variables to the gate's environment
|
|
22
22
|
* (the values are never printed, `run.mjs`).
|
|
23
|
-
*
|
|
23
|
+
* `formatter` marks detected defaults that require an explicit Git file set. Configured
|
|
24
|
+
* commands have no such marker and retain their own arguments and scope.
|
|
25
|
+
* @typedef {string[] | {argv: string[], failOnStdout?: true, env?: Record<string, string>, formatter?: 'pint'|'prettier'}} GateCommand
|
|
24
26
|
* @typedef {GateCommand | null} Gate
|
|
25
27
|
*/
|
|
26
28
|
/**
|
|
@@ -69,7 +71,8 @@ function detectPhp(root) {
|
|
|
69
71
|
if (runner === null) return null;
|
|
70
72
|
const test = has(root, 'artisan') ? ['php', 'artisan', 'test', '--compact'] : [`vendor/bin/${runner}`];
|
|
71
73
|
const types = has(root, 'phpstan.neon') || has(root, 'phpstan.neon.dist') ? ['vendor/bin/phpstan'] : null;
|
|
72
|
-
const format = has(root, 'pint.json') || has(root, 'vendor/bin/pint')
|
|
74
|
+
const format = has(root, 'pint.json') || has(root, 'vendor/bin/pint')
|
|
75
|
+
? { argv: ['vendor/bin/pint', '--test'], formatter: /** @type {const} */ ('pint') } : null;
|
|
73
76
|
return { stack: `php-${runner}`, test, lint: null, types, format };
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -123,7 +126,7 @@ function detectNode(root) {
|
|
|
123
126
|
let format = null;
|
|
124
127
|
if (typeof scripts['format:check'] === 'string') format = [pm, 'run', 'format:check'];
|
|
125
128
|
else if (has(root, '.prettierrc') || has(root, '.prettierrc.json') || has(root, '.prettierrc.js') || hasDependency(pkg, 'prettier')) {
|
|
126
|
-
format = ['npx', 'prettier', '--check', '
|
|
129
|
+
format = { argv: ['npx', 'prettier', '--check', '--ignore-unknown'], formatter: /** @type {const} */ ('prettier') };
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
return { stack: 'node', test, lint, types, format };
|