unbound-cli 1.16.17 → 1.16.19
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/package.json +1 -1
- package/src/commands/setup.js +7 -0
- package/src/toolHealth.js +42 -26
- package/test/setup-pi.test.js +206 -0
- package/test/setup-tables.test.js +132 -0
- package/test/tool-health.test.js +92 -0
package/package.json
CHANGED
package/src/commands/setup.js
CHANGED
|
@@ -36,6 +36,7 @@ const SETUP_TOOLS = [
|
|
|
36
36
|
{ label: 'Gemini CLI', value: 'gemini', script: 'gemini-cli/gateway/setup.py' },
|
|
37
37
|
{ label: 'Codex \u2014 subscription (hooks)', value: 'codex-sub', script: 'codex/hooks/setup.py', group: 'codex' },
|
|
38
38
|
{ label: 'Codex \u2014 gateway (gateway)', value: 'codex-gw', script: 'codex/gateway/setup.py', group: 'codex' },
|
|
39
|
+
{ label: 'Pi Coding Agent', value: 'pi', script: 'pi/setup.py' },
|
|
39
40
|
];
|
|
40
41
|
|
|
41
42
|
// Labels drop the `(subscription)` suffix on the default mode and keep
|
|
@@ -49,6 +50,7 @@ const MDM_TOOLS = {
|
|
|
49
50
|
'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/mdm/setup.py' },
|
|
50
51
|
'codex-subscription': { label: 'Codex', script: 'codex/hooks/mdm/setup.py' },
|
|
51
52
|
'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/mdm/setup.py' },
|
|
53
|
+
'pi': { label: 'Pi Coding Agent', script: 'pi/mdm/setup.py' },
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
// Default MDM tools for `sudo unbound onboard` (subscription mode for Claude Code/Codex since only one can be active)
|
|
@@ -73,6 +75,7 @@ const SETUP_TOOL_MAP = {
|
|
|
73
75
|
'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/setup.py' },
|
|
74
76
|
'codex-subscription': { label: 'Codex', script: 'codex/hooks/setup.py' },
|
|
75
77
|
'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/setup.py' },
|
|
78
|
+
'pi': { label: 'Pi Coding Agent', script: 'pi/setup.py' },
|
|
76
79
|
};
|
|
77
80
|
|
|
78
81
|
/**
|
|
@@ -763,6 +766,7 @@ Available tools:
|
|
|
763
766
|
claude-code Claude Code (use --subscription or --gateway)
|
|
764
767
|
gemini-cli Gemini CLI
|
|
765
768
|
codex Codex (use --subscription or --gateway)
|
|
769
|
+
pi Pi Coding Agent
|
|
766
770
|
roo-code Roo Code (shows config values)
|
|
767
771
|
cline Cline (shows config values)
|
|
768
772
|
kilo-code Kilo Code (shows config values)
|
|
@@ -782,6 +786,7 @@ Examples:
|
|
|
782
786
|
$ unbound setup claude-code --gateway Claude Code gateway mode
|
|
783
787
|
$ unbound setup claude-code --subscription Claude Code hooks only
|
|
784
788
|
$ unbound setup codex --gateway Codex gateway mode
|
|
789
|
+
$ unbound setup pi Set up Pi Coding Agent
|
|
785
790
|
|
|
786
791
|
Install the default bundle (Cursor + Copilot + Augment + Claude Code hooks + Codex hooks):
|
|
787
792
|
$ unbound setup --all Set up the default bundle
|
|
@@ -1341,6 +1346,8 @@ module.exports = {
|
|
|
1341
1346
|
hasRootPrivileges,
|
|
1342
1347
|
ALL_TOOLS,
|
|
1343
1348
|
MDM_ALL_TOOLS,
|
|
1349
|
+
SETUP_TOOL_MAP,
|
|
1350
|
+
MDM_TOOLS,
|
|
1344
1351
|
buildScriptArgs,
|
|
1345
1352
|
configDirForScript,
|
|
1346
1353
|
scriptSupportsBackfill,
|
package/src/toolHealth.js
CHANGED
|
@@ -192,11 +192,17 @@ function envKeyCheck(label, name, kind = 'aux') {
|
|
|
192
192
|
if (!r.found || !r.value) {
|
|
193
193
|
return { name: label, ok: false, kind, detail: `${name} is not set`, summary: `${base} not set` };
|
|
194
194
|
}
|
|
195
|
+
// Offer the rc-file keys as fallbacks so a shell opened before setup rewrote
|
|
196
|
+
// the rc (stale key in process.env) isn't misread as tampered. Only for
|
|
197
|
+
// Unbound-owned vars: a user-owned var like OPENAI_API_KEY may carry the
|
|
198
|
+
// user's personal key beside ours, and doctor must never upload that.
|
|
199
|
+
const rcFallback = name.startsWith('UNBOUND_') ? rcEnvValues(name).map((v) => v.value) : [];
|
|
200
|
+
const candidates = [...new Set([r.value, ...rcFallback].filter(Boolean))];
|
|
195
201
|
return {
|
|
196
202
|
name: label, ok: true, kind,
|
|
197
203
|
detail: `${name} set (${r.source})`,
|
|
198
204
|
summary: `${base} set`,
|
|
199
|
-
keyCheck: true, envName: name, value: r.value,
|
|
205
|
+
keyCheck: true, envName: name, value: r.value, candidates,
|
|
200
206
|
};
|
|
201
207
|
}
|
|
202
208
|
|
|
@@ -482,43 +488,53 @@ function detectTools({ gatewayUrl, _mdmDirs, _binaryPath } = {}) {
|
|
|
482
488
|
// client can never wipe out the local-state view callers depend on.
|
|
483
489
|
async function validateToolKeys(tools, validateKey) {
|
|
484
490
|
if (!tools?.length) return tools;
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
491
|
+
const verdict = new Map();
|
|
492
|
+
const check = async (keys) => {
|
|
493
|
+
const pending = [...keys].filter((k) => k && !verdict.has(k));
|
|
494
|
+
for (const [k, r] of await Promise.all(pending.map(async (k) => {
|
|
495
|
+
try { return [k, await validateKey(k)]; } catch { return [k, 'unknown']; }
|
|
496
|
+
}))) verdict.set(k, r);
|
|
497
|
+
};
|
|
498
|
+
// Validate the live value first; consult the rc-file fallbacks only when it is
|
|
499
|
+
// rejected. Avoids needless calls on the healthy path and, with the
|
|
500
|
+
// UNBOUND_-only candidate rule above, keeps rc siblings off the wire until a
|
|
501
|
+
// stale shell forces the search.
|
|
502
|
+
const live = new Set();
|
|
503
|
+
for (const t of tools) for (const c of t.checks || []) if (c.keyCheck && c.value) live.add(c.value);
|
|
504
|
+
if (!live.size) return tools;
|
|
505
|
+
await check(live);
|
|
506
|
+
const fallbacks = new Set();
|
|
507
|
+
for (const t of tools) for (const c of t.checks || []) {
|
|
508
|
+
if (c.keyCheck && c.value && verdict.get(c.value) === 'invalid') {
|
|
509
|
+
for (const v of c.candidates || []) if (v !== c.value) fallbacks.add(v);
|
|
489
510
|
}
|
|
490
511
|
}
|
|
491
|
-
|
|
492
|
-
const entries = await Promise.all(
|
|
493
|
-
[...unique].map(async (k) => {
|
|
494
|
-
try {
|
|
495
|
-
return [k, await validateKey(k)];
|
|
496
|
-
} catch {
|
|
497
|
-
return [k, 'unknown'];
|
|
498
|
-
}
|
|
499
|
-
})
|
|
500
|
-
);
|
|
501
|
-
const validity = new Map(entries);
|
|
512
|
+
await check(fallbacks);
|
|
502
513
|
for (const t of tools) {
|
|
503
514
|
if (!t.checks?.length) continue;
|
|
504
515
|
let okFlipped = false;
|
|
505
516
|
for (const c of t.checks) {
|
|
506
|
-
if (!c.keyCheck) continue;
|
|
507
|
-
const
|
|
508
|
-
if (
|
|
517
|
+
if (!c.keyCheck || !c.value) continue;
|
|
518
|
+
const liveVerdict = verdict.get(c.value);
|
|
519
|
+
if (liveVerdict === 'valid') continue;
|
|
520
|
+
// rc fallbacks were fetched only when the live key was invalid.
|
|
521
|
+
const others = liveVerdict === 'invalid'
|
|
522
|
+
? (c.candidates || []).filter((v) => v !== c.value).map((v) => verdict.get(v))
|
|
523
|
+
: [];
|
|
524
|
+
if (others.includes('valid')) continue; // a configured rc key is valid — wiring intact
|
|
525
|
+
if (liveVerdict !== 'invalid' || others.includes('unknown')) {
|
|
526
|
+
// Gateway unreachable for the live key, or for the only surviving rc
|
|
527
|
+
// candidates — fail open so an offline run isn't read as tampered.
|
|
528
|
+
c.validationSkipped = true;
|
|
529
|
+
c.detail = `${c.detail}; validation skipped — gateway unreachable`;
|
|
530
|
+
} else {
|
|
531
|
+
// Live key rejected and no configured key is valid — genuinely invalid.
|
|
509
532
|
c.ok = false;
|
|
510
533
|
c.warn = true;
|
|
511
534
|
const base = c.name.replace(/ env$/, '');
|
|
512
535
|
c.summary = `${base} invalid`;
|
|
513
536
|
c.detail = `${c.envName} set but not valid for this tenant`;
|
|
514
537
|
okFlipped = true;
|
|
515
|
-
} else if (verdict === 'unknown') {
|
|
516
|
-
// Stay healthy (fail-open), but record the skip so the caller can
|
|
517
|
-
// surface it. Without this tag the operator can't tell an offline
|
|
518
|
-
// run from a fully-validated one — that's the WEB-4922 lesson.
|
|
519
|
-
// Does NOT flip `ok`, so the `statusOf` re-derivation stays guarded.
|
|
520
|
-
c.validationSkipped = true;
|
|
521
|
-
c.detail = `${c.detail}; validation skipped — gateway unreachable`;
|
|
522
538
|
}
|
|
523
539
|
}
|
|
524
540
|
// ONLY re-derive when a check's `ok` actually flipped. Without this guard,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
SETUP_TOOL_MAP,
|
|
7
|
+
MDM_TOOLS,
|
|
8
|
+
ALL_TOOLS,
|
|
9
|
+
MDM_ALL_TOOLS,
|
|
10
|
+
resolveSetupAllTools,
|
|
11
|
+
buildScriptArgs,
|
|
12
|
+
configDirForScript,
|
|
13
|
+
scriptSupportsBackfill,
|
|
14
|
+
} = require('../src/commands/setup');
|
|
15
|
+
|
|
16
|
+
const SETUP_SOURCE_PATH = path.join(__dirname, '..', 'src', 'commands', 'setup.js');
|
|
17
|
+
const SETUP_SOURCE = fs.readFileSync(SETUP_SOURCE_PATH, 'utf8');
|
|
18
|
+
|
|
19
|
+
// The 10-00 harness in test/setup-tables.test.js is a test file, not a module: it has
|
|
20
|
+
// no module.exports, and requiring it would re-run its five tests inside this file.
|
|
21
|
+
// The five-check shape below is therefore the same contract re-stated locally rather
|
|
22
|
+
// than imported — test/setup-tables.test.js stays byte-identical.
|
|
23
|
+
function sourceArrayBlock(name) {
|
|
24
|
+
const start = SETUP_SOURCE.indexOf(`const ${name} = [`);
|
|
25
|
+
assert.notEqual(start, -1, `${name} not found in setup.js`);
|
|
26
|
+
const end = SETUP_SOURCE.indexOf('];', start);
|
|
27
|
+
assert.notEqual(end, -1, `${name} is not terminated`);
|
|
28
|
+
return SETUP_SOURCE.slice(start, end);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The "Available tools:" block ends at the first blank line, before the
|
|
32
|
+
// "For multi-tool setup" section that lists the explicit mode names.
|
|
33
|
+
function helpToolLines() {
|
|
34
|
+
const start = SETUP_SOURCE.indexOf('Available tools:');
|
|
35
|
+
assert.notEqual(start, -1, 'the help text has no "Available tools:" block');
|
|
36
|
+
const block = SETUP_SOURCE.slice(start).split('\n\n')[0];
|
|
37
|
+
return block.split('\n').slice(1).filter((line) => line.trim());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HELP_TOOL_LINES = helpToolLines();
|
|
41
|
+
const HELP_TOOL_NAMES = HELP_TOOL_LINES.map((line) => line.trim().split(/\s+/)[0]);
|
|
42
|
+
const PICKER_BLOCK = sourceArrayBlock('SETUP_TOOLS');
|
|
43
|
+
|
|
44
|
+
// SETUP_ALL_TOOLS and MDM_SETUP_ALL_TOOLS are deliberately NOT exported — keeping them
|
|
45
|
+
// unexported is what holds this plan's setup.js diff at five lines — so their membership
|
|
46
|
+
// is read off the array literals, the same seam 10-00 already uses for the help text.
|
|
47
|
+
function sourceStringListBlock(name) {
|
|
48
|
+
const match = SETUP_SOURCE.match(new RegExp(`const ${name} = \\[([^\\]]*)\\]`));
|
|
49
|
+
assert.ok(match, `${name} not found as an array literal in setup.js`);
|
|
50
|
+
return match[1];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BUNDLES = {
|
|
54
|
+
// exported, asserted through the real object
|
|
55
|
+
ALL_TOOLS: (name) => ALL_TOOLS.includes(name),
|
|
56
|
+
MDM_ALL_TOOLS: (name) => MDM_ALL_TOOLS.includes(name),
|
|
57
|
+
// unexported, asserted through the source literal
|
|
58
|
+
SETUP_ALL_TOOLS: (name) => sourceStringListBlock('SETUP_ALL_TOOLS').includes(`'${name}'`),
|
|
59
|
+
MDM_SETUP_ALL_TOOLS: (name) => sourceStringListBlock('MDM_SETUP_ALL_TOOLS').includes(`'${name}'`),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function checkToolWired(name, spec = {}) {
|
|
63
|
+
const mdmKey = spec.mdmKey || name;
|
|
64
|
+
const entry = SETUP_TOOL_MAP[name];
|
|
65
|
+
const mdmEntry = MDM_TOOLS[mdmKey];
|
|
66
|
+
return {
|
|
67
|
+
inSetupMap: Boolean(entry) && entry.script === spec.script,
|
|
68
|
+
inMdmTools: Boolean(mdmEntry) && mdmEntry.script === spec.mdmScript,
|
|
69
|
+
inPicker: PICKER_BLOCK.includes(`value: '${name}'`),
|
|
70
|
+
inHelp: HELP_TOOL_NAMES.includes(name),
|
|
71
|
+
inSetupAllClear: resolveSetupAllTools(true).includes(name),
|
|
72
|
+
bundles: Object.keys(BUNDLES).filter((bundle) => BUNDLES[bundle](name)),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertToolWired(name, spec) {
|
|
77
|
+
const got = checkToolWired(name, spec);
|
|
78
|
+
assert.ok(got.inSetupMap, `${name}: SETUP_TOOL_MAP entry missing or script !== ${spec.script}`);
|
|
79
|
+
assert.ok(got.inMdmTools, `${name}: MDM_TOOLS entry missing or script !== ${spec.mdmScript}`);
|
|
80
|
+
assert.ok(got.inPicker, `${name}: absent from the interactive SETUP_TOOLS picker`);
|
|
81
|
+
assert.ok(got.inHelp, `${name}: absent from the "Available tools:" help block`);
|
|
82
|
+
assert.ok(got.inSetupAllClear, `${name}: absent from resolveSetupAllTools(true)`);
|
|
83
|
+
assert.deepEqual(got.bundles.sort(), [...spec.bundles].sort(), `${name}: bundle membership drifted`);
|
|
84
|
+
return got;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const PI_SPEC = {
|
|
88
|
+
script: 'pi/setup.py',
|
|
89
|
+
mdmScript: 'pi/mdm/setup.py',
|
|
90
|
+
bundles: [],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// INST-01/INST-02: `unbound setup pi` and `sudo unbound setup pi` resolve to the two
|
|
94
|
+
// Python installers through the dispatcher every other tool already uses.
|
|
95
|
+
test('pi is wired across the four setup tables and in no default bundle', () => {
|
|
96
|
+
const got = assertToolWired('pi', PI_SPEC);
|
|
97
|
+
assert.deepEqual(got.bundles, []);
|
|
98
|
+
assert.equal(SETUP_TOOL_MAP.pi.label, 'Pi Coding Agent');
|
|
99
|
+
assert.equal(SETUP_TOOL_MAP.pi.script, 'pi/setup.py');
|
|
100
|
+
assert.equal(MDM_TOOLS.pi.label, 'Pi Coding Agent');
|
|
101
|
+
assert.equal(MDM_TOOLS.pi.script, 'pi/mdm/setup.py');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// No `group` key: pi has a single mode, so the picker must not offer it as a
|
|
105
|
+
// subscription/gateway pair the way claude-code and codex are grouped.
|
|
106
|
+
test('the interactive picker entry for pi has a script, a label and no group', () => {
|
|
107
|
+
const line = PICKER_BLOCK.split('\n').find((l) => l.includes("value: 'pi'"));
|
|
108
|
+
assert.ok(line, `no SETUP_TOOLS entry with value: 'pi'\n${PICKER_BLOCK}`);
|
|
109
|
+
assert.ok(line.includes("label: 'Pi Coding Agent'"), line);
|
|
110
|
+
assert.ok(line.includes("script: 'pi/setup.py'"), line);
|
|
111
|
+
assert.ok(!line.includes('group:'), `pi has one mode, so the picker entry must carry no group: ${line}`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// `unbound setup --all` and `unbound onboard` print the bundles verbatim, so pi must
|
|
115
|
+
// stay out of all four — but `--all --clear` must still sweep it, because
|
|
116
|
+
// resolveSetupAllTools(true) returns every SETUP_TOOL_MAP key.
|
|
117
|
+
test('pi is absent from each of the four default bundles, asserted one by one', () => {
|
|
118
|
+
assert.equal(ALL_TOOLS.includes('pi'), false, 'pi leaked into ALL_TOOLS');
|
|
119
|
+
assert.equal(MDM_ALL_TOOLS.includes('pi'), false, 'pi leaked into MDM_ALL_TOOLS');
|
|
120
|
+
assert.equal(sourceStringListBlock('SETUP_ALL_TOOLS').includes("'pi'"), false,
|
|
121
|
+
'pi leaked into the SETUP_ALL_TOOLS literal');
|
|
122
|
+
assert.equal(sourceStringListBlock('MDM_SETUP_ALL_TOOLS').includes("'pi'"), false,
|
|
123
|
+
'pi leaked into the MDM_SETUP_ALL_TOOLS literal');
|
|
124
|
+
// the negative half: the regex reader is not vacuously returning nothing
|
|
125
|
+
assert.equal(sourceStringListBlock('SETUP_ALL_TOOLS').includes("'cursor'"), true);
|
|
126
|
+
assert.equal(sourceStringListBlock('MDM_SETUP_ALL_TOOLS').includes("'cursor'"), true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('resolveSetupAllTools sweeps pi on --all --clear but never installs it via --all', () => {
|
|
130
|
+
assert.ok(resolveSetupAllTools(true).includes('pi'), resolveSetupAllTools(true));
|
|
131
|
+
assert.equal(resolveSetupAllTools(false).includes('pi'), false, resolveSetupAllTools(false));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('the help text lists pi as "Pi Coding Agent" and shows an `unbound setup pi` example', () => {
|
|
135
|
+
const piHelpLine = HELP_TOOL_LINES.find((l) => l.trim().startsWith('pi '));
|
|
136
|
+
assert.ok(piHelpLine, `no pi line in the "Available tools:" block\n${HELP_TOOL_LINES.join('\n')}`);
|
|
137
|
+
assert.match(piHelpLine, /^\s*pi\s+Pi Coding Agent\s*$/);
|
|
138
|
+
const examples = SETUP_SOURCE.slice(SETUP_SOURCE.indexOf('Examples:'));
|
|
139
|
+
assert.match(examples, /\$ unbound setup pi\s+Set up Pi Coding Agent/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// buildScriptArgs takes no tool argument (setup.js:256), so pi adds no argv behaviour.
|
|
143
|
+
// This is a cross-check of the generic flag shape — test/setup-args.test.js:126-137
|
|
144
|
+
// already owns the primary coverage — asserted once against pi's own call shape.
|
|
145
|
+
test('buildScriptArgs stays tool-agnostic: --domain non-MDM, --frontend-url under MDM, --clear keyless', () => {
|
|
146
|
+
const user = buildScriptArgs('k-123', { backendUrl: 'https://b', gatewayUrl: 'https://g', frontendUrl: 'https://f' });
|
|
147
|
+
assert.match(user, /--api-key /);
|
|
148
|
+
assert.match(user, /--domain /);
|
|
149
|
+
assert.ok(!user.includes('--frontend-url'), user);
|
|
150
|
+
|
|
151
|
+
const mdm = buildScriptArgs('k-123', { frontendUrl: 'https://f', mdm: true });
|
|
152
|
+
assert.match(mdm, /--frontend-url /);
|
|
153
|
+
assert.ok(!mdm.includes('--domain'), mdm);
|
|
154
|
+
|
|
155
|
+
// --clear runs no auth in the Python installers, so no key is present and the
|
|
156
|
+
// literal string "undefined" must never reach the argv.
|
|
157
|
+
const cleared = buildScriptArgs(undefined, { clear: true, backendUrl: 'https://b' });
|
|
158
|
+
assert.match(cleared, /--clear/);
|
|
159
|
+
assert.ok(!cleared.includes('--api-key'), cleared);
|
|
160
|
+
assert.ok(!cleared.includes('undefined'), cleared);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// configDirForScript returns undefined for any script outside this set, and also for a
|
|
164
|
+
// member when CLAUDE_CONFIG_DIR is unset — so set membership, not the return value, is
|
|
165
|
+
// the positive control that keeps the pi assertion from passing vacuously.
|
|
166
|
+
const CLAUDE_CONFIG_DIR_SOURCE = SETUP_SOURCE.slice(
|
|
167
|
+
SETUP_SOURCE.indexOf('const CLAUDE_CONFIG_DIR_SCRIPTS'),
|
|
168
|
+
SETUP_SOURCE.indexOf(']);', SETUP_SOURCE.indexOf('const CLAUDE_CONFIG_DIR_SCRIPTS')),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
test('pi honours neither CLAUDE_CONFIG_DIR nor --backfill', () => {
|
|
172
|
+
assert.ok(!configDirForScript('pi/setup.py'), String(configDirForScript('pi/setup.py')));
|
|
173
|
+
assert.equal(scriptSupportsBackfill('pi/setup.py'), false);
|
|
174
|
+
assert.equal(scriptSupportsBackfill('pi/mdm/setup.py'), false);
|
|
175
|
+
// positive controls
|
|
176
|
+
assert.equal(scriptSupportsBackfill('claude-code/hooks/setup.py'), true);
|
|
177
|
+
assert.ok(CLAUDE_CONFIG_DIR_SOURCE.includes("'claude-code/hooks/setup.py'"),
|
|
178
|
+
'the CLAUDE_CONFIG_DIR set no longer lists the Claude hooks installer');
|
|
179
|
+
assert.ok(!CLAUDE_CONFIG_DIR_SOURCE.includes("'pi/setup.py'"),
|
|
180
|
+
'pi must not be added to CLAUDE_CONFIG_DIR_SCRIPTS');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// T-10-01: integrity is verified once, in pi/setup.py, against the committed sidecar.
|
|
184
|
+
// A second hash path in JavaScript could diverge from the Python one.
|
|
185
|
+
test('the CLI gains no hashing path — integrity stays in Python', () => {
|
|
186
|
+
assert.equal(/createHash|sha256/i.test(SETUP_SOURCE), false,
|
|
187
|
+
'setup.js must contain no hashing: downloadToFile checks status, redirects, wx-open and length only');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// T-10-13: the release-version bump is validated by .github/workflows/version-check.yml,
|
|
191
|
+
// which diffs package.json against a real checkout of `main` on every PR. It is
|
|
192
|
+
// deliberately NOT asserted here: this suite has to pass on any checkout, and a
|
|
193
|
+
// version assertion would couple it to mutable Git state — it would fail on `main`
|
|
194
|
+
// immediately after this PR merges (ours would then equal main's) and would need
|
|
195
|
+
// editing on every future bump. Release state belongs in the release workflow.
|
|
196
|
+
|
|
197
|
+
// The Edit-tool hazard from 10-VALIDATION: setup.js:33-38 holds four real two-character
|
|
198
|
+
// — escapes. An edit near them must not normalise them into literal em-dashes.
|
|
199
|
+
// Scoped to the picker block on purpose: literal em-dashes are used freely in setup.js
|
|
200
|
+
// comments, so only the four string-literal escapes inside SETUP_TOOLS are asserted.
|
|
201
|
+
test('the four \\u2014 escapes in the picker labels survive byte-for-byte', () => {
|
|
202
|
+
assert.equal((SETUP_SOURCE.match(/\\u2014/g) || []).length, 4, 'the \\u2014 escape count changed');
|
|
203
|
+
assert.equal((PICKER_BLOCK.match(/\\u2014/g) || []).length, 4, 'a picker label escape was lost');
|
|
204
|
+
assert.equal(PICKER_BLOCK.includes('—'), false,
|
|
205
|
+
'a literal em-dash appeared inside SETUP_TOOLS: an escape was normalised');
|
|
206
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
SETUP_TOOL_MAP,
|
|
7
|
+
MDM_TOOLS,
|
|
8
|
+
ALL_TOOLS,
|
|
9
|
+
MDM_ALL_TOOLS,
|
|
10
|
+
resolveSetupAllTools,
|
|
11
|
+
} = require('../src/commands/setup');
|
|
12
|
+
|
|
13
|
+
const SETUP_SOURCE_PATH = path.join(__dirname, '..', 'src', 'commands', 'setup.js');
|
|
14
|
+
const SETUP_SOURCE = fs.readFileSync(SETUP_SOURCE_PATH, 'utf8');
|
|
15
|
+
|
|
16
|
+
// SETUP_TOOLS (the interactive picker) and MDM_SETUP_ALL_TOOLS are not exported and
|
|
17
|
+
// the help text is built inline by addHelpText, so all three are read from source.
|
|
18
|
+
function sourceArrayBlock(name) {
|
|
19
|
+
const start = SETUP_SOURCE.indexOf(`const ${name} = [`);
|
|
20
|
+
assert.notEqual(start, -1, `${name} not found in setup.js`);
|
|
21
|
+
const end = SETUP_SOURCE.indexOf('];', start);
|
|
22
|
+
assert.notEqual(end, -1, `${name} is not terminated`);
|
|
23
|
+
return SETUP_SOURCE.slice(start, end);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The "Available tools:" block ends at the first blank line, before the
|
|
27
|
+
// "For multi-tool setup" section that lists the explicit mode names.
|
|
28
|
+
function helpToolLines() {
|
|
29
|
+
const start = SETUP_SOURCE.indexOf('Available tools:');
|
|
30
|
+
assert.notEqual(start, -1, 'the help text has no "Available tools:" block');
|
|
31
|
+
const block = SETUP_SOURCE.slice(start).split('\n\n')[0];
|
|
32
|
+
return block.split('\n').slice(1).filter((line) => line.trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HELP_TOOL_NAMES = helpToolLines().map((line) => line.trim().split(/\s+/)[0]);
|
|
36
|
+
const PICKER_BLOCK = sourceArrayBlock('SETUP_TOOLS');
|
|
37
|
+
const MDM_SETUP_ALL_TOOLS_BLOCK = sourceArrayBlock('MDM_SETUP_ALL_TOOLS');
|
|
38
|
+
|
|
39
|
+
// Every bundle a tool can be in. `pi` must stay out of all four (RESEARCH A1) while
|
|
40
|
+
// still appearing in resolveSetupAllTools(true), so membership is asserted by name.
|
|
41
|
+
const BUNDLES = {
|
|
42
|
+
SETUP_ALL_TOOLS: (name) => resolveSetupAllTools(false).includes(name),
|
|
43
|
+
ALL_TOOLS: (name) => ALL_TOOLS.includes(name),
|
|
44
|
+
MDM_ALL_TOOLS: (name) => MDM_ALL_TOOLS.includes(name),
|
|
45
|
+
MDM_SETUP_ALL_TOOLS: (name) => MDM_SETUP_ALL_TOOLS_BLOCK.includes(`'${name}'`),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// The five wiring facts a `unbound setup <tool>` entry needs, as booleans, so the
|
|
49
|
+
// negative case can prove each one individually rather than trusting a thrown error.
|
|
50
|
+
function checkToolWired(name, spec = {}) {
|
|
51
|
+
const mdmKey = spec.mdmKey || name;
|
|
52
|
+
const entry = SETUP_TOOL_MAP[name];
|
|
53
|
+
const mdmEntry = MDM_TOOLS[mdmKey];
|
|
54
|
+
return {
|
|
55
|
+
inSetupMap: Boolean(entry) && entry.script === spec.script,
|
|
56
|
+
inMdmTools: Boolean(mdmEntry) && mdmEntry.script === spec.mdmScript,
|
|
57
|
+
inPicker: PICKER_BLOCK.includes(`value: '${name}'`),
|
|
58
|
+
inHelp: HELP_TOOL_NAMES.includes(name),
|
|
59
|
+
inSetupAllClear: resolveSetupAllTools(true).includes(name),
|
|
60
|
+
bundles: Object.keys(BUNDLES).filter((bundle) => BUNDLES[bundle](name)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertToolWired(name, spec) {
|
|
65
|
+
const got = checkToolWired(name, spec);
|
|
66
|
+
assert.ok(got.inSetupMap, `${name}: SETUP_TOOL_MAP entry missing or script !== ${spec.script}`);
|
|
67
|
+
assert.ok(got.inMdmTools, `${name}: MDM_TOOLS entry missing or script !== ${spec.mdmScript}`);
|
|
68
|
+
assert.ok(got.inPicker, `${name}: absent from the interactive SETUP_TOOLS picker`);
|
|
69
|
+
assert.ok(got.inHelp, `${name}: absent from the "Available tools:" help block`);
|
|
70
|
+
assert.ok(got.inSetupAllClear, `${name}: absent from resolveSetupAllTools(true)`);
|
|
71
|
+
assert.deepEqual(got.bundles.sort(), [...spec.bundles].sort(),
|
|
72
|
+
`${name}: bundle membership drifted`);
|
|
73
|
+
return got;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The maps have to be reachable at all before any assertion about them means anything.
|
|
77
|
+
test('setup.js exports SETUP_TOOL_MAP and MDM_TOOLS as plain objects', () => {
|
|
78
|
+
assert.equal(typeof SETUP_TOOL_MAP, 'object');
|
|
79
|
+
assert.equal(typeof MDM_TOOLS, 'object');
|
|
80
|
+
assert.ok(Object.keys(SETUP_TOOL_MAP).length >= 8, Object.keys(SETUP_TOOL_MAP));
|
|
81
|
+
assert.ok(Object.keys(MDM_TOOLS).length >= 8, Object.keys(MDM_TOOLS));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Proves the five-check assertion against a tool that is already fully wired.
|
|
85
|
+
test('assertToolWired: augment is wired across all four tables', () => {
|
|
86
|
+
const got = assertToolWired('augment', {
|
|
87
|
+
script: 'augment/hooks/setup.py',
|
|
88
|
+
mdmScript: 'augment/hooks/mdm/setup.py',
|
|
89
|
+
bundles: ['SETUP_ALL_TOOLS', 'ALL_TOOLS', 'MDM_ALL_TOOLS', 'MDM_SETUP_ALL_TOOLS'],
|
|
90
|
+
});
|
|
91
|
+
assert.equal(SETUP_TOOL_MAP.augment.label, 'Augment Code');
|
|
92
|
+
assert.equal(got.bundles.length, 4);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// The regression half: a tool in SETUP_TOOL_MAP is in `--all --clear` for free,
|
|
96
|
+
// because resolveSetupAllTools(true) returns Object.keys(SETUP_TOOL_MAP).
|
|
97
|
+
test('resolveSetupAllTools: clear covers every SETUP_TOOL_MAP key, install covers the bundle only', () => {
|
|
98
|
+
assert.deepEqual(resolveSetupAllTools(true).sort(), Object.keys(SETUP_TOOL_MAP).sort());
|
|
99
|
+
const install = resolveSetupAllTools(false);
|
|
100
|
+
assert.ok(install.length < resolveSetupAllTools(true).length, install);
|
|
101
|
+
for (const name of install) {
|
|
102
|
+
assert.ok(SETUP_TOOL_MAP[name], `${name} is in the install bundle but not in SETUP_TOOL_MAP`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Without this the five checks could all pass vacuously on a typo'd tool name.
|
|
107
|
+
test('assertToolWired: a made-up tool fails every one of the five checks', () => {
|
|
108
|
+
const got = checkToolWired('definitely-not-a-tool', {
|
|
109
|
+
script: 'definitely-not-a-tool/setup.py',
|
|
110
|
+
mdmScript: 'definitely-not-a-tool/mdm/setup.py',
|
|
111
|
+
});
|
|
112
|
+
assert.equal(got.inSetupMap, false);
|
|
113
|
+
assert.equal(got.inMdmTools, false);
|
|
114
|
+
assert.equal(got.inPicker, false);
|
|
115
|
+
assert.equal(got.inHelp, false);
|
|
116
|
+
assert.equal(got.inSetupAllClear, false);
|
|
117
|
+
assert.deepEqual(got.bundles, []);
|
|
118
|
+
assert.throws(() => assertToolWired('definitely-not-a-tool', {
|
|
119
|
+
script: 'definitely-not-a-tool/setup.py',
|
|
120
|
+
mdmScript: 'definitely-not-a-tool/mdm/setup.py',
|
|
121
|
+
bundles: [],
|
|
122
|
+
}), /SETUP_TOOL_MAP entry missing/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// The source-read helpers are the fragile half of this file: if a rename made either
|
|
126
|
+
// block unfindable, every membership check would silently report false.
|
|
127
|
+
test('the source-read blocks are real and non-empty', () => {
|
|
128
|
+
assert.ok(PICKER_BLOCK.includes("value: 'cursor'"), 'SETUP_TOOLS block did not parse');
|
|
129
|
+
assert.ok(MDM_SETUP_ALL_TOOLS_BLOCK.includes("'cursor'"), 'MDM_SETUP_ALL_TOOLS did not parse');
|
|
130
|
+
assert.ok(HELP_TOOL_NAMES.length >= 10, HELP_TOOL_NAMES);
|
|
131
|
+
assert.ok(HELP_TOOL_NAMES.includes('cursor'), HELP_TOOL_NAMES);
|
|
132
|
+
});
|
package/test/tool-health.test.js
CHANGED
|
@@ -641,6 +641,98 @@ test('validateToolKeys: invalid verdict flips the tool to tampered', async () =>
|
|
|
641
641
|
});
|
|
642
642
|
});
|
|
643
643
|
|
|
644
|
+
test('validateToolKeys: stale live-env key invalid but rc file holds a valid key → healthy', async () => {
|
|
645
|
+
await withHome(async (tmp, th) => {
|
|
646
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
647
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
648
|
+
writeFile(script, '# unbound');
|
|
649
|
+
// A shell opened before setup rewrote the rc carries a stale key in the
|
|
650
|
+
// live env, while the valid key sits in a shell rc. Must not be tampered.
|
|
651
|
+
// .zshrc is on both the macOS and Linux rc lists, so the test is portable.
|
|
652
|
+
writeFile(path.join(tmp, '.zshrc'), 'export UNBOUND_CURSOR_API_KEY="rc-valid-key"\n');
|
|
653
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'stale-invalid-key';
|
|
654
|
+
try {
|
|
655
|
+
const seen = [];
|
|
656
|
+
const tools = th.detectTools();
|
|
657
|
+
await th.validateToolKeys(tools, async (k) => { seen.push(k); return k === 'rc-valid-key' ? 'valid' : 'invalid'; });
|
|
658
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
659
|
+
assert.equal(cursor.status, 'healthy');
|
|
660
|
+
assert.equal(cursor.checks.find((c) => c.keyCheck).ok, true);
|
|
661
|
+
// Guard the rc read itself: if the rc file were skipped the tool would
|
|
662
|
+
// still be tampered, so prove the rc key actually reached the validator.
|
|
663
|
+
assert.ok(seen.includes('rc-valid-key'), 'the rc-file key must be validated, not just the live env key');
|
|
664
|
+
} finally {
|
|
665
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test('validateToolKeys: live-env AND rc keys both invalid → tampered (no false clean)', async () => {
|
|
671
|
+
await withHome(async (tmp, th) => {
|
|
672
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
673
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
674
|
+
writeFile(script, '# unbound');
|
|
675
|
+
writeFile(path.join(tmp, '.zshrc'), 'export UNBOUND_CURSOR_API_KEY="rc-stale-key"\n');
|
|
676
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'env-stale-key';
|
|
677
|
+
try {
|
|
678
|
+
const seen = [];
|
|
679
|
+
const tools = th.detectTools();
|
|
680
|
+
await th.validateToolKeys(tools, async (k) => { seen.push(k); return 'invalid'; });
|
|
681
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
682
|
+
assert.equal(cursor.status, 'tampered');
|
|
683
|
+
assert.equal(cursor.checks.find((c) => c.keyCheck).ok, false);
|
|
684
|
+
// Both candidates must be validated — otherwise a skipped rc file lets
|
|
685
|
+
// this "every candidate rejected" case pass without ever reading the rc.
|
|
686
|
+
assert.ok(seen.includes('env-stale-key') && seen.includes('rc-stale-key'), 'both the live env and rc keys must be validated');
|
|
687
|
+
} finally {
|
|
688
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test('validateToolKeys: a valid live key short-circuits — rc siblings are never sent to the validator', async () => {
|
|
694
|
+
await withHome(async (tmp, th) => {
|
|
695
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
696
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
697
|
+
writeFile(script, '# unbound');
|
|
698
|
+
// A user-owned var (e.g. OPENAI_API_KEY for codex-gateway) can carry the
|
|
699
|
+
// user's personal key exported beside ours. A valid live key must stop the
|
|
700
|
+
// check before any rc sibling is uploaded to the gateway.
|
|
701
|
+
writeFile(path.join(tmp, '.zshrc'), 'export UNBOUND_CURSOR_API_KEY="personal-secret"\nexport UNBOUND_CURSOR_API_KEY="live-valid-key"\n');
|
|
702
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'live-valid-key';
|
|
703
|
+
try {
|
|
704
|
+
const seen = [];
|
|
705
|
+
const tools = th.detectTools();
|
|
706
|
+
await th.validateToolKeys(tools, async (k) => { seen.push(k); return k === 'live-valid-key' ? 'valid' : 'invalid'; });
|
|
707
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
708
|
+
assert.equal(cursor.status, 'healthy');
|
|
709
|
+
assert.deepEqual(seen, ['live-valid-key'], 'only the live key is validated on the healthy path');
|
|
710
|
+
assert.ok(!seen.includes('personal-secret'), 'a sibling rc key must never be sent');
|
|
711
|
+
} finally {
|
|
712
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('validateToolKeys: user-owned OPENAI_API_KEY never has its rc siblings sent, even when the live key is rejected', async () => {
|
|
718
|
+
await withHome(async (tmp, th) => {
|
|
719
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), 'openai_base_url = "https://gateway.example/v1"\n');
|
|
720
|
+
// The user's personal OpenAI key sits in the rc beside anything Unbound set.
|
|
721
|
+
// A rejected live key must NOT trigger a search that uploads the personal one.
|
|
722
|
+
writeFile(path.join(tmp, '.zshrc'), 'export OPENAI_API_KEY="sk-personal-secret"\n');
|
|
723
|
+
process.env.OPENAI_API_KEY = 'stale-live-key';
|
|
724
|
+
try {
|
|
725
|
+
const seen = [];
|
|
726
|
+
const tools = th.detectTools();
|
|
727
|
+
await th.validateToolKeys(tools, async (k) => { seen.push(k); return 'invalid'; });
|
|
728
|
+
assert.ok(!seen.includes('sk-personal-secret'), 'a personal OpenAI key in the rc must never be sent to the gateway');
|
|
729
|
+
assert.deepEqual(seen, ['stale-live-key'], 'only the live value is ever validated for a user-owned var');
|
|
730
|
+
} finally {
|
|
731
|
+
delete process.env.OPENAI_API_KEY;
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
644
736
|
test('validateToolKeys: unknown verdict fails open — tool stays healthy', async () => {
|
|
645
737
|
await withHome(async (tmp, th) => {
|
|
646
738
|
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|