unbound-cli 1.7.0 → 1.7.1

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/LOCAL_DEV.md CHANGED
@@ -83,7 +83,7 @@ node src/index.js setup --backend-url http://localhost:8000 --frontend-url http:
83
83
  sudo node src/index.js setup --api-key <ADMIN_KEY> --all --backend-url http://localhost:8000 --frontend-url http://localhost:3000
84
84
 
85
85
  # Onboarding (combined setup + discover)
86
- node src/index.js onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> \
86
+ node src/index.js onboard --api-key <USER_KEY> \
87
87
  --backend-url http://localhost:8000 --frontend-url http://localhost:3000
88
88
  sudo node src/index.js onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> \
89
89
  --backend-url http://localhost:8000 --frontend-url http://localhost:3000
@@ -183,8 +183,7 @@ node src/index.js setup --all
183
183
  node src/index.js setup --all --api-key <key>
184
184
 
185
185
  # Onboarding (one command: login + setup --all + discover)
186
- node src/index.js onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
187
- sudo node src/index.js onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
186
+ node src/index.js onboard --api-key <USER_KEY>
188
187
 
189
188
  # MDM onboarding (admin device enrollment, requires root — MDM scope auto-detected from sudo)
190
189
  sudo node src/index.js onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
package/README.md CHANGED
@@ -37,7 +37,7 @@ For new users, the fastest path from install to a fully-configured device is the
37
37
  ```bash
38
38
  # End user
39
39
  npm install -g unbound-cli
40
- unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
40
+ unbound onboard --api-key <USER_KEY>
41
41
 
42
42
  # Admin enrolling a device via MDM (requires root)
43
43
  sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -199,7 +199,9 @@ Scans this device for installed AI coding tools (Cursor, Claude Code,
199
199
  Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
200
200
  and more) and reports findings to the Unbound backend.
201
201
 
202
- The --api-key is a discovery-specific key (separate from login credentials).
202
+ Which --api-key to use depends on scope:
203
+ - Single-user scan (no sudo): use your own user API key.
204
+ - All-users scan (sudo): use the org discovery key.
203
205
  The --domain defaults to the backend URL configured via "unbound config set-backend-url"
204
206
  (falls back to https://backend.getunbound.ai when unset).
205
207
 
@@ -27,7 +27,7 @@ function register(program) {
27
27
  )
28
28
  .option('--api-key <key>', 'API key (user key, or admin key when run with sudo). Falls back to UNBOUND_API_KEY or a stored `unbound login` key)')
29
29
  .addOption(new Option('--admin-api-key <key>', 'Alias for --api-key (back-compat)').hideHelp())
30
- .option('--discovery-key <key>', 'Discovery API key for device scan (or set UNBOUND_DISCOVERY_KEY)')
30
+ .option('--discovery-key <key>', 'Org discovery key for the device scan — required only under sudo (MDM/org scope); ignored for per-user onboarding (or set UNBOUND_DISCOVERY_KEY)')
31
31
  .option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
32
32
  .option('--set-cron', 'Set up a daily background job to keep governance up to date (under sudo, schedules a discovery scan only — not a full tool re-install)')
33
33
  .option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
@@ -37,24 +37,27 @@ function register(program) {
37
37
  .addHelpText('after', `
38
38
  Runs the full onboarding flow, auto-detecting scope from your privileges:
39
39
  With sudo, installs the MDM tool bundle (${MDM_ALL_TOOLS.join(', ')}) and
40
- scans all users on the device. Without sudo, installs the user bundle
41
- (${ALL_TOOLS.join(', ')}) and scans the current user.
40
+ scans all users on the device using --discovery-key. Without sudo, installs
41
+ the user bundle (${ALL_TOOLS.join(', ')}) and scans the current user using
42
+ your own API key.
42
43
 
