unbound-cli 1.16.10 → 1.16.12

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 CHANGED
@@ -254,7 +254,7 @@ Alias: `unbound groups` works the same as `unbound user-groups`.
254
254
  | `unbound tools connect <type>` | Connect a tool |
255
255
  | `unbound tools approved` | List approved tool types |
256
256
 
257
- Supported tool types: `CLAUDE_CODE`, `UNBOUND_CLAUDE_CODE`, `CURSOR`, `COPILOT`, `AUGMENT_CODE`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `UNBOUND_CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
257
+ Supported tool types: `CLAUDE_CODE`, `CURSOR`, `COPILOT`, `AUGMENT_CODE`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
258
258
 
259
259
  ## Configuration
260
260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.16.10",
3
+ "version": "1.16.12",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,4 +1,5 @@
1
- const { spawn, execSync } = require('child_process');
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
- const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
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/scan.log');
355
- const errPath = path.join(home, 'Library/Logs/unbound/scan.err');
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
@@ -12,7 +12,6 @@ const SUPPORTED_TOOL_TYPES = [
12
12
  'CLINE',
13
13
  'GEMINI_CLI',
14
14
  'CODEX',
15
- 'UNBOUND_CODEX',
16
15
  'KILO_CODE',
17
16
  'CUSTOM_ACCESS',
18
17
  ];
@@ -181,4 +180,4 @@ Examples:
181
180
  });
182
181
  }
183
182
 
184
- module.exports = { register };
183
+ module.exports = { register, SUPPORTED_TOOL_TYPES };
@@ -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,55 @@
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 { SUPPORTED_TOOL_TYPES } = require('../src/commands/tools');
6
+
7
+ // Cross-repo contract: every tool type the CLI offers must be one the backend's
8
+ // agents API accepts, or `tools connect` sends a request the backend answers
9
+ // with 400. Rather than mirror the backend list by hand (which silently drifts),
10
+ // this reads the REAL allowlist from ai-gateway-data at
11
+ // webapp/api/v1/agent_handlers.py::SUPPORTED_AGENT_TOOL_TYPES.
12
+ //
13
+ // The backend repo isn't a dependency of the CLI, so when it isn't checked out
14
+ // beside this one the test skips rather than failing — it runs wherever the
15
+ // fleet is present (a dev machine, the cross-repo CI stage) and is a no-op
16
+ // elsewhere. Point it explicitly with AI_GATEWAY_DATA_DIR.
17
+
18
+ const HANDLERS_REL = 'webapp/api/v1/agent_handlers.py';
19
+
20
+ function findBackendFile() {
21
+ const candidates = [];
22
+ if (process.env.AI_GATEWAY_DATA_DIR) {
23
+ candidates.push(path.join(process.env.AI_GATEWAY_DATA_DIR, HANDLERS_REL));
24
+ }
25
+ // Common layouts: a sibling checkout next to unbound-cli.
26
+ const repoRoot = path.join(__dirname, '..');
27
+ candidates.push(path.join(repoRoot, '..', 'ai-gateway-data', HANDLERS_REL));
28
+ candidates.push(path.join(repoRoot, '..', '..', 'ai-gateway-data', HANDLERS_REL));
29
+ return candidates.find((p) => { try { return fs.existsSync(p); } catch { return false; } });
30
+ }
31
+
32
+ function parseBackendAllowlist(file) {
33
+ const src = fs.readFileSync(file, 'utf8');
34
+ const m = src.match(/SUPPORTED_AGENT_TOOL_TYPES\s*=\s*\[([\s\S]*?)\]/);
35
+ assert.ok(m, 'could not locate SUPPORTED_AGENT_TOOL_TYPES in agent_handlers.py');
36
+ const types = [...m[1].matchAll(/['"]([A-Z_]+)['"]/g)].map((x) => x[1]);
37
+ assert.ok(types.length > 0, 'parsed an empty backend allowlist');
38
+ return new Set(types);
39
+ }
40
+
41
+ const backendFile = findBackendFile();
42
+
43
+ test('every CLI-offered tool type is in the REAL backend agent_handlers allowlist',
44
+ { skip: backendFile ? false : 'ai-gateway-data not checked out (set AI_GATEWAY_DATA_DIR)' },
45
+ () => {
46
+ const accepted = parseBackendAllowlist(backendFile);
47
+ for (const type of SUPPORTED_TOOL_TYPES) {
48
+ assert.ok(accepted.has(type),
49
+ `CLI offers ${type} but the backend allowlist does not accept it (would 400)`);
50
+ }
51
+ // Guard the specific regression: the removed type must be absent from the CLI
52
+ // list precisely because the backend rejects it.
53
+ assert.ok(!SUPPORTED_TOOL_TYPES.includes('UNBOUND_CODEX') || accepted.has('UNBOUND_CODEX'),
54
+ 'UNBOUND_CODEX may only be offered if the backend accepts it');
55
+ });
@@ -0,0 +1,85 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ const config = require('../src/config');
6
+ const api = require('../src/api');
7
+ const output = require('../src/output');
8
+ const { register, SUPPORTED_TOOL_TYPES } = require('../src/commands/tools');
9
+
10
+ // Mirror of ai-gateway-data webapp/api/v1/agent_handlers.py SUPPORTED_AGENT_TOOL_TYPES.
11
+ // `tools connect` must only offer types this backend accepts; anything else gets a 400.
12
+ const BACKEND_ACCEPTED = new Set([
13
+ 'CLAUDE_CODE', 'UNBOUND_CLAUDE_CODE', 'CURSOR', 'COPILOT',
14
+ 'ROO_CODE', 'CLINE', 'GEMINI_CLI', 'CODEX', 'KILO_CODE', 'OPENCLAW',
15
+ 'CUSTOM_ACCESS', 'AUGMENT_CODE',
16
+ ]);
17
+
18
+ // Drive the real `tools connect` action with login + api stubbed. Returns
19
+ // { reachedApi, sentType, errored } so a test can tell a type accepted by the
20
+ // CLI (reaches the connect call) from one the CLI rejects before any request.
21
+ async function connect(toolType) {
22
+ const orig = {
23
+ isLoggedIn: config.isLoggedIn,
24
+ post: api.post,
25
+ error: output.error,
26
+ success: output.success,
27
+ keyValue: output.keyValue,
28
+ json: output.json,
29
+ exitCode: process.exitCode,
30
+ };
31
+ let reachedApi = false;
32
+ let sentType = null;
33
+ let errored = false;
34
+ let errorMsg = '';
35
+ config.isLoggedIn = () => true;
36
+ api.post = async (_path, { body }) => { reachedApi = true; sentType = body.tool_type; return { tool_type: body.tool_type, status: 'connected' }; };
37
+ output.error = (m) => { errored = true; errorMsg = String(m ?? ''); };
38
+ output.success = () => {};
39
+ output.keyValue = () => {};
40
+ output.json = () => {};
41
+ try {
42
+ const program = new Command();
43
+ register(program);
44
+ await program.parseAsync(['node', 'x', 'tools', 'connect', toolType]);
45
+ return { reachedApi, sentType, errored, errorMsg, exitCode: process.exitCode };
46
+ } finally {
47
+ config.isLoggedIn = orig.isLoggedIn;
48
+ api.post = orig.post;
49
+ output.error = orig.error;
50
+ output.success = orig.success;
51
+ output.keyValue = orig.keyValue;
52
+ output.json = orig.json;
53
+ process.exitCode = orig.exitCode;
54
+ }
55
+ }
56
+
57
+ // The backend rejects UNBOUND_CODEX with a 400, so the CLI must not offer it.
58
+ test('tools connect UNBOUND_CODEX is rejected before any backend call', async () => {
59
+ const r = await connect('UNBOUND_CODEX');
60
+ assert.equal(r.reachedApi, false, 'must not send an UNBOUND_CODEX connect request');
61
+ assert.equal(r.errored, true, 'must report an unsupported-type error');
62
+ assert.equal(r.exitCode, 1, 'must exit non-zero so scripts see the rejection');
63
+ // The error points the user at the real types: it names the supported set,
64
+ // including the correct CODEX to reach for instead.
65
+ assert.match(r.errorMsg, /Supported types:/);
66
+ assert.match(r.errorMsg, /\bCODEX\b/);
67
+ });
68
+
69
+ // A representative valid type still connects.
70
+ test('tools connect CODEX reaches the backend with tool_type CODEX', async () => {
71
+ const r = await connect('CODEX');
72
+ assert.equal(r.reachedApi, true);
73
+ assert.equal(r.sentType, 'CODEX');
74
+ });
75
+
76
+ // Drive the actual production allowlist: every type the CLI offers must reach the
77
+ // backend and be one the backend accepts. Adding a backend-rejected type to
78
+ // SUPPORTED_TOOL_TYPES would fail here rather than recur as a runtime 400.
79
+ test('every CLI-offered tool type is accepted by the backend', async () => {
80
+ for (const type of SUPPORTED_TOOL_TYPES) {
81
+ const r = await connect(type);
82
+ assert.equal(r.reachedApi, true, `CLI should offer ${type}`);
83
+ assert.ok(BACKEND_ACCEPTED.has(r.sentType), `CLI offers ${r.sentType} which the backend rejects`);
84
+ }
85
+ });