unbound-cli 1.15.1 → 1.16.7

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.
@@ -2,8 +2,8 @@ const { Option } = require('commander');
2
2
  const config = require('../config');
3
3
  const output = require('../output');
4
4
  const telemetry = require('../telemetry');
5
- const { ensureLoggedIn } = require('../auth');
6
- const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
5
+ const { ensureLoggedIn, fetchDeviceOwnerKey } = require('../auth');
6
+ const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, elevated, scopeNotice, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
7
7
  const { runDiscoveryScan } = require('./discover');
8
8
 
9
9
  /**
@@ -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>', 'Org discovery key for the device scan — required only under sudo (MDM/org scope); ignored for per-user onboarding (or set UNBOUND_DISCOVERY_KEY)')
30
+ .addOption(new Option('--discovery-key <key>', 'Deprecated and ignored. The scan uses your API key.').hideHelp())
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)')
@@ -36,26 +36,22 @@ function register(program) {
36
36
  .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
37
37
  .addHelpText('after', `
38
38
  Runs the full onboarding flow, auto-detecting scope from your privileges:
39
- With sudo, installs the MDM tool bundle (${MDM_ALL_TOOLS.join(', ')}) and
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.
39
+ With sudo, installs the MDM tool bundle (${MDM_ALL_TOOLS.join(', ')}) and scans
40
+ every user on the device. Without sudo, installs the user bundle
41
+ (${ALL_TOOLS.join(', ')}) and scans just the current user.
43
42
 
44
43
  1. Logs in / resolves the API key (or reuses a stored \`unbound login\` key
45
44
  when --api-key is omitted).
46
45
  2. Installs the tool bundle for the detected scope.
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.
46
+ 3. Runs device discovery. The device is attributed to the right user
47
+ automatically — no separate discovery key needed. With --set-cron, sets up
48
+ a recurring daily scan instead of a one-time scan.
51
49
 
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.
50
+ Only your API key is required — for both per-user and sudo/MDM onboarding.
55
51
 
56
52
  Examples:
57
53
  $ unbound onboard --api-key <USER_KEY>
58
- $ sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
54
+ $ sudo unbound onboard --api-key <ADMIN_KEY>
59
55
  $ unbound onboard --api-key <USER_KEY> --backfill
60
56
  $ unbound onboard --api-key <USER_KEY> --set-cron
61
57
  `)
@@ -67,35 +63,26 @@ Examples:
67
63
  telemetry.rememberSecret(apiKeyOpt);
68
64
  telemetry.rememberSecret(discoveryKeyOpt);
69
65
  const isMdm = hasRootPrivileges();
66
+ output.info(scopeNotice(isMdm));
70
67
 
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');
77
- process.exitCode = 1;
78
- return;
79
- }
80
- // User scope needs a key or an existing login; MDM scope resolves the
81
- // admin key below (it may come from a stored `unbound login` credential).
68
+ // --api-key (or a stored `unbound login` key) is all that's required, for
69
+ // both scopes. MDM scope resolves the admin key below (it may come from a
70
+ // stored login credential).
82
71
  if (!isMdm && !apiKeyOpt && !config.isLoggedIn()) {
83
72
  output.error('--api-key is required (or set UNBOUND_API_KEY env var, or run `unbound login` first)');
84
73
  process.exitCode = 1;
85
74
  return;
86
75
  }
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).');
76
+ // --discovery-key is accepted but ignored, so existing scripts don't break.
77
+ if (discoveryKeyOpt) {
78
+ output.warn('--discovery-key is deprecated and ignored — the scan uses your API key.');
93
79
  }
94
80
 
95
81
  let setupSucceeded = false;
96
82
  let discoverySucceeded = false;
97
83
  let discoveryDomain;
98
84
  let skippedTools;
85
+ let mdmScanKey; // device-owner key resolved from the serial (MDM scope only)
99
86
  try {
100
87
  // Persist URLs first, then login, then setup — order matters so the
101
88
  // login validates against the new backend and setup wires tools at the
@@ -112,7 +99,9 @@ Examples:
112
99
  const backendUrl = written.base_url || config.getBaseUrl();
113
100
  const frontendUrl = written.frontend_url || config.getFrontendUrl();
114
101
  const gatewayUrl = written.gateway_url || config.getGatewayUrl();
115
- discoveryDomain = opts.domain || backendUrl;
102
+ // Normalize --domain (it may be scheme-less) so it's a valid absolute
103
+ // base URL for both the owner-key lookup and the scan.
104
+ discoveryDomain = opts.domain ? config.normalizeUrl(opts.domain) : backendUrl;
116
105
 
117
106
  if (isMdm) {
118
107
  // Reuse the key stored by `unbound login` when no key was passed.
@@ -123,6 +112,12 @@ Examples:
123
112
  return;
124
113
  }
125
114
 
115
+ // Resolve the owner key before the setup bundle, so a failure aborts
116
+ // before anything is installed. Fetch it from discoveryDomain, the same
117
+ // endpoint the scan reports to, so the key matches the tenant.
118
+ mdmScanKey = await fetchDeviceOwnerKey(adminApiKey, { baseUrl: discoveryDomain });
119
+ telemetry.rememberSecret(mdmScanKey);
120
+
126
121
  console.log('');
127
122
  output.info('Step 1/2: Installing MDM tool bundle (all users)');
128
123
  const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
@@ -137,7 +132,7 @@ Examples:
137
132
  console.log('');
138
133
  output.info('Step 2/2: Running device discovery');
139
134
  console.log('');
140
- await runDiscoveryScan({ apiKey: discoveryKeyOpt, domain: discoveryDomain });
135
+ await runDiscoveryScan({ apiKey: mdmScanKey, domain: discoveryDomain });
141
136
  discoverySucceeded = true;
142
137
  skippedTools = skipped;
143
138
  } else {
@@ -177,11 +172,12 @@ Examples:
177
172
  if (isMdm) {
178
173
  // Under sudo, the recurring job refreshes the device/tool inventory
179
174
  // only — it does NOT re-push the full MDM tool bundle daily, and it
180
- // stores the lower-privilege discovery key, not the admin key.
175
+ // stores the device-owner key (resolved from the serial), not the
176
+ // admin key.
181
177
  output.info('Setting up daily scheduled discovery scan');
182
178
  await setupScheduledRun({
183
179
  command: 'discover',
184
- apiKey: discoveryKeyOpt,
180
+ apiKey: mdmScanKey,
185
181
  domain: discoveryDomain,
186
182
  skipRunAtLoad: true,
187
183
  });
@@ -208,7 +204,6 @@ Examples:
208
204
  telemetry.captureError(err, { tags: { flow: 'onboard' } });
209
205
  if (!err.displayed) output.error(err.message);
210
206
  const suffix = domainHintSuffix(discoveryDomain);
211
- const sudo = isMdm ? 'sudo ' : '';
212
207
  if (discoverySucceeded && opts.setCron) {
213
208
  // Both setup and discovery completed; only the optional --set-cron
214
209
  // step failed. Retry just the cron: under sudo the scheduled job is a
@@ -216,14 +211,14 @@ Examples:
216
211
  // scope it re-runs the full onboard (the bundle reinstall is intended).
217
212
  console.error(' Setup and discovery completed successfully — only scheduled-run setup failed.');
218
213
  if (isMdm) {
219
- console.error(` Re-run just the scheduled scan with: sudo unbound discover --api-key <DISCOVERY_KEY> --set-cron${suffix}`);
214
+ console.error(` Re-run with: ${elevated(`unbound onboard --api-key <ADMIN_KEY> --set-cron${suffix}`)}`);
220
215
  } else {
221
216
  console.error(` Re-run cron setup with: unbound onboard --api-key <KEY> --set-cron${suffix}`);
222
217
  }
223
218
  } else if (setupSucceeded && !discoverySucceeded) {
224
219
  console.error(' Tool setup completed successfully — only discovery failed.');
225
220
  if (isMdm) {
226
- console.error(` Re-run discovery only with: sudo unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
221
+ console.error(` Re-run onboarding with: ${elevated(`unbound onboard --api-key <ADMIN_KEY>${suffix}`)}`);
227
222
  } else {
228
223
  console.error(` Re-run discovery only with: unbound discover --api-key <KEY>${suffix}`);
229
224
  }
@@ -9,6 +9,7 @@ const telemetry = require('../telemetry');
9
9
  const { ensureLoggedIn } = require('../auth');
10
10
  const { confirm, downloadToFile, withSecureTempFile } = require('../utils');
11
11
  const { reportSetupFailure, exitCodeForError } = require('../setup-report');
12
+ const claudePaths = require('../claudePaths');
12
13
 
13
14
  const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
14
15
 
@@ -245,18 +246,45 @@ async function runPythonScript(scriptPath, args, { capture }) {
245
246
  * their browser-auth host) and to MDM scripts as --frontend-url (persist-only,
246
247
  * no auth flow). Either way it lands in ~/.unbound/config.json as frontend_url.
247
248
  */
