unbound-cli 1.15.2 → 1.16.8

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
 
@@ -85,12 +86,19 @@ function resolveSetupAllTools(clear) {
85
86
  return clear ? Object.keys(SETUP_TOOL_MAP) : [...SETUP_ALL_TOOLS];
86
87
  }
87
88
 
89
+ // OpenAI-compatible Base URL: the gateway host with the /v1 path these clients
90
+ // POST to. Strips a trailing slash because getGatewayUrl() returns env/config
91
+ // values unnormalized, which would otherwise double the slash.
92
+ function openAiBaseUrl(gatewayUrl) {
93
+ return `${String(gatewayUrl).replace(/\/+$/, '')}/v1`;
94
+ }
95
+
88
96
  // Instruction-only tools (display config values, no setup scripts)
89
97
  const INSTRUCTION_TOOLS = {
90
98
  'roo-code': { label: 'Roo Code', values: (apiKey) => [['API Provider', 'unbound'], ['API Key', apiKey]] },
91
- 'cline': { label: 'Cline', values: (apiKey, frontendUrl) => [['API Provider', 'OpenAI Compatible'], ['Base URL', frontendUrl], ['API Key', apiKey]] },
99
+ 'cline': { label: 'Cline', values: (apiKey, gatewayUrl) => [['API Provider', 'OpenAI Compatible'], ['Base URL', openAiBaseUrl(gatewayUrl)], ['API Key', apiKey]] },
92
100
  'kilo-code': { label: 'Kilo Code', values: (apiKey) => [['API Provider', 'Unbound'], ['API Key', apiKey]] },
93
- 'custom-access': { label: 'Custom Access', values: (apiKey, frontendUrl) => [['API Key', apiKey], ['Base URL', frontendUrl]] },
101
+ 'custom-access': { label: 'Custom Access', values: (apiKey, gatewayUrl) => [['API Key', apiKey], ['Base URL', openAiBaseUrl(gatewayUrl)]] },
94
102
  };
95
103
 
96
104
  // Tools that need interactive mode selection (claude-code, codex without suffix)
@@ -245,18 +253,45 @@ async function runPythonScript(scriptPath, args, { capture }) {
245
253
  * their browser-auth host) and to MDM scripts as --frontend-url (persist-only,
246
254
  * no auth flow). Either way it lands in ~/.unbound/config.json as frontend_url.
247
255
  */
248
- function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill } = {}) {
256
+ function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill, configDir } = {}) {
249
257
  // --clear runs no auth in the Python scripts, so the key may be absent. Omit
250
258
  // the flag entirely rather than passing --api-key 'undefined'.
251
259
  let args = apiKey ? `--api-key ${shellEscape(apiKey)}` : '';
252
260
  if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
253
261
  if (gatewayUrl) args += ` --gateway-url ${shellEscape(gatewayUrl)}`;
254
262
  if (frontendUrl) args += mdm ? ` --frontend-url ${shellEscape(frontendUrl)}` : ` --domain ${shellEscape(frontendUrl)}`;
263
+ if (configDir) args += ` --config-dir ${shellEscape(configDir)}`;
255
264
  if (clear) args += ' --clear';
256
265
  if (backfill) args += ' --backfill';
257
266
  return args.trim();
258
267
  }
259
268
 