43
44
  1. Logs in / resolves the API key (or reuses a stored \`unbound login\` key
44
45
  when --api-key is omitted).
45
46
  2. Installs the tool bundle for the detected scope.
46
- 3. Runs device discovery with --discovery-key. With --set-cron, sets up a
47
- recurring daily scheduled scan (cross-platform) instead of a one-time scan.
47
+ 3. Runs device discovery. Per-user scope scans with your API key so the
48
+ device is attributed to you from the first report; sudo/MDM scope scans
49
+ all users with --discovery-key. With --set-cron, sets up a recurring daily
50
+ scheduled scan (cross-platform) instead of a one-time scan.
48
51
 
49
- The API key and discovery API key are separate keys obtained from different
50
- parts of the Unbound dashboard. Discovery uses its own key that is not stored
51
- in the CLI config.
52
+ Per-user onboarding needs only your API key. The separate --discovery-key is
53
+ required only under sudo (MDM/org enrollment), where the scan covers every user
54
+ on the device and cannot be attributed from a single user's key.
52
55
 
53
56
  Examples:
54
- $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
57
+ $ unbound onboard --api-key <USER_KEY>
55
58
  $ sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
56
- $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --backfill
57
- $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --set-cron
59
+ $ unbound onboard --api-key <USER_KEY> --backfill
60
+ $ unbound onboard --api-key <USER_KEY> --set-cron
58
61
  `)
59
62
  .action(telemetry.wrapAction('onboard', async (opts) => {
60
63
  const apiKeyOpt = opts.apiKey || opts.adminApiKey || process.env.UNBOUND_API_KEY;
@@ -65,8 +68,12 @@ Examples:
65
68
  telemetry.rememberSecret(discoveryKeyOpt);
66
69
  const isMdm = hasRootPrivileges();
67
70
 
68
- if (!discoveryKeyOpt) {
69
- output.error('--discovery-key is required (or set UNBOUND_DISCOVERY_KEY env var)');
71
+ // MDM/org scope (sudo) scans every user on the device, so it still needs a
72
+ // dedicated org discovery key. Per-user scope scans only the current user
73
+ // and attributes the device via that user's own API key (see Step 2), so a
74
+ // separate discovery key is neither needed nor used.
75
+ if (isMdm && !discoveryKeyOpt) {
76
+ output.error('--discovery-key is required under sudo (MDM/org enrollment), or set UNBOUND_DISCOVERY_KEY env var');
70
77
  process.exitCode = 1;
71
78
  return;
72
79
  }
@@ -77,6 +84,13 @@ Examples:
77
84
  process.exitCode = 1;
78
85
  return;
79
86
  }
87
+ // Back-compat: --discovery-key used to be required for per-user onboarding.
88
+ // It is now ignored in user scope — the personal API key is used for the
89
+ // scan so the device is attributed to the user from the first report
90
+ // (WEB-4891). Warn rather than fail so existing scripts keep working.
91
+ if (!isMdm && discoveryKeyOpt) {
92
+ output.warn('--discovery-key is deprecated for per-user onboarding and is ignored; your API key is used for the device scan, so the device is attributed to you. You can drop --discovery-key (and unset UNBOUND_DISCOVERY_KEY if it is set in your environment).');
93
+ }
80
94
 
81
95
  let setupSucceeded = false;
82
96
  let discoverySucceeded = false;
@@ -148,7 +162,11 @@ Examples:
148
162
  console.log('');
149
163
  output.info('Step 2/2: Running device discovery');
150
164
  console.log('');
151
- await runDiscoveryScan({ apiKey: discoveryKeyOpt, domain: discoveryDomain });
165
+ // Per-user scope: scan with the user's own API key so the backend
166
+ // attributes the device to them at ingestion. Using the org discovery
167
+ // key here leaves the device unattributed until a device->user mapping
168
+ // resolves later (WEB-4891).
169
+ await runDiscoveryScan({ apiKey, domain: discoveryDomain });
152
170
  discoverySucceeded = true;
153
171
  skippedTools = skipped;
154
172
  }
@@ -168,11 +186,12 @@ Examples:
168
186
  skipRunAtLoad: true,
169
187
  });