248
- function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill } = {}) {
249
+ function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill, configDir } = {}) {
249
250
  // --clear runs no auth in the Python scripts, so the key may be absent. Omit
250
251
  // the flag entirely rather than passing --api-key 'undefined'.
251
252
  let args = apiKey ? `--api-key ${shellEscape(apiKey)}` : '';
252
253
  if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
253
254
  if (gatewayUrl) args += ` --gateway-url ${shellEscape(gatewayUrl)}`;
254
255
  if (frontendUrl) args += mdm ? ` --frontend-url ${shellEscape(frontendUrl)}` : ` --domain ${shellEscape(frontendUrl)}`;
256
+ if (configDir) args += ` --config-dir ${shellEscape(configDir)}`;
255
257
  if (clear) args += ' --clear';
256
258
  if (backfill) args += ' --backfill';
257
259
  return args.trim();
258
260
  }
259
261
 
262
+ // User-level Claude install scripts that honor CLAUDE_CONFIG_DIR. MDM variants are
263
+ // excluded — they install per managed-user home, not the admin's env.
264
+ const CLAUDE_CONFIG_DIR_SCRIPTS = new Set([
265
+ 'claude-code/hooks/setup.py',
266
+ 'claude-code/gateway/setup.py',
267
+ ]);
268
+
269
+ function configDirForScript(scriptPath) {
270
+ if (!CLAUDE_CONFIG_DIR_SCRIPTS.has(scriptPath)) return undefined;
271
+ return claudePaths.isNonDefaultConfigDir() ? claudePaths.resolveClaudeConfigDir() : undefined;
272
+ }
273
+
274
+ function warnIfHooksMisplaced(scriptPath) {
275
+ if (!CLAUDE_CONFIG_DIR_SCRIPTS.has(scriptPath)) return;
276
+ if (claudePaths.isBlankConfigDir()) {
277
+ output.warn(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
278
+ return;
279
+ }
280
+ const mismatch = claudePaths.detectConfigDirMismatch();
281
+ if (!mismatch) return;
282
+ output.warn(
283
+ `Claude install landed in ${mismatch.staleDir} but CLAUDE_CONFIG_DIR points at ${mismatch.activeDir}; ` +
284
+ 'Claude will not read it. Re-run setup with CLAUDE_CONFIG_DIR set.'
285
+ );
286
+ }
287
+
260
288
  // Backfill only applies to the hooks variants of Claude Code / Codex / Copilot;
261
289
  // gateway mode and Cursor have no local transcripts to seed.
262
290
  function scriptSupportsBackfill(scriptPath) {
@@ -289,9 +317,11 @@ function noteBackfillUnsupported(label, scriptPath) {
289
317
  * script signals an MDM skip; throws on download or script failure.
290
318
  */
291
319
  async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
292
- const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill });
320
+ const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill, configDir: configDirForScript(scriptPath) });
293
321
  console.log('');
