unbound-cli 1.16.9 → 1.16.11
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
CHANGED
package/src/commands/discover.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
const
|
|
1
|
+
const childProcess = require('child_process');
|
|
2
|
+
const { spawn } = childProcess;
|
|
2
3
|
const fs = require('fs');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
const os = require('os');
|
|
@@ -7,7 +8,9 @@ const output = require('../output');
|
|
|
7
8
|
const telemetry = require('../telemetry');
|
|
8
9
|
const { Option } = require('commander');
|
|
9
10
|
const { shellEscape, downloadToFile, withSecureTempFile, DISCOVER_BASE_URL } = require('../utils');
|
|
10
|
-
|
|
11
|
+
// The scheduled-scan installer (setup-scheduled-scan.sh) registers this LaunchAgent
|
|
12
|
+
// and writes its logs; keep both in sync with that script.
|
|
13
|
+
const LAUNCH_AGENT_LABEL = 'ai.getunbound.scheduled';
|
|
11
14
|
|
|
12
15
|
// install.sh exits with this code when the OS isn't supported for discovery
|
|
13
16
|
// (e.g. Linux). Treated as a skipped scan, not a failure.
|
|
@@ -351,14 +354,14 @@ Examples:
|
|
|
351
354
|
|
|
352
355
|
const home = os.homedir();
|
|
353
356
|
const plistPath = path.join(home, 'Library/LaunchAgents', `${LAUNCH_AGENT_LABEL}.plist`);
|
|
354
|
-
const logPath = path.join(home, 'Library/Logs/unbound/
|
|
355
|
-
const errPath = path.join(home, 'Library/Logs/unbound/
|
|
357
|
+
const logPath = path.join(home, 'Library/Logs/unbound/scheduled.log');
|
|
358
|
+
const errPath = path.join(home, 'Library/Logs/unbound/scheduled.err');
|
|
356
359
|
|
|
357
360
|
const plistExists = fs.existsSync(plistPath);
|
|
358
361
|
|
|
359
362
|
let isLoaded = false;
|
|
360
363
|
try {
|
|
361
|
-
execSync(`launchctl list "${LAUNCH_AGENT_LABEL}"`, { stdio: 'ignore' });
|
|
364
|
+
childProcess.execSync(`launchctl list "${LAUNCH_AGENT_LABEL}"`, { stdio: 'ignore' });
|
|
362
365
|
isLoaded = true;
|
|
363
366
|
} catch {
|
|
364
367
|
// not loaded
|
package/src/toolHealth.js
CHANGED
|
@@ -17,6 +17,7 @@ const fs = require('fs');
|
|
|
17
17
|
const os = require('os');
|
|
18
18
|
const path = require('path');
|
|
19
19
|
const { spawnSync } = require('child_process');
|
|
20
|
+
const TOML = require('@iarna/toml');
|
|
20
21
|
const claudePaths = require('./claudePaths');
|
|
21
22
|
|
|
22
23
|
const HOME = os.homedir();
|
|
@@ -353,7 +354,18 @@ function buildVariants(gatewayUrl, binaryPath) {
|
|
|
353
354
|
key: 'codex-subscription', label: 'Codex (subscription)', family: 'codex', mode: 'subscription',
|
|
354
355
|
checks: () => [
|
|
355
356
|
configCheck('Config', '~/.codex/hooks.json', { json: true, test: refsUnbound }),
|
|
356
|
-
|
|
357
|
+
// Codex enables hooks via `[features] hooks = true`; the pre-Aug-2026 alias
|
|
358
|
+
// `[features] codex_hooks = true` is still honoured, with the canonical `hooks`
|
|
359
|
+
// winning when both are set. Both live under [features], so a hooks key in any
|
|
360
|
+
// other table isn't this flag; an unparseable config (which Codex couldn't load
|
|
361
|
+
// either) reads as not enabled.
|
|
362
|
+
configCheck('Hooks feature flag', '~/.codex/config.toml', { json: false, test: (t) => {
|
|
363
|
+
let cfg;
|
|
364
|
+
try { cfg = TOML.parse(t.replace(/^\uFEFF/, '')); } catch { return false; } // tolerate a UTF-8 BOM, like readJson
|
|
365
|
+
const features = cfg.features;
|
|
366
|
+
if (!features || typeof features !== 'object') return false;
|
|
367
|
+
return features.hooks === undefined ? features.codex_hooks === true : features.hooks === true;
|
|
368
|
+
} }, { short: 'codex hooks not enabled' }),
|
|
357
369
|
scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
|
|
358
370
|
envKeyCheck('API key env', 'UNBOUND_CODEX_API_KEY'),
|
|
359
371
|
],
|
|
@@ -1,7 +1,53 @@
|
|
|
1
1
|
const { test } = require('node:test');
|
|
2
2
|
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
3
6
|
const { Command } = require('commander');
|
|
4
7
|
const { classifyDiscoveryExit, register } = require('../src/commands/discover');
|
|
8
|
+
const output = require('../src/output');
|
|
9
|
+
|
|
10
|
+
// `discover status` must point at what the scheduled-scan installer actually
|
|
11
|
+
// creates: the LaunchAgent labelled `ai.getunbound.scheduled` and the
|
|
12
|
+
// scheduled.log / scheduled.err files under ~/Library/Logs/unbound. Otherwise a
|
|
13
|
+
// scheduled scan reports "No schedule". Runs the real status action with the
|
|
14
|
+
// platform stubbed to darwin and output.keyValue captured.
|
|
15
|
+
test('discover status: uses the scheduled-scan LaunchAgent label and log paths', async () => {
|
|
16
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-discover-status-'));
|
|
17
|
+
const origHome = process.env.HOME;
|
|
18
|
+
const origPlatform = process.platform;
|
|
19
|
+
const origKeyValue = output.keyValue;
|
|
20
|
+
const origHomedir = os.homedir;
|
|
21
|
+
|
|
22
|
+
os.homedir = () => tmp;
|
|
23
|
+
process.env.HOME = tmp;
|
|
24
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
25
|
+
|
|
26
|
+
// Make the plist exist so the "LaunchAgent" row shows its resolved path.
|
|
27
|
+
const plistPath = path.join(tmp, 'Library/LaunchAgents', 'ai.getunbound.scheduled.plist');
|
|
28
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
29
|
+
fs.writeFileSync(plistPath, '<plist/>');
|
|
30
|
+
|
|
31
|
+
const captured = {};
|
|
32
|
+
output.keyValue = (pairs) => { for (const [k, v] of pairs) captured[k] = v; };
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const program = new Command();
|
|
36
|
+
register(program);
|
|
37
|
+
await program.parseAsync(['node', 'x', 'discover', 'status']);
|
|
38
|
+
|
|
39
|
+
assert.equal(captured['LaunchAgent'], plistPath);
|
|
40
|
+
assert.equal(captured['Log file'], path.join(tmp, 'Library/Logs/unbound/scheduled.log'));
|
|
41
|
+
assert.equal(captured['Error log'], path.join(tmp, 'Library/Logs/unbound/scheduled.err'));
|
|
42
|
+
} finally {
|
|
43
|
+
output.keyValue = origKeyValue;
|
|
44
|
+
os.homedir = origHomedir;
|
|
45
|
+
Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true });
|
|
46
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
47
|
+
else process.env.HOME = origHome;
|
|
48
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
5
51
|
|
|
6
52
|
// Existing crons can still pass a discovery key. With strict option parsing the
|
|
7
53
|
// CLI would reject an unregistered flag, so discover keeps --discovery-key as an
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { Command } = require('commander');
|
|
7
|
+
const { register } = require('../src/commands/discover');
|
|
8
|
+
const output = require('../src/output');
|
|
9
|
+
|
|
10
|
+
// Hardening for `discover status`: the command must agree with what the
|
|
11
|
+
// scheduled-scan installer (coding-discovery-tool/setup-scheduled-scan.sh)
|
|
12
|
+
// actually creates — the `ai.getunbound.scheduled` LaunchAgent and the
|
|
13
|
+
// scheduled.log / scheduled.err files — and must degrade cleanly off macOS.
|
|
14
|
+
|
|
15
|
+
function withDarwinHome(fn) {
|
|
16
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-discover-status-'));
|
|
17
|
+
const origHome = process.env.HOME;
|
|
18
|
+
const origPlatform = process.platform;
|
|
19
|
+
const origHomedir = os.homedir;
|
|
20
|
+
const origKeyValue = output.keyValue;
|
|
21
|
+
os.homedir = () => tmp;
|
|
22
|
+
process.env.HOME = tmp;
|
|
23
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
24
|
+
const captured = {};
|
|
25
|
+
output.keyValue = (pairs) => { for (const [k, v] of pairs) captured[k] = v; };
|
|
26
|
+
return (async () => {
|
|
27
|
+
try {
|
|
28
|
+
const program = new Command();
|
|
29
|
+
register(program);
|
|
30
|
+
await program.parseAsync(['node', 'x', 'discover', 'status']);
|
|
31
|
+
await fn(tmp, captured);
|
|
32
|
+
} finally {
|
|
33
|
+
output.keyValue = origKeyValue;
|
|
34
|
+
os.homedir = origHomedir;
|
|
35
|
+
Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true });
|
|
36
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
37
|
+
else process.env.HOME = origHome;
|
|
38
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Cross-repo contract. The installer's wrapper writes a run marker to
|
|
44
|
+
// scheduled.log in exactly this shape:
|
|
45
|
+
// log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> scheduled.log; }
|
|
46
|
+
// log "=== Starting Unbound scheduled run ==="
|
|
47
|
+
// `discover status` parses the last such line into "Last scan". If either side
|
|
48
|
+
// drifts, Last scan silently reverts to "-". This seeds a real installer-format
|
|
49
|
+
// line and asserts the timestamp is recovered.
|
|
50
|
+
test('discover status: parses Last scan from the installer\'s real scheduled.log line', async () => {
|
|
51
|
+
await withDarwinHome(async (tmp, captured) => {
|
|
52
|
+
// (setup happens before parseAsync, so write the log then re-run in-band)
|
|
53
|
+
const logDir = path.join(tmp, 'Library/Logs/unbound');
|
|
54
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
55
|
+
fs.writeFileSync(path.join(logDir, 'scheduled.log'),
|
|
56
|
+
'[2026-09-20 09:00:01] === Starting Unbound scheduled run ===\n' +
|
|
57
|
+
'discovered 3 tools\n' +
|
|
58
|
+
'[2026-09-21 09:00:02] === Starting Unbound scheduled run ===\n' +
|
|
59
|
+
'[2026-09-21 09:00:04] === Finished ===\n');
|
|
60
|
+
// Re-run status now that the log exists.
|
|
61
|
+
const program = new Command();
|
|
62
|
+
register(program);
|
|
63
|
+
await program.parseAsync(['node', 'x', 'discover', 'status']);
|
|
64
|
+
assert.equal(captured['Last scan'], '2026-09-21 09:00:02', 'must report the latest run marker');
|
|
65
|
+
assert.equal(captured['Log file'], path.join(logDir, 'scheduled.log'));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// A machine that still has ONLY the stale `ai.getunbound.discovery` plist (never
|
|
70
|
+
// re-ran the migrating installer) must read as unscheduled — the reader is bound
|
|
71
|
+
// to the new label, not the old one.
|
|
72
|
+
test('discover status: a stale ai.getunbound.discovery plist alone reads as Not installed', async () => {
|
|
73
|
+
await withDarwinHome(async (tmp, captured) => {
|
|
74
|
+
const laDir = path.join(tmp, 'Library/LaunchAgents');
|
|
75
|
+
fs.mkdirSync(laDir, { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(laDir, 'ai.getunbound.discovery.plist'), '<plist/>');
|
|
77
|
+
const program = new Command();
|
|
78
|
+
register(program);
|
|
79
|
+
await program.parseAsync(['node', 'x', 'discover', 'status']);
|
|
80
|
+
assert.equal(captured['Scheduled'], 'No');
|
|
81
|
+
assert.equal(captured['LaunchAgent'], 'Not installed');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Runs `discover status` on darwin with `launchctl` stubbed. `discover.js` calls
|
|
86
|
+
// `childProcess.execSync`, so replacing that method on the shared module object
|
|
87
|
+
// takes effect without re-requiring. `loaded` decides whether `launchctl list
|
|
88
|
+
// <label>` succeeds; `plistLabel` (if set) seeds that LaunchAgent plist. Returns
|
|
89
|
+
// the captured key/values and the exact launchctl command the reader ran.
|
|
90
|
+
async function runStatusWithLaunchctl({ plistLabel, loaded }) {
|
|
91
|
+
const cp = require('child_process');
|
|
92
|
+
const orig = {
|
|
93
|
+
execSync: cp.execSync, home: process.env.HOME, platform: process.platform,
|
|
94
|
+
homedir: os.homedir, keyValue: output.keyValue,
|
|
95
|
+
};
|
|
96
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-discover-status-'));
|
|
97
|
+
if (plistLabel) {
|
|
98
|
+
const laDir = path.join(tmp, 'Library/LaunchAgents');
|
|
99
|
+
fs.mkdirSync(laDir, { recursive: true });
|
|
100
|
+
fs.writeFileSync(path.join(laDir, `${plistLabel}.plist`), '<plist/>');
|
|
101
|
+
}
|
|
102
|
+
let launchctlCmd = '';
|
|
103
|
+
cp.execSync = (cmd) => { launchctlCmd = cmd; if (loaded) return ''; throw new Error('not loaded'); };
|
|
104
|
+
os.homedir = () => tmp;
|
|
105
|
+
process.env.HOME = tmp;
|
|
106
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
107
|
+
const captured = {};
|
|
108
|
+
output.keyValue = (pairs) => { for (const [k, v] of pairs) captured[k] = v; };
|
|
109
|
+
try {
|
|
110
|
+
const program = new Command();
|
|
111
|
+
register(program);
|
|
112
|
+
await program.parseAsync(['node', 'x', 'discover', 'status']);
|
|
113
|
+
return { captured, launchctlCmd };
|
|
114
|
+
} finally {
|
|
115
|
+
cp.execSync = orig.execSync;
|
|
116
|
+
os.homedir = orig.homedir;
|
|
117
|
+
Object.defineProperty(process, 'platform', { value: orig.platform, configurable: true });
|
|
118
|
+
if (orig.home === undefined) delete process.env.HOME; else process.env.HOME = orig.home;
|
|
119
|
+
output.keyValue = orig.keyValue;
|
|
120
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The bug was "reports no schedule on a scheduled machine". A machine the
|
|
125
|
+
// installer scheduled must read Scheduled: Yes — which needs both the plist
|
|
126
|
+
// (new label) present AND `launchctl list <label>` to succeed. This asserts the
|
|
127
|
+
// reader queries launchctl under the SAME `ai.getunbound.scheduled` label it
|
|
128
|
+
// reads the plist under, so the two sides can't drift apart into a false "No".
|
|
129
|
+
test('discover status: installed + loaded scheduled agent reads Scheduled: Yes', async () => {
|
|
130
|
+
const { captured, launchctlCmd } = await runStatusWithLaunchctl({ plistLabel: 'ai.getunbound.scheduled', loaded: true });
|
|
131
|
+
assert.match(launchctlCmd, /launchctl list "ai\.getunbound\.scheduled"/, 'queries the new label, not the legacy one');
|
|
132
|
+
assert.equal(captured['Scheduled'], 'Yes (daily at 09:00)');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// The reverse guard: the plist is present but the agent is not loaded, so the
|
|
136
|
+
// scan is not actually scheduled — it must read No, not a false Yes off the plist alone.
|
|
137
|
+
test('discover status: installed but not loaded reads Scheduled: No', async () => {
|
|
138
|
+
const { captured } = await runStatusWithLaunchctl({ plistLabel: 'ai.getunbound.scheduled', loaded: false });
|
|
139
|
+
assert.equal(captured['Scheduled'], 'No');
|
|
140
|
+
assert.match(captured['LaunchAgent'], /ai\.getunbound\.scheduled\.plist$/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Off macOS the command must not touch launchd/plist state; it prints guidance
|
|
144
|
+
// pointing at the real Linux timer and Windows scheduled-task names and exits 1.
|
|
145
|
+
test('discover status: non-darwin prints OS guidance and does not read plist state', async () => {
|
|
146
|
+
const origPlatform = process.platform;
|
|
147
|
+
const origError = output.error;
|
|
148
|
+
const origExit = process.exitCode;
|
|
149
|
+
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
150
|
+
let msg = '';
|
|
151
|
+
output.error = (m) => { msg += m; };
|
|
152
|
+
try {
|
|
153
|
+
const program = new Command();
|
|
154
|
+
register(program);
|
|
155
|
+
await program.parseAsync(['node', 'x', 'discover', 'status']);
|
|
156
|
+
assert.match(msg, /only supports macOS/);
|
|
157
|
+
assert.match(msg, /unbound-scheduled\.timer/, 'points at the real systemd timer');
|
|
158
|
+
assert.match(msg, /ai\.getunbound\.scheduled/, 'points at the real Windows task name');
|
|
159
|
+
assert.equal(process.exitCode, 1);
|
|
160
|
+
} finally {
|
|
161
|
+
output.error = origError;
|
|
162
|
+
Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true });
|
|
163
|
+
process.exitCode = origExit;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
// End-to-end guard through the real shipped binary: `unbound doctor --json`.
|
|
9
|
+
// The unit tests exercise detectTools directly; this proves the same fix holds
|
|
10
|
+
// when the CLI is actually run. A Codex install using the current spelling
|
|
11
|
+
// (`[features] hooks = true`) must report healthy, not tampered — the reported
|
|
12
|
+
// bug. Key validation is pointed at an unroutable API so it fails open offline
|
|
13
|
+
// and never leaves the machine.
|
|
14
|
+
|
|
15
|
+
const CLI = path.join(__dirname, '..', 'src', 'index.js');
|
|
16
|
+
|
|
17
|
+
function runDoctor(home) {
|
|
18
|
+
const r = spawnSync('node', [CLI, 'doctor', '--json'], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
env: {
|
|
21
|
+
...process.env,
|
|
22
|
+
HOME: home,
|
|
23
|
+
UNBOUND_CODEX_API_KEY: 'dummy-key',
|
|
24
|
+
// Unroutable API: validateApiKey fails fast and fails open (status stays
|
|
25
|
+
// as detectTools derived it), so the run is hermetic and offline.
|
|
26
|
+
UNBOUND_API_URL: 'http://127.0.0.1:1',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
// doctor exits non-zero when ANY tool is tampered; this host may carry
|
|
30
|
+
// unrelated MDM-managed tools, so we assert on the codex entry, not the code.
|
|
31
|
+
const start = r.stdout.indexOf('{');
|
|
32
|
+
assert.notEqual(start, -1, `doctor produced no JSON\nstdout:${r.stdout}\nstderr:${r.stderr}`);
|
|
33
|
+
return JSON.parse(r.stdout.slice(start));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function seedHealthyCodex(home) {
|
|
37
|
+
const wrapper = path.join(home, '.codex', 'hooks', 'unbound.py');
|
|
38
|
+
fs.mkdirSync(path.dirname(wrapper), { recursive: true });
|
|
39
|
+
fs.writeFileSync(wrapper, '# unbound');
|
|
40
|
+
fs.writeFileSync(path.join(home, '.codex', 'hooks.json'), JSON.stringify({
|
|
41
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('unbound doctor: real binary reads new-spelling [features] hooks = true codex as healthy', () => {
|
|
46
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-doctor-e2e-'));
|
|
47
|
+
try {
|
|
48
|
+
seedHealthyCodex(home);
|
|
49
|
+
fs.writeFileSync(path.join(home, '.codex', 'config.toml'), 'model = "gpt-5"\n\n[features]\nhooks = true\n');
|
|
50
|
+
const report = runDoctor(home);
|
|
51
|
+
const codex = report.tools.find((t) => t.tool === 'codex-subscription');
|
|
52
|
+
assert.ok(codex, 'doctor JSON must include the codex-subscription tool');
|
|
53
|
+
assert.equal(codex.status, 'healthy', 'a working codex install must not read as tampered');
|
|
54
|
+
} finally {
|
|
55
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('unbound doctor: real binary reads legacy [features] codex_hooks = true codex as healthy', () => {
|
|
60
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-doctor-e2e-'));
|
|
61
|
+
try {
|
|
62
|
+
seedHealthyCodex(home);
|
|
63
|
+
fs.writeFileSync(path.join(home, '.codex', 'config.toml'), '[features]\ncodex_hooks = true\n');
|
|
64
|
+
const report = runDoctor(home);
|
|
65
|
+
const codex = report.tools.find((t) => t.tool === 'codex-subscription');
|
|
66
|
+
assert.ok(codex, 'doctor JSON must include the codex-subscription tool');
|
|
67
|
+
assert.equal(codex.status, 'healthy', 'a legacy codex install must stay healthy');
|
|
68
|
+
} finally {
|
|
69
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
package/test/tool-health.test.js
CHANGED
|
@@ -361,8 +361,148 @@ test('detectTools: codex binary regression (wrapper at ~/.codex/hooks/unbound.py
|
|
|
361
361
|
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
362
362
|
}));
|
|
363
363
|
writeFile(wrapper, '#!/bin/sh\nexec /opt/unbound/current/unbound-hook/unbound-hook hook codex "$@"\n');
|
|
364
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), '[features]\nhooks = true\n');
|
|
365
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
366
|
+
try {
|
|
367
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
368
|
+
assert.equal(t.status, 'healthy');
|
|
369
|
+
} finally {
|
|
370
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// The installer now writes `hooks = true` under [features] (and strips the legacy
|
|
376
|
+
// `codex_hooks`). A healthy Codex install using the new spelling must read healthy.
|
|
377
|
+
test('detectTools: codex with new-spelling [features] hooks = true → healthy', () => {
|
|
378
|
+
withHome((tmp, th) => {
|
|
379
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
380
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
381
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
382
|
+
}));
|
|
383
|
+
writeFile(wrapper, '# unbound');
|
|
384
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), 'model = "gpt-5"\n\n[features]\nhooks = true\n');
|
|
385
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
386
|
+
try {
|
|
387
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
388
|
+
assert.equal(t.status, 'healthy');
|
|
389
|
+
} finally {
|
|
390
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// An unrelated key ending in `hooks = true` must NOT satisfy the flag: with no real
|
|
396
|
+
// hooks flag present the feature is off, so the install reads tampered.
|
|
397
|
+
test('detectTools: codex config with only `web_hooks = true` (no real flag) → tampered', () => {
|
|
398
|
+
withHome((tmp, th) => {
|
|
399
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
400
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
401
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
402
|
+
}));
|
|
403
|
+
writeFile(wrapper, '# unbound');
|
|
404
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), 'web_hooks = true\n');
|
|
405
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
406
|
+
try {
|
|
407
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
408
|
+
assert.equal(t.status, 'tampered');
|
|
409
|
+
} finally {
|
|
410
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// `hooks = true` under a table other than [features] does not enable Codex's hooks,
|
|
416
|
+
// so the flag must not count it: the install reads tampered, not healthy.
|
|
417
|
+
test('detectTools: codex `hooks = true` outside [features] (wrong table) → tampered', () => {
|
|
418
|
+
withHome((tmp, th) => {
|
|
419
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
420
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
421
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
422
|
+
}));
|
|
423
|
+
writeFile(wrapper, '# unbound');
|
|
424
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), '[mcp_servers.demo]\nhooks = true\n');
|
|
425
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
426
|
+
try {
|
|
427
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
428
|
+
assert.equal(t.status, 'tampered');
|
|
429
|
+
} finally {
|
|
430
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// The legacy alias only ever lived under [features]. A root-level `codex_hooks = true`
|
|
436
|
+
// is not a form the installer or Codex ever wrote, so it must not count: tampered.
|
|
437
|
+
test('detectTools: codex root-level `codex_hooks = true` (outside [features]) → tampered', () => {
|
|
438
|
+
withHome((tmp, th) => {
|
|
439
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
440
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
441
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
442
|
+
}));
|
|
443
|
+
writeFile(wrapper, '# unbound');
|
|
364
444
|
writeFile(path.join(tmp, '.codex', 'config.toml'), 'codex_hooks = true\n');
|
|
365
445
|
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
446
|
+
try {
|
|
447
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
448
|
+
assert.equal(t.status, 'tampered');
|
|
449
|
+
} finally {
|
|
450
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Codex resolves the canonical `hooks` ahead of the alias, so an explicit
|
|
456
|
+
// `hooks = false` disables hooks even when `codex_hooks = true` sits beside it.
|
|
457
|
+
test('detectTools: codex `[features] hooks = false` beside `codex_hooks = true` → tampered', () => {
|
|
458
|
+
withHome((tmp, th) => {
|
|
459
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
460
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
461
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
462
|
+
}));
|
|
463
|
+
writeFile(wrapper, '# unbound');
|
|
464
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), '[features]\nhooks = false\ncodex_hooks = true\n');
|
|
465
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
466
|
+
try {
|
|
467
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
468
|
+
assert.equal(t.status, 'tampered');
|
|
469
|
+
} finally {
|
|
470
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// Before the Aug-2026 rename the installer wrote the alias `[features] codex_hooks = true`.
|
|
476
|
+
// Codex still honours that table-scoped alias, so a real legacy install reads healthy.
|
|
477
|
+
test('detectTools: codex legacy `[features] codex_hooks = true` alias → healthy', () => {
|
|
478
|
+
withHome((tmp, th) => {
|
|
479
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
480
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
481
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
482
|
+
}));
|
|
483
|
+
writeFile(wrapper, '# unbound');
|
|
484
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), '[features]\ncodex_hooks = true\n');
|
|
485
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
486
|
+
try {
|
|
487
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
488
|
+
assert.equal(t.status, 'healthy');
|
|
489
|
+
} finally {
|
|
490
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Codex tolerates a leading UTF-8 BOM on config.toml, so doctor must too: strip it
|
|
496
|
+
// before parsing (like readJson) rather than failing closed to tampered.
|
|
497
|
+
test('detectTools: codex config with a leading UTF-8 BOM (`[features] hooks = true`) → healthy', () => {
|
|
498
|
+
withHome((tmp, th) => {
|
|
499
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
500
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
501
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
502
|
+
}));
|
|
503
|
+
writeFile(wrapper, '# unbound');
|
|
504
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), '\uFEFF[features]\nhooks = true\n');
|
|
505
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
366
506
|
try {
|
|
367
507
|
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
368
508
|
assert.equal(t.status, 'healthy');
|
|
@@ -372,6 +512,65 @@ test('detectTools: codex binary regression (wrapper at ~/.codex/hooks/unbound.py
|
|
|
372
512
|
});
|
|
373
513
|
});
|
|
374
514
|
|
|
515
|
+
// The installer can emit the inline-table form `features = { hooks = true }`, which is
|
|
516
|
+
// the same [features] hooks = true after parsing, so it must read healthy.
|
|
517
|
+
test('detectTools: codex inline `features = { hooks = true }` → healthy', () => {
|
|
518
|
+
withHome((tmp, th) => {
|
|
519
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
520
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
521
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
522
|
+
}));
|
|
523
|
+
writeFile(wrapper, '# unbound');
|
|
524
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), 'features = { hooks = true }\n');
|
|
525
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
526
|
+
try {
|
|
527
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
528
|
+
assert.equal(t.status, 'healthy');
|
|
529
|
+
} finally {
|
|
530
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// A config Codex can't parse can't have hooks enabled, so even one that contains a
|
|
536
|
+
// `codex_hooks = true` line must read tampered rather than healthy.
|
|
537
|
+
test('detectTools: codex with a malformed config.toml → tampered', () => {
|
|
538
|
+
withHome((tmp, th) => {
|
|
539
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
540
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
541
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
542
|
+
}));
|
|
543
|
+
writeFile(wrapper, '# unbound');
|
|
544
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), 'codex_hooks = true\n[unterminated\n');
|
|
545
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
546
|
+
try {
|
|
547
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
548
|
+
assert.equal(t.status, 'tampered');
|
|
549
|
+
} finally {
|
|
550
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// The hook file going missing must still read tampered even when the feature flag is set.
|
|
556
|
+
test('detectTools: codex hook script missing (flag set) → tampered', () => {
|
|
557
|
+
withHome((tmp, th) => {
|
|
558
|
+
const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
|
|
559
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
|
|
560
|
+
hooks: { PreToolUse: [{ command: wrapper }] },
|
|
561
|
+
}));
|
|
562
|
+
// Deliberately do NOT create the unbound.py hook script.
|
|
563
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), '[features]\nhooks = true\n');
|
|
564
|
+
process.env.UNBOUND_CODEX_API_KEY = 'k';
|
|
565
|
+
try {
|
|
566
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
567
|
+
assert.equal(t.status, 'tampered');
|
|
568
|
+
} finally {
|
|
569
|
+
delete process.env.UNBOUND_CODEX_API_KEY;
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
375
574
|
// envCheck's rc-file fallback applies to the gateway URL check
|
|
376
575
|
// (ANTHROPIC_BASE_URL is still equality-checked since tenants share one
|
|
377
576
|
// gateway URL). API keys are no longer equality-checked — see WEB-4949 — so
|