170
188
  } else {
189
+ // Per-user cron re-runs `unbound onboard`, which now scans with the
190
+ // user's own API key — no separate discovery key to store.
171
191
  output.info('Setting up daily scheduled run');
172
192
  await setupScheduledRun({
173
193
  command: 'onboard',
174
194
  apiKey: apiKeyOpt || config.getApiKey(),
175
- discoveryKey: discoveryKeyOpt,
176
195
  domain: discoveryDomain,
177
196
  skipRunAtLoad: true,
178
197
  });
@@ -199,11 +218,15 @@ Examples:
199
218
  if (isMdm) {
200
219
  console.error(` Re-run just the scheduled scan with: sudo unbound discover --api-key <DISCOVERY_KEY> --set-cron${suffix}`);
201
220
  } else {
202
- console.error(` Re-run cron setup with: unbound onboard --api-key <KEY> --discovery-key <DISCOVERY_KEY> --set-cron${suffix}`);
221
+ console.error(` Re-run cron setup with: unbound onboard --api-key <KEY> --set-cron${suffix}`);
203
222
  }
204
223
  } else if (setupSucceeded && !discoverySucceeded) {
205
224
  console.error(' Tool setup completed successfully — only discovery failed.');
206
- console.error(` Re-run discovery only with: ${sudo}unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
225
+ if (isMdm) {
226
+ console.error(` Re-run discovery only with: sudo unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
227
+ } else {
228
+ console.error(` Re-run discovery only with: unbound discover --api-key <KEY>${suffix}`);
229
+ }
207
230
  }
208
231
  process.exitCode = 1;
209
232
  }
package/src/index.js CHANGED
@@ -55,7 +55,7 @@ AUTHENTICATION
55
55
  $ unbound config urls <gateway-url> <frontend-url> <backend-url>
56
56
 
57
57
  ONBOARDING (one-step install + discover; scope auto-detected from sudo)
58
- $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> Current user
58
+ $ unbound onboard --api-key <USER_KEY> Current user
59
59
  $ sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> All users (MDM)
60
60
 
61
61
  TOOL SETUP
@@ -100,7 +100,9 @@ test('onboard schedules via setupScheduledRun with command onboard', () => {
100
100
  );
101
101
  });
102
102
 