294
- return runPythonScript(scriptPath, args, { capture: false });
322
+ const r = await runPythonScript(scriptPath, args, { capture: false });
323
+ if (!clear && !(r && r.mdmSkipped)) warnIfHooksMisplaced(scriptPath);
324
+ return r;
295
325
  }
296
326
 
297
327
  /**
@@ -302,7 +332,11 @@ async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, f
302
332
  * download exited 0 because dash has no pipefail — is closed.
303
333
  */
304
334
  function runScriptPiped(scriptPath, args) {
305
- return runPythonScript(scriptPath, args, { capture: true });
335
+ const isClear = /(^|\s)--clear(\s|$)/.test(args);
336
+ return runPythonScript(scriptPath, args, { capture: true }).then((r) => {
337
+ if (!isClear && !(r && r.mdmSkipped)) warnIfHooksMisplaced(scriptPath);
338
+ return r;
339
+ });
306
340
  }
307
341
 
308
342
  /**
@@ -494,6 +528,20 @@ function runBinaryClear({
494
528
  }
495
529
  }
496
530
 
531
+ // Windows has no `sudo`: elevation is a property of the terminal, not a command
532
+ // prefix. Advise the platform's real mechanism instead of one that fails to run.
533
+ function elevated(cmd) {
534
+ return process.platform === 'win32' ? `${cmd} in an Administrator terminal` : `sudo ${cmd}`;
535
+ }
536
+
537
+ // Elevation alone selects org-wide scope. On Windows a terminal is routinely elevated
538
+ // for unrelated reasons, so the scope has to be stated rather than inferred silently.
539
+ function scopeNotice(isMdm) {
540
+ return isMdm
541
+ ? 'Scope: organization-wide, every user on this device (this terminal is elevated).'
542
+ : 'Scope: your account only.';
543
+ }
544
+
497
545
  /**
498
546
  * Returns true when the process has the privileges needed to touch system-level
499
547
  * (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
@@ -650,7 +698,7 @@ async function clearToolsBestEffort(layer, tools, { mdm, backendUrl, gatewayUrl,
650
698
  try {
651
699
  // frontendUrl is forwarded for parity with `setup --clear`; buildScriptArgs
652
700
  // only appends --domain for non-MDM tools, so MDM clears stay unaffected.
653
- const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm });
701
+ const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm, configDir: configDirForScript(tool.script) });
654
702
  await runScriptPiped(tool.script, args);
655
703
  s.succeed(`${layer}: ${tool.label}`);
656
704
  } catch (err) {
@@ -778,6 +826,7 @@ must update the MDM configuration.
778
826
  // every user on the device (MDM scope); without it, just the current
779
827
  // user. URLs were already persisted above and apply to both scopes.
780
828
  const isMdm = hasRootPrivileges();
829
+ output.info(scopeNotice(isMdm));
781
830
  if (isMdm) {
782
831
  const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
783
832
  const adminApiKey = opts.adminApiKey || opts.apiKey || config.getApiKey();
@@ -854,6 +903,7 @@ must update the MDM configuration.
854
903
  clear: opts.clear,
855
904
  mdm: true,
856
905
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
906
+ configDir: configDirForScript(tool.script),
857
907
  });
858
908
  return runScriptPiped(tool.script, toolArgs);
859
909
  },
@@ -910,6 +960,7 @@ must update the MDM configuration.
910
960
  ...urlOpts,
911
961
  clear: opts.clear,
912
962
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
963
+ configDir: configDirForScript(tool.script),
913
964
  });
914
965
  return runScriptPiped(tool.script, toolArgs);
915
966
  }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
@@ -1048,6 +1099,7 @@ must update the MDM configuration.
1048
1099
  ...urlOpts,
1049
1100
  clear: opts.clear,
1050
1101
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
1102
+ configDir: configDirForScript(tool.script),
1051
1103
  });
1052
1104
  return runScriptPiped(tool.script, toolArgs);
1053
1105
  }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
@@ -1207,6 +1259,7 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
1207
1259
  const args = buildScriptArgs(apiKey, {
1208
1260
  backendUrl, frontendUrl, gatewayUrl,
1209
1261
  backfill: backfill && scriptSupportsBackfill(tool.script),
1262
+ configDir: configDirForScript(tool.script),
1210
1263
  });
1211
1264
  return runScriptPiped(tool.script, args);
1212
1265
  }, { report: { apiKey, backendUrl } });
@@ -1228,6 +1281,7 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
1228
1281
  const args = buildScriptArgs(adminApiKey, {
1229
1282
  backendUrl, frontendUrl, gatewayUrl, mdm: true,
1230
1283
  backfill: backfill && scriptSupportsBackfill(tool.script),
1284
+ configDir: configDirForScript(tool.script),
1231
1285
  });
1232
1286
  return runScriptPiped(tool.script, args);
1233
1287
  }, { report: { apiKey: adminApiKey, backendUrl } });
@@ -1235,12 +1289,15 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
1235
1289
 
1236
1290
  module.exports = {
1237
1291
  register,
1292
+ elevated,
1293
+ scopeNotice,
1238
1294
  runSetupAllBundle,
1239
1295
  runMdmSetupAllBundle,
1240
1296
  hasRootPrivileges,
1241
1297
  ALL_TOOLS,
1242
1298
  MDM_ALL_TOOLS,
1243
1299
  buildScriptArgs,
1300
+ configDirForScript,
1244
1301
  scriptSupportsBackfill,
1245
1302
  resolveSetupAllTools,
1246
1303
  clearUnboundEnvsEverywhere,
@@ -3,10 +3,12 @@ const api = require('../api');
3
3
  const output = require('../output');
4
4
  const { getDeviceSerial } = require('../device-serial');
5
5
  const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
6
+ const claudePaths = require('../claudePaths');
6
7
 
7
8
  function roleFromPrivileges(p) {
8
9
  if (!p) return null;
9
10
  if (p.is_admin) return 'Admin';
11
+ if (p.is_analytics_viewer) return 'Analytics Viewer';
10
12
  if (p.is_manager) return 'Manager';
11
13
  if (p.is_member) return 'Member';
12
14
  return 'Unknown';
@@ -23,7 +25,7 @@ Output fields:
23
25
  Logged in - Whether credentials are stored (Yes/No)
24
26
  Email - The authenticated user's email (if logged in)
25
27
  Organization - The organization name (if logged in)
26
- Role - Admin / Manager / Member (if logged in)
28
+ Role - Admin / Analytics Viewer / Manager / Member (if logged in)
27
29
  Backend URL - REST API host (configurable for tenant deployments)
28
30
  Frontend URL - Browser login host
29
31
  Gateway URL - AI gateway host (used by tool setup)
@@ -87,6 +89,8 @@ Examples:
87
89
  keySpin.stop();
88
90
  const connected = detected.filter((t) => t.status !== 'not-installed');
89
91
  const skippedCount = countValidationSkipped(connected);
92
+ const configMismatch = claudePaths.detectConfigDirMismatch();
93
+ const blankConfigDir = claudePaths.isBlankConfigDir();
90
94
 
91
95
  if (opts.json) {
92
96
  const cfg = loggedIn ? config.readConfig() : {};
@@ -102,6 +106,8 @@ Examples:
102
106
  api_status: connectivity,
103
107
  connected_tools: connected.map((t) => ({ tool: t.key, label: t.label, mode: t.mode, status: t.status })),
104
108
  key_validations_skipped: skippedCount,
109
+ claude_config_dir_mismatch: configMismatch,
110
+ claude_config_dir_blank: blankConfigDir,
105
111
  });
106
112
  return;
107
113
  }
@@ -128,6 +134,12 @@ Examples:
128
134
  if (skippedCount > 0) {
129
135
  console.log(` ${C.dim(`Note: ${skippedCount} API key validation${skippedCount === 1 ? '' : 's'} skipped (gateway unreachable).`)}`);
130
136
  }
137
+ if (blankConfigDir) {
138
+ console.log(` ${C.yellow('\u26a0')} CLAUDE_CONFIG_DIR is set but empty — Claude Code will not load hooks from ~/.claude. Unset it, or set a real path.`);
139
+ }
140
+ if (configMismatch) {
141
+ console.log(` ${C.yellow('⚠')} Claude Code hooks are in ${configMismatch.staleDir}, but CLAUDE_CONFIG_DIR points at ${configMismatch.activeDir} — run ${C.bold('unbound doctor')}.`);
142
+ }
131
143
  console.log('');
132
144
  } catch (err) {
133
145
  output.error(err.message);
@@ -156,4 +156,20 @@ async function getDeviceSerial() {
156
156
  }
157
157
  }
158
158
 
159
- module.exports = { getDeviceSerial, isValidSerial };
159
+ /**
160
+ * Live hardware serial, bypassing the user-writable cache. Use it for
161
+ * security-sensitive decisions like minting the device-owner key, where a
162
+ * planted cache value must not be trusted. Never throws; returns null when the
163
+ * serial can't be determined.
164
+ */
165
+ async function probeDeviceSerialLive() {
166
+ try {
167
+ const serial = await probeSerial();
168
+ return isValidSerial(serial) ? serial.trim() : null;
169
+ } catch (err) {
170
+ debug(`probeDeviceSerialLive failed: ${err && err.message}`);
171
+ return null;
172
+ }
173
+ }
174
+
175
+ module.exports = { getDeviceSerial, probeDeviceSerialLive, isValidSerial };
package/src/index.js CHANGED
@@ -56,7 +56,7 @@ AUTHENTICATION
56
56
 
