vouchington-tooling 0.11.0 → 0.11.2
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/README.md +17 -0
- package/dist/agent-blackboard/snapshot-cleanup-receipt.mjs +2 -0
- package/dist/agent-blackboard/snapshot-cleanup-resume.d.mts +1 -1
- package/dist/agent-blackboard/snapshot-cleanup-resume.mjs +3 -6
- package/dist/agent-harness-config/apply.d.mts +4 -0
- package/dist/agent-harness-config/apply.mjs +72 -0
- package/dist/agent-harness-config/cli.d.mts +10 -0
- package/dist/agent-harness-config/cli.mjs +100 -0
- package/dist/agent-harness-config/index.d.mts +4 -0
- package/dist/agent-harness-config/index.mjs +3 -0
- package/dist/agent-harness-config/merge-json.d.mts +5 -0
- package/dist/agent-harness-config/merge-json.mjs +62 -0
- package/dist/agent-harness-config/merge-toml.d.mts +6 -0
- package/dist/agent-harness-config/merge-toml.mjs +132 -0
- package/dist/agent-harness-config/paths.d.mts +3 -0
- package/dist/agent-harness-config/paths.mjs +117 -0
- package/dist/agent-harness-config/policy.d.mts +14 -0
- package/dist/agent-harness-config/policy.mjs +51 -0
- package/dist/agent-harness-config/prerequisites.d.mts +2 -0
- package/dist/agent-harness-config/prerequisites.mjs +67 -0
- package/dist/agent-harness-config/toml-value.d.mts +6 -0
- package/dist/agent-harness-config/toml-value.mjs +69 -0
- package/dist/agent-harness-config/types.d.mts +88 -0
- package/dist/agent-harness-config/types.mjs +1 -0
- package/dist/browser-session-runner/watchdog.mjs +1 -0
- package/dist/cli/index.mjs +9 -6
- package/dist/cli/parse.d.mts +3 -0
- package/dist/cli/parse.mjs +2 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +5 -0
- package/dist/gha-artifacts-cleanup/classify.mjs +1 -0
- package/dist/gha-post-review/claude-token.mjs +2 -0
- package/dist/gha-post-review/github.mjs +1 -0
- package/dist/gha-post-review/post.mjs +7 -1
- package/dist/gha-pr-checkpoint/codec.mjs +1 -0
- package/dist/gha-review-payload/diff.mjs +1 -0
- package/dist/gha-review-payload/payload.mjs +2 -0
- package/dist/gha-workspace-policy/shared.mjs +1 -0
- package/dist/http-link-pagination/index.mjs +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/dist/pnpm-deploy/normalize.mjs +1 -0
- package/dist/pnpm-install/install-operations.d.mts +2 -1
- package/dist/pnpm-install/install-operations.mjs +16 -4
- package/dist/pnpm-install/metadata.d.mts +3 -3
- package/dist/pnpm-install/metadata.mjs +10 -24
- package/dist/pnpm-install/pending-builds.d.mts +6 -14
- package/dist/pnpm-install/pending-builds.mjs +23 -91
- package/dist/pnpm-install/pnpm-install-fake-pnpm.test-helpers.mjs +15 -1
- package/dist/pnpm-install/runner.mjs +27 -59
- package/dist/pnpm-install/transition.d.mts +2 -7
- package/dist/pnpm-install/transition.mjs +3 -11
- package/dist/runner-port-policy/index.mjs +2 -0
- package/dist/session-friction/log.mjs +1 -0
- package/dist/skill-discovery/manifest.mjs +2 -0
- package/dist/sql-ast/constraint-do-block.mjs +1 -0
- package/dist/sql-ast/drop-index.mjs +2 -0
- package/dist/vitest-blob-manifest/reports-cli.mjs +2 -0
- package/docs/agent-harness-config.md +134 -0
- package/package.json +8 -1
- package/dist/cli/commands/post-review.d.mts +0 -1
- package/dist/cli/commands/post-review.mjs +0 -4
- package/dist/cli/commands/stage-review-payload.d.mts +0 -1
- package/dist/cli/commands/stage-review-payload.mjs +0 -4
- package/dist/cli/commands/swift-semantic-equal.d.mts +0 -1
- package/dist/cli/commands/swift-semantic-equal.mjs +0 -4
package/README.md
CHANGED
|
@@ -18,6 +18,9 @@ vouchington runner-port-policy
|
|
|
18
18
|
vouchington runner-port-policy --file ./policy.json
|
|
19
19
|
vouchington runner-port-policy --reserved 2200
|
|
20
20
|
vouchington with-host-lock --name expensive-build --timeout-seconds 60 -- make build
|
|
21
|
+
vouchington agent-harness-config dump
|
|
22
|
+
vouchington agent-harness-config check --global
|
|
23
|
+
vouchington agent-harness-config apply --global --repo /path/to/checkout
|
|
21
24
|
vouchington gha-runtime-audit --pr-workflow CI --push-workflow '/^Main CI \\(.+\\)$/'
|
|
22
25
|
vouchington require-up-to-date --remote origin --branch main
|
|
23
26
|
vouchington gitleaks-directory-scan --config .gitleaks.toml
|
|
@@ -143,6 +146,11 @@ import {
|
|
|
143
146
|
import { initSqlAst, extractCreateTableMetadata } from 'vouchington-tooling/sql-ast'
|
|
144
147
|
import { splitSqlStatements, stripSqlComments } from 'vouchington-tooling/sql-scanner'
|
|
145
148
|
import { auditCiJobRuntime } from 'vouchington-tooling/gha-runtime-audit'
|
|
149
|
+
import {
|
|
150
|
+
applyHarnessConfig,
|
|
151
|
+
checkHarnessConfig,
|
|
152
|
+
dumpHarnessPolicy,
|
|
153
|
+
} from 'vouchington-tooling/agent-harness-config'
|
|
146
154
|
import {
|
|
147
155
|
readVitestReportAttempts,
|
|
148
156
|
writeVitestBlobManifest,
|
|
@@ -223,6 +231,15 @@ throws on a `run:` value that is a YAML alias or a multiline PLAIN scalar, shape
|
|
|
223
231
|
scan safely. Both functions scan already-in-scope source text — deciding which files count as a
|
|
224
232
|
shell script or a workflow/action YAML file is left to the caller.
|
|
225
233
|
|
|
234
|
+
`agent-harness-config` merges classifier-auto and sandbox keys into Claude, Codex, Grok, and Cursor
|
|
235
|
+
config files. See [docs/agent-harness-config.md](./docs/agent-harness-config.md). `--global` updates
|
|
236
|
+
home-directory configs; `--repo` updates a checkout. It does not copy allowlists, hooks, or plugins.
|
|
237
|
+
Grok uses `permission_mode = "auto"` plus `auto_mode.enabled` and `default_auto_mode` so the
|
|
238
|
+
classifier stays available in plan mode; its defined sandbox profile still requires
|
|
239
|
+
`--sandbox workspace-write` at launch. Cursor uses global `approvalMode = "auto-review"`;
|
|
240
|
+
`unrestricted` disables the sandbox. Repo checks surface user-level trust or mode prerequisites
|
|
241
|
+
instead of claiming that repo files alone activate them.
|
|
242
|
+
|
|
226
243
|
`checkWorkspaceGatesPolicy` rejects tracked test assertions that hard-code the exact version of a
|
|
227
244
|
dependency declared by a non-fixture package manifest. Assert dependency membership or placement,
|
|
228
245
|
or derive a configuration or documentation package spec from that manifest instead.
|
|
@@ -35,7 +35,9 @@ function canonical(receipt, signature) {
|
|
|
35
35
|
!/^[0-9a-f-]{36}$/.test(token) ||
|
|
36
36
|
!Array.isArray(partitions))
|
|
37
37
|
throw new Error('partition directory cleanup receipt is invalid');
|
|
38
|
+
// oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- retain the validated numeric type after runtime receipt checks
|
|
38
39
|
const normalizedDirectoryDev = directoryDev;
|
|
40
|
+
// oxlint-disable-next-line no-mistakes/ts-no-const-aliases -- retain the validated numeric type after runtime receipt checks
|
|
39
41
|
const normalizedDirectoryIno = directoryIno;
|
|
40
42
|
const normalized = partitions.map((partition) => {
|
|
41
43
|
if (!record(partition) ||
|
|
@@ -10,7 +10,7 @@ declare const defaults: {
|
|
|
10
10
|
unlink: typeof unlink;
|
|
11
11
|
};
|
|
12
12
|
export declare function setResumeFilesystemForTest(overrides?: Partial<typeof defaults>): void;
|
|
13
|
+
export declare function requireResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
|
|
13
14
|
export declare function writeResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
|
|
14
15
|
export declare function removeResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
|
|
15
|
-
export declare function requireResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
|
|
16
16
|
export {};
|
|
@@ -25,7 +25,7 @@ function assertResumeFile(info, links = 1) {
|
|
|
25
25
|
(typeof process.geteuid === 'function' && info.uid !== process.geteuid()))
|
|
26
26
|
throw new Error('partition directory cleanup resume metadata is unsafe');
|
|
27
27
|
}
|
|
28
|
-
async function
|
|
28
|
+
export async function requireResumeReceipt(receipt) {
|
|
29
29
|
const path = pathFor(receipt);
|
|
30
30
|
let before = await filesystem.lstat(path);
|
|
31
31
|
if (before.nlink === 2) {
|
|
@@ -80,11 +80,11 @@ export async function writeResumeReceipt(receipt) {
|
|
|
80
80
|
await file?.close();
|
|
81
81
|
await filesystem.unlink(temporary).catch(() => undefined);
|
|
82
82
|
}
|
|
83
|
-
await
|
|
83
|
+
await requireResumeReceipt(receipt);
|
|
84
84
|
}
|
|
85
85
|
export async function removeResumeReceipt(receipt) {
|
|
86
86
|
try {
|
|
87
|
-
await
|
|
87
|
+
await requireResumeReceipt(receipt);
|
|
88
88
|
}
|
|
89
89
|
catch (error) {
|
|
90
90
|
if (error.code === 'ENOENT')
|
|
@@ -93,6 +93,3 @@ export async function removeResumeReceipt(receipt) {
|
|
|
93
93
|
}
|
|
94
94
|
await filesystem.rm(pathFor(receipt), { force: false });
|
|
95
95
|
}
|
|
96
|
-
export async function requireResumeReceipt(receipt) {
|
|
97
|
-
await read(receipt);
|
|
98
|
-
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ApplyTarget, HarnessApplyResult, HarnessCheckResult, HarnessConfigOptions, HarnessPlan } from './types.mts';
|
|
2
|
+
export declare function planHarnessConfig(target: ApplyTarget, options?: HarnessConfigOptions): HarnessPlan;
|
|
3
|
+
export declare function checkHarnessConfig(target: ApplyTarget, options?: HarnessConfigOptions): Promise<HarnessCheckResult>;
|
|
4
|
+
export declare function applyHarnessConfig(target: ApplyTarget, options?: HarnessConfigOptions): Promise<HarnessApplyResult>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { applyJsonPatches } from './merge-json.mjs';
|
|
4
|
+
import { applyTomlPatches } from './merge-toml.mjs';
|
|
5
|
+
import { planGlobalFiles, planRepoFiles } from './paths.mjs';
|
|
6
|
+
import { checkHarnessPrerequisites } from './prerequisites.mjs';
|
|
7
|
+
export function planHarnessConfig(target, options) {
|
|
8
|
+
const files = target.kind === 'global' ? planGlobalFiles(options) : planRepoFiles(target.root, options);
|
|
9
|
+
return { files, target };
|
|
10
|
+
}
|
|
11
|
+
async function readOptional(path) {
|
|
12
|
+
try {
|
|
13
|
+
return { exists: true, source: await readFile(path, 'utf8') };
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT')
|
|
17
|
+
return { exists: false, source: '' };
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function patched(plan, source) {
|
|
22
|
+
return plan.format === 'json'
|
|
23
|
+
? applyJsonPatches(source, plan.patches, plan.path)
|
|
24
|
+
: applyTomlPatches(source, plan.patches, plan.path);
|
|
25
|
+
}
|
|
26
|
+
function fileResult(plan, exists, drifts) {
|
|
27
|
+
if (!exists)
|
|
28
|
+
return { action: 'missing', drifts, path: plan.path };
|
|
29
|
+
return { action: drifts.length === 0 ? 'ok' : 'update', drifts, path: plan.path };
|
|
30
|
+
}
|
|
31
|
+
export async function checkHarnessConfig(target, options) {
|
|
32
|
+
const plan = planHarnessConfig(target, options);
|
|
33
|
+
const files = [];
|
|
34
|
+
for (const file of plan.files) {
|
|
35
|
+
const { exists, source } = await readOptional(file.path);
|
|
36
|
+
files.push(fileResult(file, exists, patched(file, source).drifts));
|
|
37
|
+
}
|
|
38
|
+
const prerequisites = await checkHarnessPrerequisites(target.kind === 'repo' ? target.root : undefined, options);
|
|
39
|
+
return {
|
|
40
|
+
compliant: files.every((file) => file.action === 'ok') &&
|
|
41
|
+
prerequisites.every((prerequisite) => prerequisite.satisfied),
|
|
42
|
+
files,
|
|
43
|
+
prerequisites,
|
|
44
|
+
target,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export async function applyHarnessConfig(target, options) {
|
|
48
|
+
const plan = planHarnessConfig(target, options);
|
|
49
|
+
const files = [];
|
|
50
|
+
const written = [];
|
|
51
|
+
for (const file of plan.files) {
|
|
52
|
+
const { exists, source } = await readOptional(file.path);
|
|
53
|
+
const next = patched(file, source);
|
|
54
|
+
const result = fileResult(file, exists, next.drifts);
|
|
55
|
+
if (result.action === 'ok') {
|
|
56
|
+
files.push(result);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
60
|
+
await writeFile(file.path, next.text);
|
|
61
|
+
files.push({ ...result, action: exists ? 'update' : 'create' });
|
|
62
|
+
written.push(file.path);
|
|
63
|
+
}
|
|
64
|
+
const prerequisites = await checkHarnessPrerequisites(target.kind === 'repo' ? target.root : undefined, options);
|
|
65
|
+
return {
|
|
66
|
+
compliant: prerequisites.every((prerequisite) => prerequisite.satisfied),
|
|
67
|
+
files,
|
|
68
|
+
prerequisites,
|
|
69
|
+
target,
|
|
70
|
+
written,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type HarnessId } from './types.mts';
|
|
2
|
+
export interface ParsedAgentHarnessConfig {
|
|
3
|
+
readonly action: 'dump' | 'check' | 'apply';
|
|
4
|
+
readonly harnesses?: readonly HarnessId[];
|
|
5
|
+
readonly home?: string;
|
|
6
|
+
readonly repos: readonly string[];
|
|
7
|
+
readonly global: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseAgentHarnessConfigArguments(argv: readonly string[]): ParsedAgentHarnessConfig;
|
|
10
|
+
export declare function runAgentHarnessConfigCli(argv: readonly string[]): Promise<number>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { dumpHarnessPolicy } from './policy.mjs';
|
|
2
|
+
import { applyHarnessConfig, checkHarnessConfig } from './apply.mjs';
|
|
3
|
+
import { HARNESS_IDS } from './types.mjs';
|
|
4
|
+
const AGENT_HARNESS_CONFIG_USAGE = 'agent-harness-config dump|check|apply [--global] [--repo PATH]... [--harness NAME]... [--home PATH]';
|
|
5
|
+
function isHarness(value) {
|
|
6
|
+
return HARNESS_IDS.includes(value);
|
|
7
|
+
}
|
|
8
|
+
export function parseAgentHarnessConfigArguments(argv) {
|
|
9
|
+
const action = argv[0];
|
|
10
|
+
if (action !== 'dump' && action !== 'check' && action !== 'apply') {
|
|
11
|
+
throw new Error(`unknown agent-harness-config action: ${action ?? ''}\n${AGENT_HARNESS_CONFIG_USAGE}`);
|
|
12
|
+
}
|
|
13
|
+
let global = false;
|
|
14
|
+
let home;
|
|
15
|
+
const repos = [];
|
|
16
|
+
const harnesses = [];
|
|
17
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
18
|
+
const flag = argv[index];
|
|
19
|
+
if (flag === '--global') {
|
|
20
|
+
global = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (flag === '--repo' || flag === '--harness' || flag === '--home') {
|
|
24
|
+
const value = argv[index + 1];
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
throw new Error(`${flag} requires a value\n${AGENT_HARNESS_CONFIG_USAGE}`);
|
|
27
|
+
index += 1;
|
|
28
|
+
if (flag === '--repo')
|
|
29
|
+
repos.push(value);
|
|
30
|
+
else if (flag === '--home')
|
|
31
|
+
home = value;
|
|
32
|
+
else if (!isHarness(value))
|
|
33
|
+
throw new Error(`unknown harness: ${value}`);
|
|
34
|
+
else
|
|
35
|
+
harnesses.push(value);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`unknown agent-harness-config option: ${flag}\n${AGENT_HARNESS_CONFIG_USAGE}`);
|
|
39
|
+
}
|
|
40
|
+
if (action !== 'dump' && !global && repos.length === 0) {
|
|
41
|
+
throw new Error(`check and apply require --global and/or --repo\n${AGENT_HARNESS_CONFIG_USAGE}`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
action,
|
|
45
|
+
global,
|
|
46
|
+
repos,
|
|
47
|
+
...(harnesses.length > 0 ? { harnesses } : {}),
|
|
48
|
+
...(home === undefined ? {} : { home }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function formatDrift(drift) {
|
|
52
|
+
return ` ${drift.path} ${drift.key}: ${JSON.stringify(drift.current)} -> ${JSON.stringify(drift.desired)}`;
|
|
53
|
+
}
|
|
54
|
+
function targets(parsed) {
|
|
55
|
+
const list = [];
|
|
56
|
+
if (parsed.global)
|
|
57
|
+
list.push({ kind: 'global' });
|
|
58
|
+
for (const root of parsed.repos)
|
|
59
|
+
list.push({ kind: 'repo', root });
|
|
60
|
+
return list;
|
|
61
|
+
}
|
|
62
|
+
export async function runAgentHarnessConfigCli(argv) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = parseAgentHarnessConfigArguments(argv);
|
|
65
|
+
if (parsed.action === 'dump') {
|
|
66
|
+
process.stdout.write(`${JSON.stringify(dumpHarnessPolicy(), null, 2)}\n`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
const options = {
|
|
70
|
+
...(parsed.harnesses === undefined ? {} : { harnesses: parsed.harnesses }),
|
|
71
|
+
...(parsed.home === undefined ? {} : { home: parsed.home }),
|
|
72
|
+
};
|
|
73
|
+
let compliant = true;
|
|
74
|
+
for (const target of targets(parsed)) {
|
|
75
|
+
const result = parsed.action === 'apply'
|
|
76
|
+
? await applyHarnessConfig(target, options)
|
|
77
|
+
: await checkHarnessConfig(target, options);
|
|
78
|
+
compliant &&=
|
|
79
|
+
parsed.action === 'apply'
|
|
80
|
+
? result.prerequisites.every((prerequisite) => prerequisite.satisfied)
|
|
81
|
+
: result.compliant;
|
|
82
|
+
for (const file of result.files) {
|
|
83
|
+
if (file.action === 'ok')
|
|
84
|
+
process.stdout.write(`ok ${file.path}\n`);
|
|
85
|
+
else
|
|
86
|
+
process.stdout.write(`${file.action} ${file.path}\n`);
|
|
87
|
+
for (const drift of file.drifts)
|
|
88
|
+
process.stdout.write(`${formatDrift(drift)}\n`);
|
|
89
|
+
}
|
|
90
|
+
for (const prerequisite of result.prerequisites) {
|
|
91
|
+
process.stdout.write(`prerequisite ${prerequisite.harness} ${prerequisite.key} satisfied=${prerequisite.satisfied}: ${prerequisite.message}\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return compliant ? 0 : 1;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { applyHarnessConfig, checkHarnessConfig, planHarnessConfig } from './apply.mts';
|
|
2
|
+
export { dumpHarnessPolicy, DEFAULT_EXTRA_WRITABLE_ROOTS } from './policy.mts';
|
|
3
|
+
export { HARNESS_IDS } from './types.mts';
|
|
4
|
+
export type { ApplyTarget, FilePlan, FileResult, HarnessApplyResult, HarnessCheckResult, HarnessConfigOptions, HarnessId, HarnessPlan, HarnessPolicyDump, JsonPatch, KeyDrift, TomlPatch, TomlValue, } from './types.mts';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { sameValue } from './policy.mjs';
|
|
2
|
+
function getJsonPath(value, path) {
|
|
3
|
+
let current = value;
|
|
4
|
+
for (const key of path) {
|
|
5
|
+
if (current === null || typeof current !== 'object' || Array.isArray(current))
|
|
6
|
+
return undefined;
|
|
7
|
+
current = current[key];
|
|
8
|
+
}
|
|
9
|
+
return current;
|
|
10
|
+
}
|
|
11
|
+
function setJsonPath(target, path, value) {
|
|
12
|
+
let current = target;
|
|
13
|
+
for (const key of path.slice(0, -1)) {
|
|
14
|
+
const next = current[key];
|
|
15
|
+
if (next === null || typeof next !== 'object' || Array.isArray(next)) {
|
|
16
|
+
current[key] = {};
|
|
17
|
+
}
|
|
18
|
+
current = current[key];
|
|
19
|
+
}
|
|
20
|
+
const last = path.at(-1);
|
|
21
|
+
if (last !== undefined)
|
|
22
|
+
current[last] = value;
|
|
23
|
+
}
|
|
24
|
+
function parseJsonObject(source, path) {
|
|
25
|
+
if (source.trim() === '')
|
|
26
|
+
return {};
|
|
27
|
+
const parsed = JSON.parse(source);
|
|
28
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
29
|
+
throw new Error(`${path}: expected a JSON object`);
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
function unionMerge(current, values) {
|
|
34
|
+
const base = Array.isArray(current) ? current : [];
|
|
35
|
+
const merged = [...base];
|
|
36
|
+
for (const value of values) {
|
|
37
|
+
if (!merged.some((entry) => sameValue(entry, value)))
|
|
38
|
+
merged.push(value);
|
|
39
|
+
}
|
|
40
|
+
return merged;
|
|
41
|
+
}
|
|
42
|
+
function desiredValue(document, patch) {
|
|
43
|
+
if (patch.merge !== 'union')
|
|
44
|
+
return patch.value;
|
|
45
|
+
return unionMerge(getJsonPath(document, patch.path), patch.value);
|
|
46
|
+
}
|
|
47
|
+
export function applyJsonPatches(source, patches, path) {
|
|
48
|
+
const document = parseJsonObject(source, path);
|
|
49
|
+
const resolved = patches.map((patch) => ({ desired: desiredValue(document, patch), patch }));
|
|
50
|
+
const drifts = [];
|
|
51
|
+
for (const { desired, patch } of resolved) {
|
|
52
|
+
const current = getJsonPath(document, patch.path);
|
|
53
|
+
if (!sameValue(current, desired)) {
|
|
54
|
+
drifts.push({ current, desired, key: patch.path.join('.'), path });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (drifts.length === 0)
|
|
58
|
+
return { drifts, text: source === '' ? '{}\n' : source };
|
|
59
|
+
for (const { desired, patch } of resolved)
|
|
60
|
+
setJsonPath(document, patch.path, desired);
|
|
61
|
+
return { drifts, text: `${JSON.stringify(document, null, 2)}\n` };
|
|
62
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { KeyDrift, TomlPatch } from './types.mts';
|
|
2
|
+
export declare function readTomlKey(text: string, table: string, key: string): unknown;
|
|
3
|
+
export declare function applyTomlPatches(source: string, patches: readonly TomlPatch[], path: string): {
|
|
4
|
+
drifts: KeyDrift[];
|
|
5
|
+
text: string;
|
|
6
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { parse as parseToml } from 'smol-toml';
|
|
2
|
+
import { sameValue } from './policy.mjs';
|
|
3
|
+
import { formatTomlValue, parseTomlValue } from './toml-value.mjs';
|
|
4
|
+
function lineStarts(text) {
|
|
5
|
+
const starts = [0];
|
|
6
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
7
|
+
if (text[index] === '\n')
|
|
8
|
+
starts.push(index + 1);
|
|
9
|
+
}
|
|
10
|
+
return starts;
|
|
11
|
+
}
|
|
12
|
+
function lineAt(text, start) {
|
|
13
|
+
const end = text.indexOf('\n', start);
|
|
14
|
+
return text.slice(start, end === -1 ? text.length : end);
|
|
15
|
+
}
|
|
16
|
+
function isHeaderLine(line) {
|
|
17
|
+
return line.trimStart().startsWith('[');
|
|
18
|
+
}
|
|
19
|
+
function headerName(line) {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (trimmed.startsWith('[[') || !trimmed.startsWith('['))
|
|
22
|
+
return undefined;
|
|
23
|
+
const close = trimmed.indexOf(']');
|
|
24
|
+
if (close <= 1)
|
|
25
|
+
return undefined;
|
|
26
|
+
return trimmed.slice(1, close);
|
|
27
|
+
}
|
|
28
|
+
function tableRange(text, table) {
|
|
29
|
+
const starts = lineStarts(text);
|
|
30
|
+
if (table === '') {
|
|
31
|
+
const first = starts.find((start) => isHeaderLine(lineAt(text, start)));
|
|
32
|
+
return { bodyEnd: first ?? text.length, bodyStart: 0 };
|
|
33
|
+
}
|
|
34
|
+
for (const start of starts) {
|
|
35
|
+
const line = lineAt(text, start);
|
|
36
|
+
if (headerName(line) !== table)
|
|
37
|
+
continue;
|
|
38
|
+
const bodyStart = start + line.length + (text[start + line.length] === '\n' ? 1 : 0);
|
|
39
|
+
const next = starts.find((candidate) => candidate >= bodyStart && isHeaderLine(lineAt(text, candidate)));
|
|
40
|
+
return { bodyEnd: next ?? text.length, bodyStart };
|
|
41
|
+
}
|
|
42
|
+
return { bodyEnd: -1, bodyStart: -1 };
|
|
43
|
+
}
|
|
44
|
+
function keyMatch(text, bodyStart, bodyEnd, key) {
|
|
45
|
+
const pattern = new RegExp(`^[ \\t]*${key.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}[ \\t]*=[ \\t]*`, 'm');
|
|
46
|
+
const slice = text.slice(bodyStart, bodyEnd);
|
|
47
|
+
const match = pattern.exec(slice);
|
|
48
|
+
if (!match || match.index === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
const equals = bodyStart + match.index + match[0].length;
|
|
51
|
+
return { equals, parsed: parseTomlValue(text, equals) };
|
|
52
|
+
}
|
|
53
|
+
export function readTomlKey(text, table, key) {
|
|
54
|
+
const range = tableRange(text, table);
|
|
55
|
+
if (range.bodyStart < 0)
|
|
56
|
+
return undefined;
|
|
57
|
+
return keyMatch(text, range.bodyStart, range.bodyEnd, key)?.parsed.value;
|
|
58
|
+
}
|
|
59
|
+
function appendTable(text, table, assignment) {
|
|
60
|
+
const prefix = text === '' || text.endsWith('\n') ? text : `${text}\n`;
|
|
61
|
+
const blank = prefix !== '' && !prefix.endsWith('\n\n') ? '\n' : '';
|
|
62
|
+
return `${prefix}${blank}[${table}]\n${assignment}\n`;
|
|
63
|
+
}
|
|
64
|
+
function upsertTomlKey(text, table, key, value) {
|
|
65
|
+
const assignment = `${key} = ${formatTomlValue(value)}`;
|
|
66
|
+
const range = tableRange(text, table);
|
|
67
|
+
if (range.bodyStart < 0)
|
|
68
|
+
return appendTable(text, table, assignment);
|
|
69
|
+
const existing = keyMatch(text, range.bodyStart, range.bodyEnd, key);
|
|
70
|
+
if (existing) {
|
|
71
|
+
return `${text.slice(0, existing.equals)}${formatTomlValue(value)}${text.slice(existing.parsed.end)}`;
|
|
72
|
+
}
|
|
73
|
+
const at = range.bodyEnd;
|
|
74
|
+
const prefix = at > 0 && text[at - 1] !== '\n' ? '\n' : '';
|
|
75
|
+
return `${text.slice(0, at)}${prefix}${assignment}\n${text.slice(at)}`;
|
|
76
|
+
}
|
|
77
|
+
function tomlPatchValue(parsed, table, key) {
|
|
78
|
+
let current = parsed;
|
|
79
|
+
if (table !== '') {
|
|
80
|
+
for (const segment of table.split('.')) {
|
|
81
|
+
/* v8 ignore next 2 -- if `text` parsed, every ancestor of a dotted table header is
|
|
82
|
+
guaranteed to be a table too, so a real TOML parser can never hand back a non-object
|
|
83
|
+
mid-path here. */
|
|
84
|
+
if (current === null || typeof current !== 'object' || Array.isArray(current))
|
|
85
|
+
return undefined;
|
|
86
|
+
current = current[segment];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/* v8 ignore next 2 -- assertValidToml only reaches a patch's table here once readTomlKey has
|
|
90
|
+
already proven (as a match, or by forcing the write that creates it) that the table is a
|
|
91
|
+
real nested object in this parsed document. */
|
|
92
|
+
if (current === null || typeof current !== 'object' || Array.isArray(current))
|
|
93
|
+
return undefined;
|
|
94
|
+
return current[key];
|
|
95
|
+
}
|
|
96
|
+
function assertValidToml(text, patches, path) {
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = parseToml(text);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
/* v8 ignore next -- smol-toml's parse() only ever throws TomlError, which extends Error, so
|
|
103
|
+
the non-Error fallback here is unreachable in practice. */
|
|
104
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
105
|
+
throw new Error(`${path}: produced invalid TOML (${message})`);
|
|
106
|
+
}
|
|
107
|
+
for (const patch of patches) {
|
|
108
|
+
const value = tomlPatchValue(parsed, patch.table, patch.key);
|
|
109
|
+
/* v8 ignore next 6 -- backstop for a formatTomlValue/parseToml encoding mismatch; formatTomlValue's
|
|
110
|
+
JSON.stringify-based escaping is a valid TOML basic string in every case this module writes,
|
|
111
|
+
so a real parser always reads back what we just wrote. */
|
|
112
|
+
if (!sameValue(value, patch.value)) {
|
|
113
|
+
const key = patch.table === '' ? patch.key : `${patch.table}.${patch.key}`;
|
|
114
|
+
throw new Error(`${path}: wrote ${key} but a real TOML parser reads back ${JSON.stringify(value)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function applyTomlPatches(source, patches, path) {
|
|
119
|
+
const drifts = [];
|
|
120
|
+
let text = source.replaceAll('\r\n', '\n');
|
|
121
|
+
for (const patch of patches) {
|
|
122
|
+
const current = readTomlKey(text, patch.table, patch.key);
|
|
123
|
+
if (sameValue(current, patch.value))
|
|
124
|
+
continue;
|
|
125
|
+
const key = patch.table === '' ? patch.key : `${patch.table}.${patch.key}`;
|
|
126
|
+
drifts.push({ current, desired: patch.value, key, path });
|
|
127
|
+
text = upsertTomlKey(text, patch.table, patch.key, patch.value);
|
|
128
|
+
}
|
|
129
|
+
if (drifts.length > 0)
|
|
130
|
+
assertValidToml(text, patches, path);
|
|
131
|
+
return { drifts, text };
|
|
132
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { CLAUDE_DEFAULT_MODE, CODEX_APPROVAL_POLICY, CODEX_APPROVALS_REVIEWER, CODEX_SANDBOX_MODE, CURSOR_APPROVAL_MODE, CURSOR_SANDBOX_MODE, DEFAULT_EXTRA_WRITABLE_ROOTS, GROK_AUTO_MODE_ENABLED, GROK_DEFAULT_AUTO_MODE, GROK_PERMISSION_MODE, } from './policy.mjs';
|
|
4
|
+
import { HARNESS_IDS } from './types.mjs';
|
|
5
|
+
function selected(options) {
|
|
6
|
+
return new Set(options?.harnesses ?? HARNESS_IDS);
|
|
7
|
+
}
|
|
8
|
+
function extraRoots(options) {
|
|
9
|
+
return options?.extraWritableRoots ?? DEFAULT_EXTRA_WRITABLE_ROOTS;
|
|
10
|
+
}
|
|
11
|
+
function wants(set, id) {
|
|
12
|
+
return set.has(id);
|
|
13
|
+
}
|
|
14
|
+
function codexPatches(roots) {
|
|
15
|
+
return {
|
|
16
|
+
format: 'toml',
|
|
17
|
+
patches: [
|
|
18
|
+
{ key: 'approval_policy', table: '', value: CODEX_APPROVAL_POLICY },
|
|
19
|
+
{ key: 'approvals_reviewer', table: '', value: CODEX_APPROVALS_REVIEWER },
|
|
20
|
+
{ key: 'sandbox_mode', table: '', value: CODEX_SANDBOX_MODE },
|
|
21
|
+
{ key: 'network_access', table: 'sandbox_workspace_write', value: true },
|
|
22
|
+
{ key: 'writable_roots', table: 'sandbox_workspace_write', value: roots },
|
|
23
|
+
],
|
|
24
|
+
path: '',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function grokSandboxPatches(roots) {
|
|
28
|
+
return {
|
|
29
|
+
format: 'toml',
|
|
30
|
+
patches: [
|
|
31
|
+
{ key: 'extends', table: 'profiles.workspace-write', value: 'workspace' },
|
|
32
|
+
{ key: 'read_write', table: 'profiles.workspace-write', value: roots },
|
|
33
|
+
],
|
|
34
|
+
path: '',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function homeDir(options) {
|
|
38
|
+
return resolve(options?.home ?? homedir());
|
|
39
|
+
}
|
|
40
|
+
export function planGlobalFiles(options) {
|
|
41
|
+
const harnesses = selected(options);
|
|
42
|
+
const roots = extraRoots(options);
|
|
43
|
+
const home = homeDir(options);
|
|
44
|
+
const files = [];
|
|
45
|
+
if (wants(harnesses, 'claude')) {
|
|
46
|
+
files.push({
|
|
47
|
+
format: 'json',
|
|
48
|
+
patches: [
|
|
49
|
+
{ path: ['permissions', 'defaultMode'], value: CLAUDE_DEFAULT_MODE },
|
|
50
|
+
{ path: ['sandbox', 'enabled'], value: true },
|
|
51
|
+
{ path: ['sandbox', 'failIfUnavailable'], value: true },
|
|
52
|
+
{ merge: 'union', path: ['sandbox', 'filesystem', 'allowWrite'], value: roots },
|
|
53
|
+
{ path: ['useAutoModeDuringPlan'], value: true },
|
|
54
|
+
],
|
|
55
|
+
path: join(home, '.claude', 'settings.json'),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (wants(harnesses, 'codex'))
|
|
59
|
+
files.push({ ...codexPatches(roots), path: join(home, '.codex', 'config.toml') });
|
|
60
|
+
if (wants(harnesses, 'grok')) {
|
|
61
|
+
files.push({ ...grokSandboxPatches(roots), path: join(home, '.grok', 'sandbox.toml') });
|
|
62
|
+
files.push({
|
|
63
|
+
format: 'toml',
|
|
64
|
+
patches: [
|
|
65
|
+
{ key: 'permission_mode', table: 'ui', value: GROK_PERMISSION_MODE },
|
|
66
|
+
{ key: 'enabled', table: 'auto_mode', value: GROK_AUTO_MODE_ENABLED },
|
|
67
|
+
{ key: 'default_auto_mode', table: '', value: GROK_DEFAULT_AUTO_MODE },
|
|
68
|
+
],
|
|
69
|
+
path: join(home, '.grok', 'config.toml'),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (wants(harnesses, 'cursor')) {
|
|
73
|
+
files.push({
|
|
74
|
+
format: 'json',
|
|
75
|
+
patches: [
|
|
76
|
+
{ path: ['approvalMode'], value: CURSOR_APPROVAL_MODE },
|
|
77
|
+
{ path: ['sandbox', 'mode'], value: CURSOR_SANDBOX_MODE },
|
|
78
|
+
],
|
|
79
|
+
path: join(home, '.cursor', 'cli-config.json'),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
84
|
+
export function planRepoFiles(root, options) {
|
|
85
|
+
const harnesses = selected(options);
|
|
86
|
+
const roots = extraRoots(options);
|
|
87
|
+
const repo = resolve(root);
|
|
88
|
+
const files = [];
|
|
89
|
+
if (wants(harnesses, 'claude')) {
|
|
90
|
+
files.push({
|
|
91
|
+
format: 'json',
|
|
92
|
+
patches: [
|
|
93
|
+
{ path: ['sandbox', 'enabled'], value: true },
|
|
94
|
+
{ path: ['sandbox', 'failIfUnavailable'], value: true },
|
|
95
|
+
{ merge: 'union', path: ['sandbox', 'filesystem', 'allowWrite'], value: roots },
|
|
96
|
+
],
|
|
97
|
+
path: join(repo, '.claude', 'settings.json'),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (wants(harnesses, 'codex'))
|
|
101
|
+
files.push({ ...codexPatches(roots), path: join(repo, '.codex', 'config.toml') });
|
|
102
|
+
if (wants(harnesses, 'grok'))
|
|
103
|
+
files.push({ ...grokSandboxPatches(roots), path: join(repo, '.grok', 'sandbox.toml') });
|
|
104
|
+
if (wants(harnesses, 'cursor')) {
|
|
105
|
+
files.push({
|
|
106
|
+
format: 'json',
|
|
107
|
+
patches: [
|
|
108
|
+
{ path: ['type'], value: 'workspace_readwrite' },
|
|
109
|
+
{ path: ['enableSharedBuildCache'], value: true },
|
|
110
|
+
{ path: ['networkPolicy', 'default'], value: 'allow' },
|
|
111
|
+
{ path: ['additionalReadwritePaths'], value: roots },
|
|
112
|
+
],
|
|
113
|
+
path: join(repo, '.cursor', 'sandbox.json'),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return files;
|
|
117
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HarnessPolicyDump } from './types.mts';
|
|
2
|
+
export declare const DEFAULT_EXTRA_WRITABLE_ROOTS: readonly ['~/.cargo', '~/Library/pnpm', '~/.local/share/pnpm', '~/.pnpm-store', '~/Library/Caches/no-mistakes', '~/Library/Caches/pnpm', '~/.pnpm-state', '~/.cache/no-mistakes', '~/.cache/pnpm', '~/.local/state/pnpm', '/var/folders', '/private/var/folders'];
|
|
3
|
+
export declare const CLAUDE_DEFAULT_MODE = "auto";
|
|
4
|
+
export declare const CODEX_APPROVAL_POLICY = "on-request";
|
|
5
|
+
export declare const CODEX_APPROVALS_REVIEWER = "auto_review";
|
|
6
|
+
export declare const CODEX_SANDBOX_MODE = "workspace-write";
|
|
7
|
+
export declare const GROK_PERMISSION_MODE = "auto";
|
|
8
|
+
export declare const GROK_SANDBOX_PROFILE = "workspace-write";
|
|
9
|
+
export declare const GROK_AUTO_MODE_ENABLED = true;
|
|
10
|
+
export declare const GROK_DEFAULT_AUTO_MODE = true;
|
|
11
|
+
export declare const CURSOR_APPROVAL_MODE = "auto-review";
|
|
12
|
+
export declare const CURSOR_SANDBOX_MODE = "enabled";
|
|
13
|
+
export declare function dumpHarnessPolicy(extraWritableRoots?: readonly string[]): HarnessPolicyDump;
|
|
14
|
+
export declare function sameValue(left: unknown, right: unknown): boolean;
|