103
- test('onboard passes both apiKey and discoveryKey to setupScheduledRun', () => {
103
+ // WEB-4891: per-user onboarding scans with the user's own API key, so the
104
+ // per-user cron stores a single key — no separate discoveryKey.
105
+ test('per-user onboard cron passes apiKey but NOT discoveryKey to setupScheduledRun', () => {
104
106
  const callIdx = onboardSrc.indexOf("command: 'onboard'");
105
107
  assert.notEqual(callIdx, -1, 'expected command: onboard call site in onboard.js');
106
108
  const open = onboardSrc.lastIndexOf('{', callIdx);
@@ -112,10 +114,31 @@ test('onboard passes both apiKey and discoveryKey to setupScheduledRun', () => {
112
114
  }
113
115
  const callArgs = onboardSrc.slice(open, close + 1);
114
116
  assert.ok(callArgs.includes('apiKey'), 'setupScheduledRun call must include apiKey');
115
- assert.ok(callArgs.includes('discoveryKey'), 'setupScheduledRun call must include discoveryKey');
117
+ assert.ok(!callArgs.includes('discoveryKey'), 'per-user onboard cron must NOT pass a discoveryKey (single-key onboarding, WEB-4891)');
116
118
  assert.ok(!callArgs.toLowerCase().includes('backfill'), 'setupScheduledRun call must not include backfill');
117
119
  });
118
120
 
121
+ // WEB-4891 core fix: the per-user discovery scan must run with the user's own
122
+ // API key so the backend attributes the device to them at ingestion. Only the
123
+ // MDM (sudo) branch — which scans every user and cannot be attributed from one
124
+ // user's key — may scan with the org discovery key.
125
+ test('per-user discovery scans with the user key; only MDM uses the discovery key', () => {
126
+ const userBranchStart = onboardSrc.indexOf('const apiKey = config.getApiKey();');
127
+ assert.notEqual(userBranchStart, -1, 'expected the per-user branch in onboard.js');
128
+ const userBranch = onboardSrc.slice(userBranchStart);
129
+ assert.match(
130
+ userBranch,
131
+ /runDiscoveryScan\(\{\s*apiKey\s*,/,
132
+ 'per-user onboarding must scan with the user apiKey, not the org discovery key'
133
+ );
134
+ const discoveryKeyScans = (onboardSrc.match(/runDiscoveryScan\(\{\s*apiKey:\s*discoveryKeyOpt/g) || []).length;
135
+ assert.equal(
136
+ discoveryKeyScans,
137
+ 1,
138
+ 'exactly one runDiscoveryScan (the MDM branch) may use the org discovery key'
139
+ );
140
+ });
141
+
119
142
  test('onboard imports setupScheduledRun from ../scheduled', () => {
120
143
  assert.match(
121
144
  onboardSrc,
@@ -35,7 +35,7 @@ test('hasRootPrivileges: false when effective uid is not 0 (no sudo)', () => {
35
35
  // ---- 2. onboard routes by detected scope ----
36
36
  // Reloads the module graph with stubbed deps so we can observe which bundle
37
37
  // helper the single `onboard` command invokes.
38
- async function runOnboard(isRoot, { setCron = false } = {}) {
38
+ async function runOnboard(isRoot, { setCron = false, discoveryKey = 'd' } = {}) {
39
39
  for (const m of [
40
40
  '../src/commands/onboard',
41
41
  '../src/commands/setup',
@@ -48,7 +48,7 @@ async function runOnboard(isRoot, { setCron = false } = {}) {
48
48
  delete require.cache[require.resolve(m)];
49
49
  }
50
50
 
51
- const calls = { mdm: 0, user: 0, discovery: 0, loggedIn: 0, cron: null };
51
+ const calls = { mdm: 0, user: 0, discovery: 0, loggedIn: 0, cron: null, warns: [] };
52
52
  const setup = require('../src/commands/setup');
53
53
  const discover = require('../src/commands/discover');
54
54
  const auth = require('../src/auth');
@@ -59,7 +59,7 @@ async function runOnboard(isRoot, { setCron = false } = {}) {
59
59
  setup.hasRootPrivileges = () => isRoot;
60
60
  setup.runMdmSetupAllBundle = async () => { calls.mdm++; return { ok: true, skipped: [] }; };
61
61
  setup.runSetupAllBundle = async () => { calls.user++; return { ok: true, skipped: [] }; };
62
- discover.runDiscoveryScan = async () => { calls.discovery++; };
62
+ discover.runDiscoveryScan = async (o) => { calls.discovery++; calls.discoveryArgs = o; };
63
63
  auth.ensureLoggedIn = async () => { calls.loggedIn++; };
64
64
  scheduled.setupScheduledRun = async (o) => { calls.cron = o; };
65
65
  config.setUrls = () => ({});
@@ -68,13 +68,15 @@ async function runOnboard(isRoot, { setCron = false } = {}) {
68
68
  config.getFrontendUrl = () => 'https://f.acme';
69
69
  config.getGatewayUrl = () => 'https://g.acme';
70
70
  config.isLoggedIn = () => true;
71
- for (const k of ['info', 'success', 'error', 'warn']) output[k] = () => {};
71
+ for (const k of ['info', 'success', 'error']) output[k] = () => {};
72
+ output.warn = (m) => calls.warns.push(m);
72
73
 
73
74
  const { register } = require('../src/commands/onboard');
74
75
  const program = new Command();
75
76
  program.exitOverride();
76
77
  register(program);
77
- const argv = ['node', 'unbound', 'onboard', '--api-key', 'k', '--discovery-key', 'd'];
78
+ const argv = ['node', 'unbound', 'onboard', '--api-key', 'k'];
79
+ if (discoveryKey) argv.push('--discovery-key', discoveryKey);
78
80
  if (setCron) argv.push('--set-cron');
79
81
  await program.parseAsync(argv);
80
82
  return calls;
@@ -86,6 +88,7 @@ test('onboard with sudo/root → installs the MDM bundle (all users), no login',
86
88
  assert.equal(calls.user, 0, 'user bundle NOT installed');
87
89
  assert.equal(calls.loggedIn, 0, 'MDM scope does not run ensureLoggedIn');
88
90
  assert.equal(calls.discovery, 1, 'discovery still runs');
91
+ assert.equal(calls.discoveryArgs.apiKey, 'd', 'MDM scope scans with the org discovery key');
89
92
  });
90
93
 
91
94
  test('onboard without sudo → logs in and installs the user bundle', async () => {
@@ -94,6 +97,9 @@ test('onboard without sudo → logs in and installs the user bundle', async () =
94
97
  assert.equal(calls.mdm, 0, 'MDM bundle NOT installed');
95
98
  assert.equal(calls.loggedIn, 1, 'user scope runs ensureLoggedIn');
96
99
  assert.equal(calls.discovery, 1, 'discovery still runs');
100
+ // WEB-4891: per-user scan uses the resolved user key, NOT the org discovery key.
101
+ assert.equal(calls.discoveryArgs.apiKey, 'resolved-key', 'user scope scans with the user API key');
102
+ assert.notEqual(calls.discoveryArgs.apiKey, 'd', 'user scope must NOT scan with the discovery key');
97
103
  });
98
104
 
99
105
  // --set-cron under sudo schedules discovery only (not a daily full MDM
@@ -110,5 +116,34 @@ test('onboard --set-cron without sudo → schedules the full onboard run', async
110
116
  const calls = await runOnboard(false, { setCron: true });
111
117
  assert.ok(calls.cron, 'a scheduled run was set up');
112
118
  assert.equal(calls.cron.command, 'onboard', 'user scope keeps the full onboard cron');
113
- assert.equal(calls.cron.discoveryKey, 'd', 'discovery key forwarded for the onboard cron');
119
+ // WEB-4891: the per-user cron re-runs onboard, which scans with the user's own
120
+ // key — so the user key is stored and no separate discovery key is.
121
+ assert.equal(calls.cron.apiKey, 'k', 'per-user cron stores the user API key');
122
+ assert.equal(calls.cron.discoveryKey, undefined, 'per-user onboard cron no longer forwards a discovery key');
123
+ });
124
+
125
+ // WEB-4891: the clean per-user path passes NO --discovery-key. It must work
126
+ // without error, scan with the user key, and emit no deprecation warning.
127
+ test('onboard without sudo and without --discovery-key → clean single-key path', async () => {
128
+ const calls = await runOnboard(false, { discoveryKey: null });
129
+ assert.equal(calls.user, 1, 'user bundle installed');
130
+ assert.equal(calls.discovery, 1, 'discovery runs');
131
+ assert.equal(calls.discoveryArgs.apiKey, 'resolved-key', 'scans with the user API key');
132
+ assert.equal(
133
+ calls.warns.filter((w) => /discovery-key is deprecated/.test(w)).length,
134
+ 0,
135
+ 'no deprecation warning when --discovery-key is omitted',
136
+ );
137
+ });
138
+
139
+ // Passing --discovery-key in user scope is deprecated: it is ignored (the scan
140
+ // still uses the user key) and exactly one deprecation warning is emitted.
141
+ test('onboard without sudo WITH --discovery-key → ignored + deprecation warning', async () => {
142
+ const calls = await runOnboard(false, { discoveryKey: 'd' });
143
+ assert.equal(calls.discoveryArgs.apiKey, 'resolved-key', 'still scans with the user API key, not the discovery key');
144
+ assert.equal(
145
+ calls.warns.filter((w) => /discovery-key is deprecated/.test(w)).length,
146
+ 1,
147
+ 'emits exactly one deprecation warning',
148
+ );
114
149
  });