57
57
  ONBOARDING (one-step install + discover; scope auto-detected from sudo)
58
58
  $ unbound onboard --api-key <USER_KEY> Current user
59
- $ sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> All users (MDM)
59
+ $ sudo unbound onboard --api-key <ADMIN_KEY> All users (MDM)
60
60
 
61
61
  TOOL SETUP
62
62
  $ unbound setup Select and install multiple tools interactively
@@ -155,7 +155,7 @@ TOOL CONNECTIONS
155
155
  $ unbound tools connect <type> Connect a new tool
156
156
  $ unbound tools approved List approved tool types
157
157
 
158
- ANALYTICS (Admin/Manager; CSV export is Admin-only)
158
+ ANALYTICS (Admin/Analytics Viewer only, for listing as well as CSV export)
159
159
  $ unbound analytics tool-use terminal Terminal commands agents ran
160
160
  $ unbound analytics tool-use mcp-servers MCP servers seen (find --server values here)
161
161
  $ unbound analytics tool-use mcp-actions --server Notion
@@ -167,7 +167,7 @@ ANALYTICS (Admin/Manager; CSV export is Admin-only)
167
167
  csv exports every matching row, not just the page.
168
168
  See "unbound analytics tool-use --help" for the JSON contract used by agents.
169
169
 
