@codedology/code-forge 0.9.0 → 0.10.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 +58 -0
- package/README.md +29 -8
- package/docs/reference/blocks.json +385 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +8 -3
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +11 -4
- package/skill/references/security.md +4 -4
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +2 -0
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +164 -24
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +27 -6
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/engine.mjs +82 -30
- package/src/review/fixloop.mjs +45 -13
- package/src/review/gate-check.mjs +3 -2
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +4 -6
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +2 -2
- package/src/worker/loop.mjs +79 -14
- package/src/worker/ticket.mjs +9 -1
package/src/config/validate.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 1. **Schema validation** (Ajv, draft 2020-12, `additionalProperties: false` almost everywhere)
|
|
6
6
|
* catches shape errors: unknown keys, wrong types, missing required fields.
|
|
7
|
-
* 2. **Domain rules** (`DOMAIN_RULES` below,
|
|
7
|
+
* 2. **Domain rules** (`DOMAIN_RULES` below, 20 of them as of B93) catch things no JSON Schema can express:
|
|
8
8
|
* cross-field comparisons (two levels resolving to the same tuple), a value that needs a
|
|
9
9
|
* second config value to be reachable (`second_levels.L2.provider` vs. the effective L2
|
|
10
10
|
* provider) and rules that reach outside the config object entirely (is a CLI on PATH, is a
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
* 14 caps-coders-exceeds-cap WARN (v1.3/R10/B1.1: caps.coders > 2)
|
|
37
37
|
* 15 effort-not-valid-for-provider ERROR (B29: a level's / fallback's effort vs. its provider)
|
|
38
38
|
* 16 closed-book-on-openai WARN (B32: Codex has no no-tools mode)
|
|
39
|
+
* 17 proof-runners-invalid ERROR (B87: a proof.runners entry: adapter, match, argv tokens, env, timeout_s)
|
|
40
|
+
* 18 review-routes-invalid ERROR (B90: a review.routes rule: level, duplicate level+min_lines, provider vs. the second reviewer)
|
|
41
|
+
* 19 sandbox-dir-refused ERROR (B93: a coder.sandbox.add_dirs entry opens /, the home dir, a secret dir or the run temp tree)
|
|
42
|
+
* 20 sandbox-dirs-unused WARN (B93: coder.sandbox.add_dirs set under a profile other than toolchain)
|
|
39
43
|
*
|
|
40
44
|
* **B1.2 (v1.3, Q16 cut) removed rule:** `proof-tool-absent-for-high-tier` (formerly #13) — the
|
|
41
45
|
* key it warned about (`proof.tiers.high.tool`) left the schema in the same amendment, along with
|
|
@@ -66,6 +70,11 @@ import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
|
66
70
|
import { checkEffort, displayEffort } from '../engines/efforts.mjs';
|
|
67
71
|
import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
|
|
68
72
|
import { isKnownId, resolveLevel } from './known-ids.mjs';
|
|
73
|
+
// B90: the routing module's own level list and provider resolution, so rules 16 and 18 check a
|
|
74
|
+
// route with exactly the provider the worker spawns it on (they cannot drift)
|
|
75
|
+
import { ROUTE_LEVELS, ruleProvider } from '../review/route.mjs';
|
|
76
|
+
import { DEFAULT_SANDBOX_PROFILE, SANDBOX_PROFILES, sandboxDirRefusals, SANDBOX_DIR_REFUSED } from '../engines/sandbox-dirs.mjs';
|
|
77
|
+
import { tmpBase } from '../util/tmp.mjs';
|
|
69
78
|
import { looksLikeSecret, maskSecretTokens, SECRET_MASK, secretTokensIn } from './secret-patterns.mjs';
|
|
70
79
|
|
|
71
80
|
/**
|
|
@@ -781,6 +790,21 @@ function checkClosedBookOnOpenai(cfg) {
|
|
|
781
790
|
if (cfg?.review?.multimodel === true && isNoClosedBookProvider(effectiveSecondL2Provider(cfg))) {
|
|
782
791
|
where.push('review.second_levels.L2 (else review.second_provider)');
|
|
783
792
|
}
|
|
793
|
+
// B90: a review.routes rule replaces an L2/L3 level for a big file — the same closed-book limit
|
|
794
|
+
routeRulesOf(cfg).forEach((rule, i) => {
|
|
795
|
+
if (!isPlainObject(rule)) return;
|
|
796
|
+
if (isNoClosedBookProvider(ruleProvider(cfg, rule))) where.push(`review.routes[${i}]`);
|
|
797
|
+
if (Array.isArray(rule.fallback)) {
|
|
798
|
+
rule.fallback.forEach((fb, j) => {
|
|
799
|
+
if (isNoClosedBookProvider(fb?.provider)) where.push(`review.routes[${i}].fallback[${j}]`);
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
// like the second reviewer's own check above: only with multimodel on, and with the same
|
|
803
|
+
// effective second provider (the rule's own, else second_levels.L2, else second_provider)
|
|
804
|
+
if (cfg?.review?.multimodel === true && isPlainObject(rule.second) && isNoClosedBookProvider(rule.second.provider ?? effectiveSecondL2Provider(cfg))) {
|
|
805
|
+
where.push(`review.routes[${i}].second`);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
784
808
|
// B32 opt-in: the same places, but the text says what the opt-in costs instead of "refused".
|
|
785
809
|
const text = openBookCodexAllowed(cfg) ? OPEN_BOOK_CODEX_WARNING : CODEX_CLOSED_BOOK_REFUSAL;
|
|
786
810
|
return where.map((key) => ({
|
|
@@ -790,11 +814,298 @@ function checkClosedBookOnOpenai(cfg) {
|
|
|
790
814
|
}));
|
|
791
815
|
}
|
|
792
816
|
|
|
817
|
+
// ── 17. proof-runners-invalid (ERROR, B87) ──────────────────────────────────
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* B87: the argv tokens each `proof.runners` adapter expands. A token is a whole argv item, never
|
|
821
|
+
* the command. `proof/runners.mjs` expands exactly these (proof may import config; config may not
|
|
822
|
+
* import proof).
|
|
823
|
+
*/
|
|
824
|
+
export const RUNNER_ADAPTER_TOKENS = Object.freeze({
|
|
825
|
+
junit: Object.freeze(['{test_file}', '{test_id}', '{test_filter}', '{report}']),
|
|
826
|
+
swift: Object.freeze(['{test_file}', '{test_id}', '{test_filter}', '{report}']),
|
|
827
|
+
// B102: the values come from the entry's `xcode` object; `{only_testing}` names the test and
|
|
828
|
+
// `{result_bundle}` is the fresh result bundle path code-forge reads
|
|
829
|
+
xcodebuild: Object.freeze(['{workspace}', '{project}', '{scheme}', '{destination}', '{only_testing}', '{result_bundle}']),
|
|
830
|
+
});
|
|
831
|
+
/** B87: the tokens that name the test; a `junit` or `swift` runner needs at least one (a proof must name its test). */
|
|
832
|
+
export const RUNNER_TEST_TOKENS = Object.freeze(['{test_file}', '{test_id}', '{test_filter}']);
|
|
833
|
+
/** B87: every token any adapter knows. */
|
|
834
|
+
const ALL_RUNNER_TOKENS = Object.freeze(['{test_file}', '{test_id}', '{test_filter}', '{report}', '{workspace}', '{project}', '{scheme}', '{destination}', '{only_testing}', '{result_bundle}']);
|
|
835
|
+
/** B102: the keys of an `xcodebuild` entry's `xcode` object, and the ones it must have. */
|
|
836
|
+
export const XCODE_KEYS = Object.freeze(['workspace', 'project', 'scheme', 'destination', 'test_target']);
|
|
837
|
+
const XCODE_REQUIRED = Object.freeze(['scheme', 'destination', 'test_target']);
|
|
838
|
+
/** B102: the tokens an `xcodebuild` argv must have, and why. */
|
|
839
|
+
const XCODE_REQUIRED_TOKENS = Object.freeze([
|
|
840
|
+
['{scheme}', 'the xcodebuild adapter fills it from xcode.scheme'],
|
|
841
|
+
['{destination}', 'the xcodebuild adapter fills it from xcode.destination, the one the precheck checks'],
|
|
842
|
+
['{only_testing}', 'a proof must name its test'],
|
|
843
|
+
['{result_bundle}', 'the xcodebuild adapter reads the result bundle code-forge places there'],
|
|
844
|
+
]);
|
|
845
|
+
/** B87: an item that is a whole `{word}` is read as a token. */
|
|
846
|
+
const TOKEN_SHAPE = /^\{[a-z_]+\}$/;
|
|
847
|
+
/** B87: the bounds of `proof.runners[].timeout_s`, in seconds. */
|
|
848
|
+
const RUNNER_TIMEOUT_MIN_S = 60;
|
|
849
|
+
const RUNNER_TIMEOUT_MAX_S = 7200;
|
|
850
|
+
/** B87: the keys a runner entry may have (the schema's `additionalProperties: false`, as a rule). */
|
|
851
|
+
const RUNNER_KEYS = Object.freeze(['match', 'adapter', 'argv', 'env', 'timeout_s', 'xcode']);
|
|
852
|
+
/** B87: every token some adapter lists; with a bad adapter, an item outside it is still refused. */
|
|
853
|
+
const ANY_ADAPTER_TOKENS = Object.freeze([...new Set(Object.values(RUNNER_ADAPTER_TOKENS).flat())]);
|
|
854
|
+
/** B102: the report items and the test tokens of every adapter; a bad adapter's argv needs one of each. */
|
|
855
|
+
const ANY_REPORT_TOKENS = Object.freeze(['{report}', '{result_bundle}']);
|
|
856
|
+
const ANY_TEST_TOKENS = Object.freeze([...RUNNER_TEST_TOKENS, '{only_testing}']);
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Rule 17's messages for one runner entry. A message names the key path and the rule; it may name
|
|
860
|
+
* an env variable NAME or an unknown entry key (key paths), never a value (argv items, env values) — only the adapter's
|
|
861
|
+
* own token words, which are code-forge's. The argv checks run even when the adapter is bad, so
|
|
862
|
+
* one pass shows every error. `proof/runners.mjs` checks the snapshot's entries with it again
|
|
863
|
+
* before it runs one.
|
|
864
|
+
* @param {unknown} entry @param {number} i @returns {string[]}
|
|
865
|
+
*/
|
|
866
|
+
export function runnerMessages(entry, i) {
|
|
867
|
+
const at = `proof.runners[${i}]`;
|
|
868
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return [`${at} must be an object with match, adapter and argv`];
|
|
869
|
+
const runner = /** @type {Record<string, unknown>} */ (entry);
|
|
870
|
+
const messages = [];
|
|
871
|
+
const adapter = typeof runner.adapter === 'string' && Object.hasOwn(RUNNER_ADAPTER_TOKENS, runner.adapter) ? runner.adapter : null;
|
|
872
|
+
if (adapter === null) messages.push(`${at}.adapter must be one of: ${Object.keys(RUNNER_ADAPTER_TOKENS).join(', ')}`);
|
|
873
|
+
for (const key of Object.keys(runner)) {
|
|
874
|
+
if (!RUNNER_KEYS.includes(key)) messages.push(`${at}: ${JSON.stringify(key)} is not a runner key (${RUNNER_KEYS.join(', ')})`);
|
|
875
|
+
}
|
|
876
|
+
const match = runner.match;
|
|
877
|
+
if (!Array.isArray(match) || match.length === 0 || !match.every((glob) => typeof glob === 'string' && glob.length > 0)) {
|
|
878
|
+
messages.push(`${at}.match must be a non-empty list of globs`);
|
|
879
|
+
}
|
|
880
|
+
if (!isArgvArray(runner.argv)) {
|
|
881
|
+
messages.push(`${at}.argv must be a non-empty argv array of strings`);
|
|
882
|
+
} else {
|
|
883
|
+
const argv = /** @type {string[]} */ (runner.argv);
|
|
884
|
+
if (TOKEN_SHAPE.test(argv[0])) messages.push(`${at}.argv[0]: a token cannot be the command`);
|
|
885
|
+
// with a bad adapter, a token-shaped item is checked against every adapter's tokens instead;
|
|
886
|
+
// every other argv check still runs
|
|
887
|
+
const known = adapter === null ? null : RUNNER_ADAPTER_TOKENS[/** @type {keyof typeof RUNNER_ADAPTER_TOKENS} */ (adapter)];
|
|
888
|
+
argv.forEach((item, j) => {
|
|
889
|
+
if (item.includes('\0')) {
|
|
890
|
+
messages.push(`${at}.argv[${j}] contains a NUL character`);
|
|
891
|
+
} else if (j > 0 && TOKEN_SHAPE.test(item) && known !== null && !known.includes(item)) {
|
|
892
|
+
messages.push(`${at}.argv[${j}] is not a token of the ${adapter} adapter (${known.join(', ')})`);
|
|
893
|
+
} else if (j > 0 && TOKEN_SHAPE.test(item) && known === null && !ANY_ADAPTER_TOKENS.includes(item)) {
|
|
894
|
+
messages.push(`${at}.argv[${j}] is not a token of any adapter (${ANY_ADAPTER_TOKENS.join(', ')})`);
|
|
895
|
+
} else if (!TOKEN_SHAPE.test(item) && ALL_RUNNER_TOKENS.some((token) => item.includes(token))) {
|
|
896
|
+
messages.push(`${at}.argv[${j}]: a token must be a whole argv item (give it as its own item)`);
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
// the required tokens depend on the adapter; with a bad adapter, every adapter's report items
|
|
900
|
+
// and test tokens count, so an argv that names no report or no test is still refused
|
|
901
|
+
const items = argv.slice(1);
|
|
902
|
+
if (adapter === 'junit' || adapter === 'swift') {
|
|
903
|
+
if (!items.includes('{report}')) messages.push(`${at}.argv needs a {report} item (the ${adapter} adapter reads the report code-forge places there)`);
|
|
904
|
+
if (!items.some((item) => RUNNER_TEST_TOKENS.includes(item))) messages.push(`${at}.argv needs a test token (${RUNNER_TEST_TOKENS.join(', ')}): a proof must name its test`);
|
|
905
|
+
} else if (adapter === 'xcodebuild') {
|
|
906
|
+
for (const [token, why] of XCODE_REQUIRED_TOKENS) if (!items.includes(token)) messages.push(`${at}.argv needs a ${token} item (${why})`);
|
|
907
|
+
} else {
|
|
908
|
+
if (!items.some((item) => ANY_REPORT_TOKENS.includes(item))) messages.push(`${at}.argv needs a report item (${ANY_REPORT_TOKENS.join(', ')}): every adapter reads the report code-forge places there`);
|
|
909
|
+
if (!items.some((item) => ANY_TEST_TOKENS.includes(item))) messages.push(`${at}.argv needs a test token (${ANY_TEST_TOKENS.join(', ')}): a proof must name its test`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
messages.push(...xcodeMessages(runner, adapter, at));
|
|
913
|
+
if (runner.env !== undefined) {
|
|
914
|
+
if (runner.env === null || typeof runner.env !== 'object' || Array.isArray(runner.env)) {
|
|
915
|
+
messages.push(`${at}.env must be a map of variable names to string values`);
|
|
916
|
+
} else {
|
|
917
|
+
for (const [name, envValue] of Object.entries(runner.env)) {
|
|
918
|
+
if (!ENV_NAME.test(name)) messages.push(`${at}.env: ${JSON.stringify(name)} is not a variable name (letters, digits and _, not starting with a digit)`);
|
|
919
|
+
else if (typeof envValue !== 'string') messages.push(`${at}.env.${name} must be a string`);
|
|
920
|
+
else if (envValue.includes('\0')) messages.push(`${at}.env.${name} contains a NUL character`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (runner.timeout_s !== undefined) {
|
|
925
|
+
const t = runner.timeout_s;
|
|
926
|
+
if (typeof t !== 'number' || !Number.isInteger(t) || t < RUNNER_TIMEOUT_MIN_S || t > RUNNER_TIMEOUT_MAX_S) {
|
|
927
|
+
messages.push(`${at}.timeout_s must be a whole number of seconds from ${RUNNER_TIMEOUT_MIN_S} to ${RUNNER_TIMEOUT_MAX_S}`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return messages;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** @param {unknown} value @returns {boolean} */
|
|
934
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* B102: rule 17's messages for an entry's `xcode` object. An `xcodebuild` entry needs one; any other
|
|
938
|
+
* adapter refuses one. Its values are argv items of `xcodebuild`, so each is a non-empty string with
|
|
939
|
+
* no NUL that does not start with `-` (it would be read as an option). Messages name key paths only.
|
|
940
|
+
* @param {Record<string, unknown>} runner @param {string | null} adapter @param {string} at
|
|
941
|
+
* @returns {string[]}
|
|
942
|
+
*/
|
|
943
|
+
function xcodeMessages(runner, adapter, at) {
|
|
944
|
+
const xcode = runner.xcode;
|
|
945
|
+
if (xcode === undefined) return adapter === 'xcodebuild' ? [`${at}.xcode is required for the xcodebuild adapter (${XCODE_REQUIRED.join(', ')})`] : [];
|
|
946
|
+
if (adapter !== null && adapter !== 'xcodebuild') return [`${at}.xcode is only for the xcodebuild adapter`];
|
|
947
|
+
if (!isPlainObject(xcode)) return [`${at}.xcode must be an object with ${XCODE_REQUIRED.join(', ')}`];
|
|
948
|
+
const values = /** @type {Record<string, unknown>} */ (xcode);
|
|
949
|
+
const messages = [];
|
|
950
|
+
for (const key of Object.keys(values)) {
|
|
951
|
+
if (!XCODE_KEYS.includes(key)) messages.push(`${at}.xcode: ${JSON.stringify(key)} is not an xcode key (${XCODE_KEYS.join(', ')})`);
|
|
952
|
+
}
|
|
953
|
+
for (const key of XCODE_KEYS) {
|
|
954
|
+
const value = values[key];
|
|
955
|
+
if (value === undefined) {
|
|
956
|
+
if (XCODE_REQUIRED.includes(key)) messages.push(`${at}.xcode.${key} is required`);
|
|
957
|
+
} else if (typeof value !== 'string' || value.length === 0 || value.startsWith('-')) {
|
|
958
|
+
messages.push(`${at}.xcode.${key} must be a non-empty string that does not start with "-"`);
|
|
959
|
+
} else if (value.includes('\0')) {
|
|
960
|
+
messages.push(`${at}.xcode.${key} contains a NUL character`);
|
|
961
|
+
} else if (key === 'test_target' && value.includes('/')) {
|
|
962
|
+
messages.push(`${at}.xcode.test_target must be one target name, without "/"`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (values.workspace !== undefined && values.project !== undefined) messages.push(`${at}.xcode: give workspace or project, not both`);
|
|
966
|
+
// a {workspace} / {project} item and its xcode key come together: one without the other is a
|
|
967
|
+
// value xcodebuild would never see, or an argv item with no value
|
|
968
|
+
const argv = isArgvArray(runner.argv) ? /** @type {string[]} */ (runner.argv).slice(1) : null;
|
|
969
|
+
if (argv !== null) {
|
|
970
|
+
for (const key of ['workspace', 'project']) {
|
|
971
|
+
const token = `{${key}}`;
|
|
972
|
+
if (argv.includes(token) && values[key] === undefined) messages.push(`${at}.argv has ${token} but ${at}.xcode.${key} is not set`);
|
|
973
|
+
if (!argv.includes(token) && values[key] !== undefined) messages.push(`${at}.xcode.${key} is set but ${at}.argv has no ${token} item`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return messages;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function checkProofRunnersInvalid(cfg) {
|
|
980
|
+
const runners = cfg?.proof?.runners;
|
|
981
|
+
if (runners === undefined) return [];
|
|
982
|
+
const messages = !Array.isArray(runners)
|
|
983
|
+
? ['proof.runners must be a list of runner entries']
|
|
984
|
+
: runners.length === 0
|
|
985
|
+
? ['proof.runners must list at least one runner (leave the key out for none)']
|
|
986
|
+
: runners.flatMap((entry, i) => runnerMessages(entry, i));
|
|
987
|
+
return messages.map((message) => ({ rule: 'proof-runners-invalid', severity: /** @type {const} */ ('error'), message }));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ── 18. review-routes-invalid (ERROR, B90) ──────────────────────────────────
|
|
991
|
+
|
|
992
|
+
/** @param {Record<string, any>} cfg @returns {any[]} the `review.routes` list, `[]` when it is not one. */
|
|
993
|
+
function routeRulesOf(cfg) {
|
|
994
|
+
const routes = cfg?.review?.routes;
|
|
995
|
+
return Array.isArray(routes) ? routes : [];
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* B90: what the schema cannot say about `review.routes` — a level other than L2 or L3, two rules
|
|
1000
|
+
* for the same level and `min_lines` (which one wins would be order-dependent), and, with
|
|
1001
|
+
* `review.multimodel` on, an L2 rule whose provider (its own, else the top-level one) equals its
|
|
1002
|
+
* own `second.provider`, or the second reviewer's effective L2 provider — the consensus slot B a
|
|
1003
|
+
* routed ticket really runs with — (the consensus needs two providers on a big file too).
|
|
1004
|
+
* Messages name key paths only, never a value.
|
|
1005
|
+
* @param {Record<string, any>} cfg
|
|
1006
|
+
*/
|
|
1007
|
+
function checkReviewRoutesInvalid(cfg) {
|
|
1008
|
+
const routes = cfg?.review?.routes;
|
|
1009
|
+
if (routes === undefined || routes === null) return [];
|
|
1010
|
+
/** @type {string[]} */
|
|
1011
|
+
const messages = [];
|
|
1012
|
+
if (!Array.isArray(routes)) {
|
|
1013
|
+
messages.push('review.routes must be a list of rules');
|
|
1014
|
+
} else {
|
|
1015
|
+
/** @type {Map<string, number>} */
|
|
1016
|
+
const seen = new Map();
|
|
1017
|
+
const second = effectiveSecondL2Provider(cfg);
|
|
1018
|
+
routes.forEach((rule, i) => {
|
|
1019
|
+
const at = `review.routes[${i}]`;
|
|
1020
|
+
if (!isPlainObject(rule)) {
|
|
1021
|
+
messages.push(`${at} must be a rule object`);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const level = rule.level ?? 'L2';
|
|
1025
|
+
if (!ROUTE_LEVELS.includes(level)) {
|
|
1026
|
+
messages.push(`${at}.level must be L2 or L3`);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (Number.isInteger(rule.min_lines)) {
|
|
1030
|
+
const k = `${level}\0${rule.min_lines}`;
|
|
1031
|
+
const first = seen.get(k);
|
|
1032
|
+
if (first === undefined) seen.set(k, i);
|
|
1033
|
+
else messages.push(`${at} has the same level and min_lines as review.routes[${first}]`);
|
|
1034
|
+
}
|
|
1035
|
+
if (cfg?.review?.multimodel === true && level === 'L2') {
|
|
1036
|
+
// the provider the worker spawns this rule on: its own, else the top-level one (the rule
|
|
1037
|
+
// REPLACES levels.L2, so levels.L2.provider is never inherited)
|
|
1038
|
+
const first = ruleProvider(cfg, rule);
|
|
1039
|
+
const own = isPlainObject(rule.second) ? rule.second.provider : undefined;
|
|
1040
|
+
if (own !== undefined && first === own) {
|
|
1041
|
+
// the rule's own second reviewer
|
|
1042
|
+
messages.push(`${at}.provider (else provider) equals ${at}.second.provider — multimodel needs two different providers`);
|
|
1043
|
+
} else if (second !== undefined && first === second) {
|
|
1044
|
+
// the consensus slot B a routed ticket runs with (it keeps review.second_levels.L2)
|
|
1045
|
+
messages.push(`${at}.provider (else provider) equals the second reviewer's effective L2 provider (review.second_levels.L2.provider, else review.second_provider) — multimodel needs two different providers`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
return messages.map((message) => ({ rule: 'review-routes-invalid', severity: /** @type {const} */ ('error'), message }));
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ── 19. sandbox-dir-refused (ERROR, B93) ────────────────────────────────────
|
|
1054
|
+
/**
|
|
1055
|
+
* A `coder.sandbox.add_dirs` entry that is not absolute / `~/`, or that is `/`, the home dir, or
|
|
1056
|
+
* is, holds or sits inside a secret dir (`~/.code-forge`, `~/.codex`, `~/.ssh`, …) or the run
|
|
1057
|
+
* temp base (every run temp root and session CODEX_HOME live under it). One issue per refused
|
|
1058
|
+
* entry; the message names the key path, never the value. The builder checks again at spawn.
|
|
1059
|
+
* @param {Record<string, any>} cfg
|
|
1060
|
+
* @returns {ValidationIssue[]}
|
|
1061
|
+
*/
|
|
1062
|
+
function checkSandboxDirRefused(cfg) {
|
|
1063
|
+
const sandbox = isPlainObject(cfg.coder) ? cfg.coder.sandbox : undefined;
|
|
1064
|
+
// a non-list add_dirs, or a non-string entry, is the schema layer's issue (fix round 1)
|
|
1065
|
+
if (!isPlainObject(sandbox) || !Array.isArray(sandbox.add_dirs)) return [];
|
|
1066
|
+
const dirs = /** @type {unknown[]} */ (sandbox.add_dirs);
|
|
1067
|
+
/** @type {string[]} */
|
|
1068
|
+
const extra = [];
|
|
1069
|
+
try {
|
|
1070
|
+
extra.push(tmpBase(isPlainObject(cfg.tmp) ? cfg.tmp.root ?? undefined : undefined));
|
|
1071
|
+
} catch {
|
|
1072
|
+
extra.push(tmpBase()); // a bad tmp.root is the schema's issue; the default base still counts
|
|
1073
|
+
}
|
|
1074
|
+
const nonStrings = new Set(dirs.flatMap((entry, i) => (typeof entry === 'string' ? [] : [`coder.sandbox.add_dirs[${i}]`])));
|
|
1075
|
+
return sandboxDirRefusals(dirs, { extra })
|
|
1076
|
+
.filter((r) => !nonStrings.has(r.keyPath))
|
|
1077
|
+
.map((r) => ({
|
|
1078
|
+
rule: SANDBOX_DIR_REFUSED,
|
|
1079
|
+
severity: /** @type {const} */ ('error'),
|
|
1080
|
+
message: `${r.keyPath} is refused — ${r.why}`,
|
|
1081
|
+
}));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ── 20. sandbox-dirs-unused (WARN, B93 fix round 1) ─────────────────────────
|
|
1085
|
+
/**
|
|
1086
|
+
* `coder.sandbox.add_dirs` is set (a non-empty list) but the profile is not `toolchain`: only
|
|
1087
|
+
* `toolchain` adds `--add-dir`, so the entries change nothing (rule 19 still refuses a bad one).
|
|
1088
|
+
* The message names the key path and the profile, never a dir.
|
|
1089
|
+
* @param {Record<string, any>} cfg
|
|
1090
|
+
* @returns {ValidationIssue[]}
|
|
1091
|
+
*/
|
|
1092
|
+
function checkSandboxDirsUnused(cfg) {
|
|
1093
|
+
const sandbox = isPlainObject(cfg.coder) ? cfg.coder.sandbox : undefined;
|
|
1094
|
+
if (!isPlainObject(sandbox) || !Array.isArray(sandbox.add_dirs) || sandbox.add_dirs.length === 0) return [];
|
|
1095
|
+
const profile = sandbox.profile ?? DEFAULT_SANDBOX_PROFILE;
|
|
1096
|
+
if (profile === 'toolchain' || !SANDBOX_PROFILES.includes(profile)) return [];
|
|
1097
|
+
return [{
|
|
1098
|
+
rule: 'sandbox-dirs-unused',
|
|
1099
|
+
severity: /** @type {const} */ ('warning'),
|
|
1100
|
+
message: `coder.sandbox.add_dirs is set but coder.sandbox.profile is ${profile}, which does not use it — only toolchain adds --add-dir`,
|
|
1101
|
+
}];
|
|
1102
|
+
}
|
|
1103
|
+
|
|
793
1104
|
/**
|
|
794
|
-
* The
|
|
1105
|
+
* The 20 domain rules paired with the exact `rule` id string each one emits — the single source
|
|
795
1106
|
* of truth both `DOMAIN_RULES` (below, what `validateConfig` runs) and the exported
|
|
796
1107
|
* `DOMAIN_RULE_IDS` are built from. `'schema'` (the ajv layer's own issues) is deliberately not
|
|
797
|
-
* one of these
|
|
1108
|
+
* one of these 20 (B29 added rule 15, B32 rule 16, B87 rule 17, B90 rule 18, B93 rules 19 and 20). (B1.2/v1.3, Q16 cut: `proof-tool-absent-for-high-tier` removed, 15 -> 14.)
|
|
798
1109
|
* @type {ReadonlyArray<{fn: (cfg: Record<string, any>, opts?: object) => ValidationIssue[], id: string}>}
|
|
799
1110
|
*/
|
|
800
1111
|
const RULE_TABLE = Object.freeze([
|
|
@@ -814,12 +1125,16 @@ const RULE_TABLE = Object.freeze([
|
|
|
814
1125
|
{ fn: checkCapsCodersExceedsCap, id: 'caps-coders-exceeds-cap' },
|
|
815
1126
|
{ fn: checkEffortNotValidForProvider, id: 'effort-not-valid-for-provider' },
|
|
816
1127
|
{ fn: checkClosedBookOnOpenai, id: 'closed-book-on-openai' },
|
|
1128
|
+
{ fn: checkProofRunnersInvalid, id: 'proof-runners-invalid' },
|
|
1129
|
+
{ fn: checkReviewRoutesInvalid, id: 'review-routes-invalid' },
|
|
1130
|
+
{ fn: checkSandboxDirRefused, id: 'sandbox-dir-refused' },
|
|
1131
|
+
{ fn: checkSandboxDirsUnused, id: 'sandbox-dirs-unused' },
|
|
817
1132
|
]);
|
|
818
1133
|
|
|
819
|
-
/** The
|
|
1134
|
+
/** The 20 domain rules, in the order documented above. */
|
|
820
1135
|
const DOMAIN_RULES = Object.freeze(RULE_TABLE.map((entry) => entry.fn));
|
|
821
1136
|
|
|
822
|
-
/** The
|
|
1137
|
+
/** The 20 rule ids `validateConfig` can emit, in `DOMAIN_RULES` order (see `RULE_TABLE`). */
|
|
823
1138
|
export const DOMAIN_RULE_IDS = Object.freeze(RULE_TABLE.map((entry) => entry.id));
|
|
824
1139
|
|
|
825
1140
|
/**
|
package/src/doctor/index.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
|
14
14
|
import { newRunId } from '../state/paths.mjs';
|
|
15
15
|
import { runRoot } from '../util/tmp.mjs';
|
|
16
16
|
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
|
|
17
|
-
import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
|
|
17
|
+
import { checkCoderSandbox, configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
|
|
18
18
|
import { row } from './rows.mjs';
|
|
19
19
|
import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
|
|
20
20
|
|
|
@@ -70,6 +70,7 @@ export async function runDoctor(opts = {}, deps = {}) {
|
|
|
70
70
|
rows.push(...checkTmp(cfg));
|
|
71
71
|
rows.push(...(await checkReviewState(cfg)));
|
|
72
72
|
rows.push(checkReviewConcurrency(cfg));
|
|
73
|
+
rows.push(...checkCoderSandbox(cfg)); // B93: no rows without coder.sandbox
|
|
73
74
|
if (!cfg) {
|
|
74
75
|
rows.push(row('probes', 'FAIL', 'probes', 'skipped: no valid config'));
|
|
75
76
|
rows.push(SIGNER_ROW);
|
package/src/doctor/local.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { commandOnPath } from '../install/detect.mjs';
|
|
|
16
16
|
import { installsPath, readInstalls, targetResolves } from '../install/link.mjs';
|
|
17
17
|
import { missingTools } from '../install/tools.mjs';
|
|
18
18
|
import { OP_MESSAGES } from '../keys/onepassword.mjs';
|
|
19
|
-
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
19
|
+
import { cacheHoursFrom, createDefaultKeyStore, isTime, resolveKey } from '../keys/store.mjs';
|
|
20
20
|
import { ledgerDir } from '../ledger/paths.mjs';
|
|
21
21
|
import { readAllRows } from '../ledger/write.mjs';
|
|
22
22
|
import { DEFAULT_PROVIDER_CONCURRENCY, providerLimit } from '../session/spawn.mjs';
|
|
@@ -72,16 +72,28 @@ export async function checkLinks(home) {
|
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
74
|
* Resolve the Jev key through B2's chain (env → keychain/file → 1Password). The value never
|
|
75
|
-
* leaves this function except to the caller that makes the live call.
|
|
75
|
+
* leaves this function except to the caller that makes the live call. A refresh from 1Password is
|
|
76
|
+
* cached for `keys.cache_hours` (B84); a config that failed validation arrives as null ⇒ 8 h.
|
|
77
|
+
* B85: under 1 hour left the row is WARN with a fixed re-link text (never the reference); when
|
|
78
|
+
* `keys.cache_hours` is set the OK text ends `(cache <n> h)`.
|
|
76
79
|
* @param {Record<string, any> | null} cfg
|
|
77
|
-
* @param {{env: NodeJS.ProcessEnv, store?: any, opRead?: any}} deps
|
|
80
|
+
* @param {{env: NodeJS.ProcessEnv, store?: any, opRead?: any, now?: number}} deps
|
|
78
81
|
* @returns {Promise<{rows: Row[], key: string | null}>}
|
|
79
82
|
*/
|
|
80
83
|
export async function checkKeys(cfg, deps) {
|
|
81
84
|
try {
|
|
82
85
|
const store = deps.store ?? (await createDefaultKeyStore(deps.env));
|
|
83
86
|
const ref = cfg?.system1?.key ?? cfg?.keys?.jev;
|
|
84
|
-
const
|
|
87
|
+
const cache = cacheHoursFrom(cfg);
|
|
88
|
+
const now = deps.now ?? Date.now();
|
|
89
|
+
const res = await resolveKey('jev', {
|
|
90
|
+
store,
|
|
91
|
+
env: deps.env,
|
|
92
|
+
ref: typeof ref === 'string' ? ref : undefined,
|
|
93
|
+
cacheHours: cache.hours,
|
|
94
|
+
now,
|
|
95
|
+
...(deps.opRead ? { opRead: deps.opRead } : {}),
|
|
96
|
+
});
|
|
85
97
|
if (res.value === null) {
|
|
86
98
|
// only fixed texts are shown: a 1Password failure by its classified message (B25), any other
|
|
87
99
|
// reason as a fixed "key store error" — never a reference, a path or tool output
|
|
@@ -92,8 +104,17 @@ export async function checkKeys(cfg, deps) {
|
|
|
92
104
|
const why = reasons.length > 0 ? ` (${reasons.join('; ')})` : '';
|
|
93
105
|
return { rows: [row('keys', 'WARN', 'keys', `jev key not resolved${why}; System 1 falls back to rules`)], key: null };
|
|
94
106
|
}
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
if (isTime(res.exp) && res.exp - now < 3_600_000) {
|
|
108
|
+
// whole minutes left, never 0 and never 60 (as keys list)
|
|
109
|
+
const minutes = Math.max(1, Math.floor((res.exp - now) / 60_000));
|
|
110
|
+
return {
|
|
111
|
+
rows: [row('keys', 'WARN', 'keys', `jev key from ${res.source} expires in ${minutes} min; re-link with code-forge keys set jev --op <ref from keys.jev>`)],
|
|
112
|
+
key: res.value,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const exp = res.exp === null ? 'no expiry' : isTime(res.exp) ? `expires ${new Date(res.exp).toISOString()}` : 'expiry unknown';
|
|
116
|
+
const hours = cache.source === 'config' ? ` (cache ${cache.hours} h)` : '';
|
|
117
|
+
return { rows: [row('keys', 'OK', 'keys', `jev key from ${res.source}, ${exp}${hours}`)], key: res.value };
|
|
97
118
|
} catch (err) {
|
|
98
119
|
return { rows: [row('keys', 'FAIL', 'keys', `jev key resolution failed (${err?.code ?? err?.name ?? 'error'})`)], key: null };
|
|
99
120
|
}
|
package/src/doctor/probes.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { mergeForbidden, renderForCodex } from '../util/forbidden.mjs';
|
|
|
32
32
|
import { buildArgv } from '../engines/builders/index.mjs';
|
|
33
33
|
import { probeHelpText } from '../engines/probe.mjs';
|
|
34
34
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
35
|
+
import { DEFAULT_SANDBOX_PROFILE, SANDBOX_PROFILES } from '../engines/sandbox-dirs.mjs';
|
|
35
36
|
import { commandOnPath } from '../install/detect.mjs';
|
|
36
37
|
import { classifyUnavailable, readAnswer, spawnSession } from '../session/spawn.mjs';
|
|
37
38
|
import { S2_SCHEMA } from '../session/s2.mjs';
|
|
@@ -98,6 +99,43 @@ export function configuredProviders(cfg) {
|
|
|
98
99
|
return [...out];
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/** B93: the coders `coder.sandbox` never changes, by provider, in the order doctor names them. */
|
|
103
|
+
const SANDBOX_NO_EFFECT = Object.freeze([
|
|
104
|
+
['anthropic', 'Claude (anthropic)'],
|
|
105
|
+
['xai', 'Grok (xai)'],
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* B93: the `coder.sandbox` rows. None without the key, for a null config, or for a value the
|
|
110
|
+
* schema refuses (a non-mapping, an unknown profile: config validation reports those). With it:
|
|
111
|
+
* - one INFO row `coder-sandbox` naming the profile (and, for `toolchain`, how many extra dirs);
|
|
112
|
+
* - one INFO row `coder-sandbox-no-effect` only when a Claude (anthropic) or Grok (xai) coder is
|
|
113
|
+
* configured (any level or fallback): the key has no effect on it, its argv does not change;
|
|
114
|
+
* - one WARN row `coder-sandbox-full` when the profile is `full` (`-s danger-full-access`).
|
|
115
|
+
* Rows name the profile and a count, never a directory.
|
|
116
|
+
* @param {Record<string, any> | null | undefined} cfg
|
|
117
|
+
* @returns {import('./rows.mjs').Row[]}
|
|
118
|
+
*/
|
|
119
|
+
export function checkCoderSandbox(cfg) {
|
|
120
|
+
const sandbox = cfg?.coder?.sandbox;
|
|
121
|
+
if (sandbox === null || typeof sandbox !== 'object' || Array.isArray(sandbox)) return [];
|
|
122
|
+
const profile = sandbox.profile ?? DEFAULT_SANDBOX_PROFILE;
|
|
123
|
+
if (!SANDBOX_PROFILES.includes(profile)) return [];
|
|
124
|
+
const dirs = Array.isArray(sandbox.add_dirs) ? sandbox.add_dirs.length : 0;
|
|
125
|
+
const extra = profile === 'toolchain' ? `, ${dirs} extra writable ${dirs === 1 ? 'dir' : 'dirs'}` : '';
|
|
126
|
+
const rows = [row('coder-sandbox', 'INFO', 'coder sandbox', `profile ${profile}${extra}; applies to the Codex (openai) coder only`)];
|
|
127
|
+
const providers = configuredProviders(cfg);
|
|
128
|
+
const others = SANDBOX_NO_EFFECT.filter(([id]) => providers.includes(id)).map(([, name]) => name);
|
|
129
|
+
if (others.length > 0) {
|
|
130
|
+
const who = others.length === 1 ? `the ${others[0]} coder` : `the ${others.join(' and ')} coders`;
|
|
131
|
+
rows.push(row('coder-sandbox-no-effect', 'INFO', 'coder sandbox', `coder.sandbox has no effect on ${who}: the argv does not change`));
|
|
132
|
+
}
|
|
133
|
+
if (profile === 'full') {
|
|
134
|
+
rows.push(row('coder-sandbox-full', 'WARN', 'coder sandbox', 'profile full runs the Codex coder with -s danger-full-access: it can write anywhere you can; only the forbidden list, the transcript grep and the gates limit it'));
|
|
135
|
+
}
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
|
138
|
+
|
|
101
139
|
/** @param {Record<string, any>} cfg @returns {Record<string, any>} the config with every level's fallback removed. */
|
|
102
140
|
function withoutFallback(cfg) {
|
|
103
141
|
/** @type {Record<string, any>} */
|
|
@@ -65,6 +65,8 @@ import { CODEX_RULES_FILE_NAME, defaultCodexHome, isInside, prepareCodexHome } f
|
|
|
65
65
|
import { hasAnyRenderedRule } from '../render-rules.mjs';
|
|
66
66
|
import { assertEffortForProvider, PROVIDER_EFFORTS } from '../efforts.mjs';
|
|
67
67
|
import { assertBaseParams } from './validate-params.mjs';
|
|
68
|
+
import { checkSandboxDirs, DEFAULT_SANDBOX_PROFILE, SandboxDirError, sandboxRefusalMessage, SANDBOX_PROFILES } from '../sandbox-dirs.mjs';
|
|
69
|
+
import { tmpBase } from '../../util/tmp.mjs';
|
|
68
70
|
import { CODEX_CLOSED_BOOK_REFUSAL, isNeverOpenBookRole, isNoToolRole } from '../../config/closed-book.mjs';
|
|
69
71
|
|
|
70
72
|
export const CLI = 'codex';
|
|
@@ -123,6 +125,11 @@ export const VALID_EFFORTS = PROVIDER_EFFORTS.openai;
|
|
|
123
125
|
* @property {string} [codexHome] - coder role only: the per-session Codex home to create (absolute,
|
|
124
126
|
* must not exist as the real `~/.codex` or inside it); defaults to `defaultCodexHome()` under the
|
|
125
127
|
* run temp root.
|
|
128
|
+
* @property {{profile?: string, add_dirs?: string[]}} [sandbox] - B93, coder role only: the
|
|
129
|
+
* `coder.sandbox` config value. Absent ⇒ today's argv. `toolchain` adds one `--add-dir <abs>`
|
|
130
|
+
* per checked `add_dirs` entry; `full` builds `-s danger-full-access`. A refused entry throws
|
|
131
|
+
* a `SandboxDirError` (code `sandbox-dir-refused`) naming the key path, never the value.
|
|
132
|
+
* @property {string} [runRoot] - B93, coder role only: the run temp root, refused as an `add_dirs` tree.
|
|
126
133
|
*/
|
|
127
134
|
|
|
128
135
|
/**
|
|
@@ -158,13 +165,23 @@ function assertValidEffort(effort) {
|
|
|
158
165
|
/**
|
|
159
166
|
* The Codex coder argv itself — pure, no I/O, no rules file (B4.2). `buildCoderArgv` uses it, and
|
|
160
167
|
* the doctor's flags probe builds it with placeholder values for its static conflict check.
|
|
161
|
-
*
|
|
168
|
+
* B93: `profile` `full` swaps `-s workspace-write` for `-s danger-full-access`; `addDirs` (already
|
|
169
|
+
* checked and resolved, `toolchain` only) follow `-s workspace-write` as `--add-dir <abs>` pairs.
|
|
170
|
+
* The tmp exclusions stay in every profile: under `danger-full-access` Codex ignores the
|
|
171
|
+
* `sandbox_workspace_write.*` keys, and one argv shape keeps the forbidden-list check the same.
|
|
172
|
+
* @param {{model: string, effort?: string, cwd: string, outPath: string, promptPath: string, profile?: string, addDirs?: ReadonlyArray<string>}} p
|
|
162
173
|
* @returns {string[]}
|
|
163
174
|
*/
|
|
164
|
-
export function coderFlagArgv({ model, effort, cwd, outPath, promptPath }) {
|
|
175
|
+
export function coderFlagArgv({ model, effort, cwd, outPath, promptPath, profile = DEFAULT_SANDBOX_PROFILE, addDirs = [] }) {
|
|
176
|
+
if (!SANDBOX_PROFILES.includes(profile)) throw new TypeError(`coderFlagArgv: profile must be one of ${SANDBOX_PROFILES.join(', ')}`);
|
|
165
177
|
const argv = ['codex', 'exec', '-m', model];
|
|
166
178
|
if (effort) argv.push('-c', `model_reasoning_effort=${effort}`);
|
|
167
|
-
|
|
179
|
+
if (profile === 'full') {
|
|
180
|
+
argv.push('-s', 'danger-full-access');
|
|
181
|
+
} else {
|
|
182
|
+
argv.push('-s', 'workspace-write');
|
|
183
|
+
if (profile === 'toolchain') for (const dir of addDirs) argv.push('--add-dir', dir);
|
|
184
|
+
}
|
|
168
185
|
// INVARIANT (B4.1 fix round 1): the session CODEX_HOME lives under the run temp root, so the
|
|
169
186
|
// sandbox must not make $TMPDIR or /tmp writable — the project cwd is then the only writable
|
|
170
187
|
// root, and a home inside the cwd is refused below. See `../codex-home.mjs`.
|
|
@@ -178,6 +195,26 @@ export function coderFlagArgv({ model, effort, cwd, outPath, promptPath }) {
|
|
|
178
195
|
return argv;
|
|
179
196
|
}
|
|
180
197
|
|
|
198
|
+
/**
|
|
199
|
+
* B93: the checked sandbox of one coder build. Absent ⇒ the default profile with no extra dir.
|
|
200
|
+
* The `add_dirs` are checked here too (defence in depth: validation checks them first), against
|
|
201
|
+
* the protected home dirs plus `extra` (the session CODEX_HOME, the run temp root and base).
|
|
202
|
+
* @param {unknown} sandbox @param {ReadonlyArray<string | undefined>} extra
|
|
203
|
+
* @returns {{profile: string, addDirs: string[]}}
|
|
204
|
+
*/
|
|
205
|
+
function coderSandbox(sandbox, extra) {
|
|
206
|
+
if (sandbox === undefined || sandbox === null) return { profile: DEFAULT_SANDBOX_PROFILE, addDirs: [] };
|
|
207
|
+
if (typeof sandbox !== 'object' || Array.isArray(sandbox)) throw new TypeError('buildCodexArgv: coder.sandbox must be a mapping');
|
|
208
|
+
const s = /** @type {{profile?: unknown, add_dirs?: unknown}} */ (sandbox);
|
|
209
|
+
const profile = s.profile ?? DEFAULT_SANDBOX_PROFILE;
|
|
210
|
+
if (typeof profile !== 'string' || !SANDBOX_PROFILES.includes(profile)) {
|
|
211
|
+
throw new TypeError(`buildCodexArgv: coder.sandbox.profile must be one of ${SANDBOX_PROFILES.join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
const checked = checkSandboxDirs(s.add_dirs, { extra });
|
|
214
|
+
if ('keyPath' in checked) throw new SandboxDirError(sandboxRefusalMessage(checked));
|
|
215
|
+
return { profile, addDirs: profile === 'toolchain' ? checked.dirs : [] };
|
|
216
|
+
}
|
|
217
|
+
|
|
181
218
|
/**
|
|
182
219
|
* @param {CodexBuildParams} params
|
|
183
220
|
* @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}}}
|
|
@@ -189,7 +226,6 @@ function buildCoderArgv(params) {
|
|
|
189
226
|
if (!hasAnyRenderedRule(renderedForbidden)) {
|
|
190
227
|
throw new Error('buildCodexArgv: coder role requires a non-empty forbidden-list render (got 0 usable patterns)');
|
|
191
228
|
}
|
|
192
|
-
const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath });
|
|
193
229
|
const codexHome = params.codexHome ?? defaultCodexHome();
|
|
194
230
|
// Fix round 4: a non-absolute home is refused here, before any I/O, and the containment check
|
|
195
231
|
// below then runs EVERY time (no `isAbsolute` short-circuit that could skip it).
|
|
@@ -202,6 +238,10 @@ function buildCoderArgv(params) {
|
|
|
202
238
|
if (isInside(codexHome, cwd)) {
|
|
203
239
|
throw new Error('buildCodexArgv: the session CODEX_HOME must not be inside the coder cwd (the sandbox can write there)');
|
|
204
240
|
}
|
|
241
|
+
// B93 (fix round 1): the sandbox dirs are checked only after the home is known to be absolute
|
|
242
|
+
// and outside the cwd, so the home they are checked against is the one the session will use.
|
|
243
|
+
const sandbox = coderSandbox(params.sandbox, [codexHome, params.runRoot, tmpBase()]);
|
|
244
|
+
const argv = coderFlagArgv({ model, effort, cwd, outPath, promptPath, ...sandbox });
|
|
205
245
|
const home = prepareCodexHome(codexHome, renderedForbidden);
|
|
206
246
|
return {
|
|
207
247
|
cli: CLI,
|