269
+ // User-level Claude install scripts that honor CLAUDE_CONFIG_DIR. MDM variants are
270
+ // excluded — they install per managed-user home, not the admin's env.
271
+ const CLAUDE_CONFIG_DIR_SCRIPTS = new Set([
272
+ 'claude-code/hooks/setup.py',
273
+ 'claude-code/gateway/setup.py',
274
+ ]);
275
+
276
+ function configDirForScript(scriptPath) {
277
+ if (!CLAUDE_CONFIG_DIR_SCRIPTS.has(scriptPath)) return undefined;
278
+ return claudePaths.isNonDefaultConfigDir() ? claudePaths.resolveClaudeConfigDir() : undefined;
279
+ }
280
+
281
+ function warnIfHooksMisplaced(scriptPath) {
282
+ if (!CLAUDE_CONFIG_DIR_SCRIPTS.has(scriptPath)) return;
283
+ if (claudePaths.isBlankConfigDir()) {
284
+ output.warn(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
285
+ return;
286
+ }
287
+ const mismatch = claudePaths.detectConfigDirMismatch();
288
+ if (!mismatch) return;
289
+ output.warn(
290
+ `Claude install landed in ${mismatch.staleDir} but CLAUDE_CONFIG_DIR points at ${mismatch.activeDir}; ` +
291
+ 'Claude will not read it. Re-run setup with CLAUDE_CONFIG_DIR set.'
292
+ );
293
+ }
294
+
260
295
  // Backfill only applies to the hooks variants of Claude Code / Codex / Copilot;
261
296
  // gateway mode and Cursor have no local transcripts to seed.
262
297
  function scriptSupportsBackfill(scriptPath) {
@@ -289,9 +324,11 @@ function noteBackfillUnsupported(label, scriptPath) {
289
324
  * script signals an MDM skip; throws on download or script failure.
290
325
  */
291
326
  async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
292
- const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill });
327
+ const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill, configDir: configDirForScript(scriptPath) });
293
328
  console.log('');
294
- return runPythonScript(scriptPath, args, { capture: false });
329
+ const r = await runPythonScript(scriptPath, args, { capture: false });
330
+ if (!clear && !(r && r.mdmSkipped)) warnIfHooksMisplaced(scriptPath);
331
+ return r;
295
332
  }
296
333
 
297
334
  /**
@@ -302,7 +339,11 @@ async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, f
302
339
  * download exited 0 because dash has no pipefail — is closed.
303
340
  */
304
341
  function runScriptPiped(scriptPath, args) {
305
- return runPythonScript(scriptPath, args, { capture: true });
342
+ const isClear = /(^|\s)--clear(\s|$)/.test(args);
343
+ return runPythonScript(scriptPath, args, { capture: true }).then((r) => {
344
+ if (!isClear && !(r && r.mdmSkipped)) warnIfHooksMisplaced(scriptPath);
345
+ return r;
346
+ });
306
347
  }
307
348
 