170
- CHAT (Admin/Manager)
170
+ CHAT (Admin/Analytics Viewer/Manager)
171
171
  $ unbound chat Interactive REPL (multi-turn, in-memory history)
172
172
  $ unbound chat -m "show cost by provider for the last 30 days"
173
173
  One-shot: render a chart in the terminal
@@ -306,8 +306,8 @@ function describeError(err, what = 'export', { byJobId = false, listing = false
306
306
  }
307
307
  if (err.statusCode === 403) {
308
308
  return listing
309
- ? 'Admin role required. Analytics > Tool Use is restricted to organization admins.'
310
- : 'Admin role required. Exporting data is restricted to organization admins.';
309
+ ? 'Analytics > Tool Use is not available to your role.'
310
+ : 'Exporting data is not available to your role.';
311
311
  }
312
312
  if (err.statusCode === 422) {
313
313
  const rows = Number(err.body?.row_count);
@@ -261,7 +261,8 @@ async function runTerminalPromptCreate(opts) {
261
261
  }
262
262
  if (!privileges || !privileges.is_admin) {
263
263
  let role = 'unknown';
264
- if (privileges && privileges.is_manager) role = 'Manager';
264
+ if (privileges && privileges.is_analytics_viewer) role = 'Analytics Viewer';
265
+ else if (privileges && privileges.is_manager) role = 'Manager';
265
266
  else if (privileges && privileges.is_member) role = 'Member';
266
267
  const e = new Error(`Tool policy creation requires admin role; current role: ${role}.`);
267
268
  e.exitCode = 3;
@@ -425,7 +426,8 @@ async function runMcpPromptCreate(opts) {
425
426
  }
426
427
  if (!privileges || !privileges.is_admin) {
427
428
  let role = 'unknown';
428
- if (privileges && privileges.is_manager) role = 'Manager';
429
+ if (privileges && privileges.is_analytics_viewer) role = 'Analytics Viewer';
430
+ else if (privileges && privileges.is_manager) role = 'Manager';
429
431
  else if (privileges && privileges.is_member) role = 'Member';
430
432
  const e = new Error(`Tool policy creation requires admin role; current role: ${role}.`);
431
433
  e.exitCode = 3;
package/src/scheduled.js CHANGED
@@ -16,13 +16,12 @@ const { shellEscape, downloadToFile, DISCOVER_BASE_URL } = require('./utils');
16
16
  *
17
17
  * @param {object} opts
18
18
  * @param {'onboard'|'discover'} opts.command - which subcommand the cron should run
19
- * @param {string} opts.apiKey - api key (for onboard) or discovery key (for discover)
20
- * @param {string} [opts.discoveryKey] - separate discovery key (onboard only)
19
+ * @param {string} opts.apiKey - api key to run the scheduled scan with (the device-owner key for MDM discover)
21
20
  * @param {string} [opts.domain] - backend domain to pass to the scheduled run
22
21
  * @param {boolean} [opts.skipRunAtLoad] - macOS: set RunAtLoad=false (scan already ran)
23
22
  */
24
23
  async function setupScheduledRun(opts) {
25
- const { command, apiKey, discoveryKey, domain, skipRunAtLoad } = opts;
24
+ const { command, apiKey, domain, skipRunAtLoad } = opts;
26
25
  if (!command || !apiKey) {
27
26
  throw new Error('setupScheduledRun: command and apiKey are required');
28
27
  }
@@ -36,7 +35,6 @@ async function setupScheduledRun(opts) {
36
35
  '--command', shellEscape(command),
37
36
  '--api-key', shellEscape(apiKey),
38
37
  ];
39
- if (discoveryKey) args.push('--discovery-key', shellEscape(discoveryKey));
40
38
  if (domain) args.push('--domain', shellEscape(domain));
41
39
  if (skipRunAtLoad) args.push('--no-run-at-load');
42
40
 
@@ -54,7 +52,7 @@ async function setupScheduledRun(opts) {
54
52
  }
55
53
 
56
54
  async function setupScheduledRunWindows(opts) {
57
- const { command, apiKey, discoveryKey, domain } = opts;
55
+ const { command, apiKey, domain } = opts;
58
56
  const url = `${DISCOVER_BASE_URL}/setup-scheduled-scan.ps1`;
59
57
  const tmp = path.join(os.tmpdir(), `unbound-scheduled-${Date.now()}-${Math.random().toString(36).slice(2)}.ps1`);
60
58
  // Single try/finally covers both download and spawn so a mid-stream
@@ -73,7 +71,6 @@ async function setupScheduledRunWindows(opts) {
73
71
  '-Command', command,
74
72
  '-ApiKey', apiKey,
75
73
  ];
76
- if (discoveryKey) psArgs.push('-DiscoveryKey', discoveryKey);
77
74
  if (domain) psArgs.push('-Domain', domain);
78
75
 
79
76
  await new Promise((resolve, reject) => {