308
349
  /**
@@ -494,6 +535,20 @@ function runBinaryClear({
494
535
  }
495
536
  }
496
537
 
538
+ // Windows has no `sudo`: elevation is a property of the terminal, not a command
539
+ // prefix. Advise the platform's real mechanism instead of one that fails to run.
540
+ function elevated(cmd) {
541
+ return process.platform === 'win32' ? `${cmd} in an Administrator terminal` : `sudo ${cmd}`;
542
+ }
543
+
544
+ // Elevation alone selects org-wide scope. On Windows a terminal is routinely elevated
545
+ // for unrelated reasons, so the scope has to be stated rather than inferred silently.
546
+ function scopeNotice(isMdm) {
547
+ return isMdm
548
+ ? 'Scope: organization-wide, every user on this device (this terminal is elevated).'
549
+ : 'Scope: your account only.';
550
+ }
551
+
497
552
  /**
498
553
  * Returns true when the process has the privileges needed to touch system-level
499
554
  * (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
@@ -650,7 +705,7 @@ async function clearToolsBestEffort(layer, tools, { mdm, backendUrl, gatewayUrl,
650
705
  try {
651
706
  // frontendUrl is forwarded for parity with `setup --clear`; buildScriptArgs
652
707
  // only appends --domain for non-MDM tools, so MDM clears stay unaffected.
653
- const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm });
708
+ const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm, configDir: configDirForScript(tool.script) });
654
709
  await runScriptPiped(tool.script, args);
655
710
  s.succeed(`${layer}: ${tool.label}`);
656
711
  } catch (err) {
@@ -778,6 +833,7 @@ must update the MDM configuration.
778
833
  // every user on the device (MDM scope); without it, just the current
779
834
  // user. URLs were already persisted above and apply to both scopes.
780
835
  const isMdm = hasRootPrivileges();
836
+ output.info(scopeNotice(isMdm));
781
837
  if (isMdm) {
782
838
  const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
783
839
  const adminApiKey = opts.adminApiKey || opts.apiKey || config.getApiKey();
@@ -854,6 +910,7 @@ must update the MDM configuration.
854
910
  clear: opts.clear,
855
911
  mdm: true,
856
912
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
913
+ configDir: configDirForScript(tool.script),
857
914
  });
858
915
  return runScriptPiped(tool.script, toolArgs);
859
916
  },
@@ -910,6 +967,7 @@ must update the MDM configuration.
910
967
  ...urlOpts,
911
968
  clear: opts.clear,
912
969
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
970
+ configDir: configDirForScript(tool.script),
913
971
  });
914
972
  return runScriptPiped(tool.script, toolArgs);
915
973
  }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
@@ -1003,7 +1061,7 @@ must update the MDM configuration.
1003
1061
  if (r && r.mdmSkipped) reportMdmSkips([label]);
1004
1062
  }
1005
1063
  } else if (INSTRUCTION_TOOLS[toolName]) {
1006
- output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
1064
+ output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, gatewayUrl));
1007
1065
  }
1008
1066
  return;
1009
1067
  }
@@ -1048,6 +1106,7 @@ must update the MDM configuration.
1048
1106
  ...urlOpts,
1049
1107
  clear: opts.clear,
1050
1108
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
1109
+ configDir: configDirForScript(tool.script),
1051
1110
  });
1052
1111
  return runScriptPiped(tool.script, toolArgs);
1053
1112
  }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
@@ -1058,7 +1117,7 @@ must update the MDM configuration.
1058
1117
  for (const toolName of instructionResults) {
1059
1118
  console.log('');
1060
1119
  output.info(`${INSTRUCTION_TOOLS[toolName].label}:`);
1061
- output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
1120
+ output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, gatewayUrl));
1062
1121
  }
1063
1122
 
1064
1123
  } catch (err) {
@@ -1207,6 +1266,7 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
1207
1266
  const args = buildScriptArgs(apiKey, {
1208
1267
  backendUrl, frontendUrl, gatewayUrl,
1209
1268
  backfill: backfill && scriptSupportsBackfill(tool.script),
1269
+ configDir: configDirForScript(tool.script),
1210
1270
  });
1211
1271
  return runScriptPiped(tool.script, args);
1212
1272
  }, { report: { apiKey, backendUrl } });
@@ -1228,6 +1288,7 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
1228
1288
  const args = buildScriptArgs(adminApiKey, {
1229
1289
  backendUrl, frontendUrl, gatewayUrl, mdm: true,
1230
1290
  backfill: backfill && scriptSupportsBackfill(tool.script),
1291
+ configDir: configDirForScript(tool.script),
1231
1292
  });
1232
1293
  return runScriptPiped(tool.script, args);
1233
1294
  }, { report: { apiKey: adminApiKey, backendUrl } });
@@ -1235,12 +1296,16 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
1235
1296
 
1236
1297
  module.exports = {
1237
1298
  register,
1299
+ elevated,
1300
+ INSTRUCTION_TOOLS,
1301
+ scopeNotice,
1238
1302
  runSetupAllBundle,
1239
1303
  runMdmSetupAllBundle,
1240
1304
  hasRootPrivileges,
1241
1305
  ALL_TOOLS,
1242
1306
  MDM_ALL_TOOLS,
1243
1307
  buildScriptArgs,
1308
+ configDirForScript,
1244
1309
  scriptSupportsBackfill,
1245
1310
  resolveSetupAllTools,
1246
1311
  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) => {