unbound-cli 1.2.0 → 1.3.0
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 +1 -1
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/commands/chat.js +2 -2
- package/src/commands/doctor.js +198 -0
- package/src/commands/status.js +42 -2
- package/src/config.js +1 -1
- package/src/index.js +4 -4
- package/src/toolHealth.js +288 -0
- package/test/doctor-exit.test.js +31 -0
- package/test/tool-health.test.js +210 -0
- package/src/commands/whoami.js +0 -65
package/LOCAL_DEV.md
CHANGED
|
@@ -119,8 +119,8 @@ node src/index.js login --base-url http://localhost:8000 --api-key <your-key>
|
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
121
|
# Auth
|
|
122
|
-
node src/index.js whoami
|
|
123
122
|
node src/index.js status
|
|
123
|
+
node src/index.js doctor
|
|
124
124
|
|
|
125
125
|
# Policies — overview
|
|
126
126
|
node src/index.js policy # overview + docs links
|
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ unbound login
|
|
|
17
17
|
# Or login with an API key (for CI/CD or headless environments)
|
|
18
18
|
unbound login --api-key <your-api-key>
|
|
19
19
|
|
|
20
|
-
# Check
|
|
21
|
-
unbound
|
|
20
|
+
# Check your status, role, and connected tools
|
|
21
|
+
unbound status
|
|
22
22
|
|
|
23
23
|
# List policies
|
|
24
24
|
unbound policy list
|
|
@@ -55,8 +55,8 @@ The user API key and discovery API key are separate — discovery uses its own k
|
|
|
55
55
|
| `unbound login --api-key <key>` | Sign in with an API key (non-interactive) |
|
|
56
56
|
| `unbound login --domain <domain>` | Sign in via a custom domain |
|
|
57
57
|
| `unbound logout` | Remove stored credentials |
|
|
58
|
-
| `unbound
|
|
59
|
-
| `unbound
|
|
58
|
+
| `unbound status` | Show CLI status, role, and connected tools |
|
|
59
|
+
| `unbound doctor` | Diagnose per-tool health (config, hook, env) and API key |
|
|
60
60
|
|
|
61
61
|
### Tool Setup
|
|
62
62
|
|
package/package.json
CHANGED
package/src/commands/chat.js
CHANGED
|
@@ -121,7 +121,7 @@ function normalizeAssistantTurn(turn) {
|
|
|
121
121
|
// backend-supplied strings out of the shell line so copy-pastes stay safe.
|
|
122
122
|
function friendlyChatError(err) {
|
|
123
123
|
if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
|
|
124
|
-
if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound
|
|
124
|
+
if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound status` to check yours.';
|
|
125
125
|
if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
|
|
126
126
|
return sanitizeForTerminal(err?.message || 'Request failed.');
|
|
127
127
|
}
|
|
@@ -479,7 +479,7 @@ INTERACTIVE SESSION
|
|
|
479
479
|
/exit quit (Ctrl+C also works)
|
|
480
480
|
|
|
481
481
|
NOTES
|
|
482
|
-
* Requires Admin or Manager role in your organization. Run 'unbound
|
|
482
|
+
* Requires Admin or Manager role in your organization. Run 'unbound status' to check.
|
|
483
483
|
* Conversation history is held in memory only; nothing is ever written to disk.
|
|
484
484
|
* Each -m invocation sends no history; keep each prompt self-contained.
|
|
485
485
|
* -o writes a new file with mode 0600 and refuses to overwrite existing files.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const { spawnSync } = require('child_process');
|
|
2
|
+
const config = require('../config');
|
|
3
|
+
const api = require('../api');
|
|
4
|
+
const output = require('../output');
|
|
5
|
+
const { getDeviceSerial } = require('../device-serial');
|
|
6
|
+
const { detectTools } = require('../toolHealth');
|
|
7
|
+
const { hasRootPrivileges } = require('./setup');
|
|
8
|
+
|
|
9
|
+
// Validate the stored API key against the backend. Returns one of:
|
|
10
|
+
// valid | invalid | not-logged-in | unverified (network/other error).
|
|
11
|
+
async function checkApiKey() {
|
|
12
|
+
if (!config.isLoggedIn()) return { state: 'not-logged-in', detail: 'no API key stored — run `unbound login`' };
|
|
13
|
+
try {
|
|
14
|
+
const deviceSerial = await getDeviceSerial();
|
|
15
|
+
const privileges = await api.get('/api/v1/users/privileges/', { query: { device_serial: deviceSerial } });
|
|
16
|
+
config.backfillUserInfo(privileges);
|
|
17
|
+
return { state: 'valid', detail: 'verified against the backend' };
|
|
18
|
+
} catch (err) {
|
|
19
|
+
// The HTTP status is the reliable signal; the message wording can change.
|
|
20
|
+
if (err && (err.statusCode === 401 || /invalid or expired|401|unauthor/i.test(err.message || ''))) {
|
|
21
|
+
return { state: 'invalid', detail: 'the stored key was rejected (401) — run `unbound login`' };
|
|
22
|
+
}
|
|
23
|
+
return { state: 'unverified', detail: `could not verify (${err.message})` };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Exit code for `--fix`: signal/spawn error → 1; otherwise the `setup` status. A
|
|
28
|
+
// successful non-root run that left MDM tools tampered still fails so a CI gate
|
|
29
|
+
// doesn't read the machine as clean. As root those tools were just repaired, so a
|
|
30
|
+
// status-0 run is clean — don't penalize.
|
|
31
|
+
function fixExitCode(status, root, mdmRemaining) {
|
|
32
|
+
if (status == null) return 1;
|
|
33
|
+
return status || (!root && mdmRemaining ? 1 : 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function register(program) {
|
|
37
|
+
program
|
|
38
|
+
.command('doctor')
|
|
39
|
+
.description('Diagnose the local Unbound install: per-tool config, hook script and env wiring, plus API-key validity. Prints how to fix anything broken.')
|
|
40
|
+
.addHelpText('after', `
|
|
41
|
+
For each AI coding tool, doctor verifies:
|
|
42
|
+
Config - the tool's config file contains the Unbound integration
|
|
43
|
+
Hook script - the installed hook/key-helper file exists
|
|
44
|
+
Env wiring - the API key (and gateway URL, for gateway mode) is exported
|
|
45
|
+
|
|
46
|
+
Per-tool status:
|
|
47
|
+
Healthy - everything in place
|
|
48
|
+
Tampered - partially installed (reinstall to fix)
|
|
49
|
+
Not installed - no Unbound integration found
|
|
50
|
+
Managed by MDM- configured org-wide by your administrator
|
|
51
|
+
|
|
52
|
+
Exit code is non-zero when any tool is tampered or the API key is invalid, so
|
|
53
|
+
doctor can gate scripts/CI.
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
$ unbound doctor
|
|
57
|
+
$ unbound doctor --fix Reinstall every tampered tool
|
|
58
|
+
$ unbound doctor --json
|
|
59
|
+
`)
|
|
60
|
+
.option('--fix', 'Reinstall every tool reported as tampered')
|
|
61
|
+
.option('--json', 'Output raw JSON')
|
|
62
|
+
.action(async (opts) => {
|
|
63
|
+
try {
|
|
64
|
+
const apiKey = config.getApiKey();
|
|
65
|
+
const gatewayUrl = config.getGatewayUrl();
|
|
66
|
+
|
|
67
|
+
const spin = output.spinner('Running diagnostics...');
|
|
68
|
+
const key = await checkApiKey();
|
|
69
|
+
const tools = detectTools({ gatewayUrl, apiKey });
|
|
70
|
+
spin.stop();
|
|
71
|
+
|
|
72
|
+
const tampered = tools.filter((t) => t.status === 'tampered');
|
|
73
|
+
const unhealthy = tampered.length > 0 || key.state === 'invalid';
|
|
74
|
+
|
|
75
|
+
if (opts.json) {
|
|
76
|
+
output.json({
|
|
77
|
+
api_key: key,
|
|
78
|
+
tools: tools.map((t) => ({
|
|
79
|
+
tool: t.key,
|
|
80
|
+
label: t.label,
|
|
81
|
+
mode: t.mode,
|
|
82
|
+
status: t.status,
|
|
83
|
+
conflict: !!t.conflict,
|
|
84
|
+
checks: t.checks.map((c) => ({ name: c.name, ok: c.ok, kind: c.kind, detail: c.detail, warn: !!c.warn })),
|
|
85
|
+
})),
|
|
86
|
+
healthy: !unhealthy,
|
|
87
|
+
});
|
|
88
|
+
if (unhealthy) process.exitCode = 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const C = output.colors;
|
|
93
|
+
const labelOf = (t) => t.label + (t.mode ? ` (${t.mode})` : '');
|
|
94
|
+
const W = Math.max(7, ...tools.map((t) => labelOf(t).length)); // 7 = "API key"
|
|
95
|
+
|
|
96
|
+
console.log('');
|
|
97
|
+
const keyText = {
|
|
98
|
+
valid: C.green('✓ Valid'),
|
|
99
|
+
invalid: C.red('✗ Invalid or expired'),
|
|
100
|
+
'not-logged-in': C.dim('– Not logged in'),
|
|
101
|
+
unverified: `${C.yellow('? Could not verify')} ${C.dim(`(${key.detail})`)}`,
|
|
102
|
+
}[key.state];
|
|
103
|
+
console.log(` ${C.bold('API key'.padEnd(W))} ${keyText}`);
|
|
104
|
+
console.log('');
|
|
105
|
+
|
|
106
|
+
for (const t of tools) {
|
|
107
|
+
let s;
|
|
108
|
+
if (t.status === 'healthy') s = C.green('✓ Healthy');
|
|
109
|
+
else if (t.status === 'managed-by-mdm') s = C.green('✓ Healthy') + C.dim(' · managed by MDM');
|
|
110
|
+
else if (t.status === 'tampered') {
|
|
111
|
+
// Status + reason on one line. Mode sits next to the tool name.
|
|
112
|
+
const reasons = [];
|
|
113
|
+
if (t.conflict) reasons.push('two modes installed');
|
|
114
|
+
for (const c of t.checks) if (!c.ok) reasons.push(c.summary || c.name);
|
|
115
|
+
s = C.red(`✗ Tampered — ${reasons.join(', ')}`);
|
|
116
|
+
} else s = C.dim('○ Not set up');
|
|
117
|
+
console.log(` ${labelOf(t).padEnd(W)} ${s}`);
|
|
118
|
+
}
|
|
119
|
+
console.log('');
|
|
120
|
+
|
|
121
|
+
// Fix routing. User-level tools are fixable by anyone (`unbound doctor
|
|
122
|
+
// --fix`); MDM-managed ones need `sudo`. One sudo run repairs everything
|
|
123
|
+
// (sudo `setup` installs MDM-wide, and on macOS sudo keeps HOME so it sees
|
|
124
|
+
// the user's tools too). When both kinds are broken we show both commands
|
|
125
|
+
// so a user WITHOUT sudo can still repair their own user-level tools.
|
|
126
|
+
const userTampered = tampered.filter((t) => t.scope !== 'mdm');
|
|
127
|
+
const mdmTampered = tampered.filter((t) => t.scope === 'mdm');
|
|
128
|
+
const userFix = userTampered.map((t) => t.key);
|
|
129
|
+
const mdmFix = mdmTampered.map((t) => t.key);
|
|
130
|
+
const userNames = userTampered.map((t) => t.label).join(', ');
|
|
131
|
+
const mdmNames = mdmTampered.map((t) => t.label).join(', ');
|
|
132
|
+
const allFix = tampered.map((t) => t.key);
|
|
133
|
+
const anyInstalled = tools.some((t) => t.status !== 'not-installed');
|
|
134
|
+
|
|
135
|
+
if (opts.fix) {
|
|
136
|
+
if (!tampered.length) {
|
|
137
|
+
if (!anyInstalled) console.log(`${C.yellow('No tools are set up yet')} — nothing to fix. Run ${C.bold('unbound setup')} first.`);
|
|
138
|
+
else output.success('Nothing to fix — all set up tools are healthy.');
|
|
139
|
+
console.log('');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const root = hasRootPrivileges();
|
|
143
|
+
// sudo → reinstall everything (MDM-wide); without sudo → the user-level
|
|
144
|
+
// tools only, and point at sudo for whatever's org-managed.
|
|
145
|
+
if (!root && !userFix.length) {
|
|
146
|
+
console.log(`${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
147
|
+
console.log(` Run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
148
|
+
console.log('');
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const fixNow = root ? allFix : userFix;
|
|
153
|
+
output.info(`Reinstalling: ${fixNow.join(', ')}${root ? ' (org-wide, all users)' : ''}`);
|
|
154
|
+
console.log('');
|
|
155
|
+
const r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow], { stdio: 'inherit' });
|
|
156
|
+
if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
157
|
+
process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// No `--fix`: report and show the command(s) that fix it.
|
|
162
|
+
if (!anyInstalled && key.state !== 'invalid') {
|
|
163
|
+
console.log(`${C.yellow('No AI tools are set up yet.')} Run ${C.bold('unbound setup')} to get started.`);
|
|
164
|
+
console.log('');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!unhealthy) {
|
|
168
|
+
output.success('All set up tools are healthy.');
|
|
169
|
+
console.log('');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (tampered.length) {
|
|
173
|
+
console.log(C.red(`✗ ${tampered.length} tool${tampered.length === 1 ? ' needs' : 's need'} attention.`));
|
|
174
|
+
if (!mdmFix.length) {
|
|
175
|
+
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
176
|
+
} else if (!userFix.length) {
|
|
177
|
+
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
178
|
+
console.log(` Run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
179
|
+
} else {
|
|
180
|
+
// Both: a user without sudo can still fix their own tools; sudo fixes everything.
|
|
181
|
+
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
182
|
+
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'} too.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (key.state === 'invalid') {
|
|
186
|
+
if (!tampered.length) console.log(C.red('✗ Your API key is invalid or expired.'));
|
|
187
|
+
console.log(` Run ${C.bold('unbound login')} to re-authenticate.`);
|
|
188
|
+
}
|
|
189
|
+
console.log('');
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (!err.displayed) output.error(err.message);
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { register, fixExitCode };
|
package/src/commands/status.js
CHANGED
|
@@ -2,21 +2,36 @@ const config = require('../config');
|
|
|
2
2
|
const api = require('../api');
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const { getDeviceSerial } = require('../device-serial');
|
|
5
|
+
const { detectTools } = require('../toolHealth');
|
|
6
|
+
|
|
7
|
+
function roleFromPrivileges(p) {
|
|
8
|
+
if (!p) return null;
|
|
9
|
+
if (p.is_admin) return 'Admin';
|
|
10
|
+
if (p.is_manager) return 'Manager';
|
|
11
|
+
if (p.is_member) return 'Member';
|
|
12
|
+
return 'Unknown';
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
|
|
6
16
|
function register(program) {
|
|
7
17
|
program
|
|
8
18
|
.command('status')
|
|
9
|
-
.description('Show the current CLI status
|
|
19
|
+
.description('Show the current CLI status: config location, login state, role, connected tools, and API connectivity. Useful for debugging connection issues.')
|
|
10
20
|
.addHelpText('after', `
|
|
11
21
|
Output fields:
|
|
12
22
|
Config file - Path to the config file (~/.unbound/config.json)
|
|
13
23
|
Logged in - Whether credentials are stored (Yes/No)
|
|
14
24
|
Email - The authenticated user's email (if logged in)
|
|
15
25
|
Organization - The organization name (if logged in)
|
|
26
|
+
Role - Admin / Manager / Member (if logged in)
|
|
16
27
|
Backend URL - REST API host (configurable for tenant deployments)
|
|
17
28
|
Frontend URL - Browser login host
|
|
18
29
|
Gateway URL - AI gateway host (used by tool setup)
|
|
19
30
|
API status - Connectivity check result (Connected / Error)
|
|
31
|
+
Connected tools - AI tools wired through Unbound on this device, with mode
|
|
32
|
+
|
|
33
|
+
For a deep per-tool health check (config, hook script, env wiring), run
|
|
34
|
+
\`unbound doctor\`.
|
|
20
35
|
|
|
21
36
|
Examples:
|
|
22
37
|
$ unbound status
|
|
@@ -32,8 +47,8 @@ Examples:
|
|
|
32
47
|
['Logged in', loggedIn ? 'Yes' : 'No'],
|
|
33
48
|
];
|
|
34
49
|
|
|
35
|
-
// Check API connectivity
|
|
36
50
|
let connectivity = 'Not checked (not logged in)';
|
|
51
|
+
let role = null;
|
|
37
52
|
if (loggedIn) {
|
|
38
53
|
const spin = output.spinner('Checking API connectivity...');
|
|
39
54
|
try {
|
|
@@ -42,6 +57,7 @@ Examples:
|
|
|
42
57
|
query: { device_serial: deviceSerial },
|
|
43
58
|
});
|
|
44
59
|
config.backfillUserInfo(privileges);
|
|
60
|
+
role = roleFromPrivileges(privileges);
|
|
45
61
|
spin.stop();
|
|
46
62
|
connectivity = 'Connected';
|
|
47
63
|
} catch (err) {
|
|
@@ -52,12 +68,17 @@ Examples:
|
|
|
52
68
|
const cfg = config.readConfig();
|
|
53
69
|
pairs.push(['Email', cfg.email || '-']);
|
|
54
70
|
pairs.push(['Organization', cfg.org_name || '-']);
|
|
71
|
+
pairs.push(['Role', role || '-']);
|
|
55
72
|
}
|
|
56
73
|
pairs.push(['Backend URL', config.getBaseUrl()]);
|
|
57
74
|
pairs.push(['Frontend URL', config.getFrontendUrl()]);
|
|
58
75
|
pairs.push(['Gateway URL', config.getGatewayUrl()]);
|
|
59
76
|
pairs.push(['API status', connectivity]);
|
|
60
77
|
|
|
78
|
+
// Locally detected AI tools wired through Unbound, with their mode.
|
|
79
|
+
const connected = detectTools({ gatewayUrl: config.getGatewayUrl(), apiKey: config.getApiKey() })
|
|
80
|
+
.filter((t) => t.status !== 'not-installed');
|
|
81
|
+
|
|
61
82
|
if (opts.json) {
|
|
62
83
|
const cfg = loggedIn ? config.readConfig() : {};
|
|
63
84
|
output.json({
|
|
@@ -65,15 +86,34 @@ Examples:
|
|
|
65
86
|
logged_in: loggedIn,
|
|
66
87
|
email: loggedIn ? (cfg.email || null) : null,
|
|
67
88
|
organization: loggedIn ? (cfg.org_name || null) : null,
|
|
89
|
+
role: role,
|
|
68
90
|
backend_url: config.getBaseUrl(),
|
|
69
91
|
frontend_url: config.getFrontendUrl(),
|
|
70
92
|
gateway_url: config.getGatewayUrl(),
|
|
71
93
|
api_status: connectivity,
|
|
94
|
+
connected_tools: connected.map((t) => ({ tool: t.key, label: t.label, mode: t.mode, status: t.status })),
|
|
72
95
|
});
|
|
73
96
|
return;
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
output.keyValue(pairs);
|
|
100
|
+
|
|
101
|
+
console.log('');
|
|
102
|
+
output.info('Connected tools');
|
|
103
|
+
const C = output.colors;
|
|
104
|
+
if (connected.length === 0) {
|
|
105
|
+
console.log(` ${C.dim('None set up yet.')} Run ${C.bold('unbound setup')} to wire a tool.`);
|
|
106
|
+
} else {
|
|
107
|
+
for (const t of connected) {
|
|
108
|
+
const mode = t.mode ? C.dim(` (${t.mode})`) : '';
|
|
109
|
+
let mark = C.green('✓');
|
|
110
|
+
let note = '';
|
|
111
|
+
if (t.status === 'tampered') { mark = C.red('✗'); note = C.dim(' — run `unbound doctor`'); }
|
|
112
|
+
else if (t.status === 'managed-by-mdm') { note = C.dim(' (managed by MDM)'); }
|
|
113
|
+
console.log(` ${mark} ${t.label}${mode}${note}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
console.log('');
|
|
77
117
|
} catch (err) {
|
|
78
118
|
output.error(err.message);
|
|
79
119
|
process.exitCode = 1;
|
package/src/config.js
CHANGED
|
@@ -141,7 +141,7 @@ function isLoggedIn() {
|
|
|
141
141
|
* Refreshes cached user identity (email, org_name) from a backend response.
|
|
142
142
|
* Always overwrites when the response carries a non-empty value so that
|
|
143
143
|
* switching tenants under the same API key (or rotating the key to a new org)
|
|
144
|
-
* shows the correct organization in `
|
|
144
|
+
* shows the correct organization in `status` instead of stale data.
|
|
145
145
|
* Defensive: leaves an existing cached value untouched if the response field
|
|
146
146
|
* is missing or empty, so a partial API response can't blank the local config.
|
|
147
147
|
*/
|
package/src/index.js
CHANGED
|
@@ -29,8 +29,8 @@ AUTHENTICATION
|
|
|
29
29
|
$ unbound login --api-key <key> Sign in with an API key (for CI/CD)
|
|
30
30
|
$ unbound login --domain custom.co Sign in via custom domain
|
|
31
31
|
$ unbound logout Remove stored credentials
|
|
32
|
-
$ unbound
|
|
33
|
-
$ unbound
|
|
32
|
+
$ unbound status Show CLI status, role, and connected tools
|
|
33
|
+
$ unbound doctor Diagnose per-tool health and API key
|
|
34
34
|
|
|
35
35
|
Tenant deployments — pass URL flags on login; they persist to ~/.unbound/config.json:
|
|
36
36
|
$ unbound login --api-key <YOUR_API_KEY> \\
|
|
@@ -178,8 +178,8 @@ LEARN MORE
|
|
|
178
178
|
// Register all command modules
|
|
179
179
|
require('./commands/login').register(program);
|
|
180
180
|
require('./commands/logout').register(program);
|
|
181
|
-
require('./commands/whoami').register(program);
|
|
182
181
|
require('./commands/status').register(program);
|
|
182
|
+
require('./commands/doctor').register(program);
|
|
183
183
|
require('./commands/policy').register(program);
|
|
184
184
|
require('./commands/users').register(program);
|
|
185
185
|
require('./commands/user-groups').register(program);
|
|
@@ -241,7 +241,7 @@ Use this on a fresh install for tenant deployments. Positional order is fixed:
|
|
|
241
241
|
2. <frontend-url> — Frontend host (e.g. https://gateway.acme.com)
|
|
242
242
|
Used by the browser login flow.
|
|
243
243
|
3. <backend-url> — REST API host (e.g. https://backend.acme.com)
|
|
244
|
-
All "unbound *" commands (
|
|
244
|
+
All "unbound *" commands (status, doctor, policies, ...) hit this.
|
|
245
245
|
|
|
246
246
|
Bare hostnames are accepted; "https://" is added automatically.
|
|
247
247
|
The three values are written atomically to ~/.unbound/config.json.
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Per-tool local health detection shared by `unbound doctor` and `unbound status`.
|
|
2
|
+
//
|
|
3
|
+
// Source of truth for what each tool installs is the setup repo's per-tool
|
|
4
|
+
// setup.py (mirrored here). Each tool has STRUCTURAL artifacts (a config file
|
|
5
|
+
// with an Unbound marker + an optional hook/script file) that decide whether it
|
|
6
|
+
// is installed, plus AUXILIARY wiring (an env var holding the api key, or the
|
|
7
|
+
// gateway URL). Env vars are persisted to a shell rc file (Unix) or the user
|
|
8
|
+
// registry (Windows), NOT reliably to the current process env, so we read those.
|
|
9
|
+
//
|
|
10
|
+
// Install state (matches the doctor spec):
|
|
11
|
+
// none of the structural artifacts present -> not installed
|
|
12
|
+
// all structural present AND all wiring ok -> healthy
|
|
13
|
+
// some present (or wiring broken) -> tampered
|
|
14
|
+
// nothing local but an org MDM install found -> managed by MDM
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const HOME = os.homedir();
|
|
22
|
+
const GATEWAY_DEFAULT = 'https://api.getunbound.ai';
|
|
23
|
+
|
|
24
|
+
function expand(p) {
|
|
25
|
+
return p.startsWith('~') ? path.join(HOME, p.slice(1)) : p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fileExists(p) {
|
|
29
|
+
try {
|
|
30
|
+
return fs.statSync(expand(p)).isFile();
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readText(p) {
|
|
37
|
+
try {
|
|
38
|
+
return fs.readFileSync(expand(p), 'utf8');
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readJson(p) {
|
|
45
|
+
const text = readText(p);
|
|
46
|
+
if (text == null) return null;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(text.replace(/^\uFEFF/, '')); // tolerate a UTF-8 BOM
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined; // file exists but is corrupt
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Candidate shell rc files where the setup scripts append `export NAME=...`.
|
|
55
|
+
function rcFiles() {
|
|
56
|
+
if (process.platform === 'win32') return [];
|
|
57
|
+
if (process.platform === 'darwin') return ['~/.zprofile', '~/.bash_profile', '~/.zshrc', '~/.bashrc'];
|
|
58
|
+
return ['~/.zshrc', '~/.bashrc', '~/.profile'];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read a persisted env var: live process env first, then the shell rc (Unix) or
|
|
62
|
+
// the user registry (Windows). Returns the value so callers can flag mismatch.
|
|
63
|
+
function readEnvVar(name) {
|
|
64
|
+
// `in`, not truthiness: an env var explicitly set to "" is "set but empty", not
|
|
65
|
+
// absent — without this we'd fall through and return a stale rc-file value,
|
|
66
|
+
// masking the fact that the live env blanked the key.
|
|
67
|
+
if (name in process.env) return { found: true, value: process.env[name], source: 'process env' };
|
|
68
|
+
|
|
69
|
+
if (process.platform === 'win32') {
|
|
70
|
+
try {
|
|
71
|
+
const r = spawnSync('reg', ['query', 'HKCU\\Environment', '/v', name], { encoding: 'utf8' });
|
|
72
|
+
if (r.status === 0 && r.stdout) {
|
|
73
|
+
const m = r.stdout.match(new RegExp(`${name}\\s+REG_[A-Z_]+\\s+(.*)`));
|
|
74
|
+
if (m) return { found: true, value: m[1].trim(), source: 'user registry' };
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
/* fall through */
|
|
78
|
+
}
|
|
79
|
+
return { found: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const re = new RegExp(`^\\s*export\\s+${name}=(.*)$`, 'm');
|
|
83
|
+
for (const rc of rcFiles()) {
|
|
84
|
+
const text = readText(rc);
|
|
85
|
+
if (!text) continue;
|
|
86
|
+
const m = text.match(re);
|
|
87
|
+
if (m) {
|
|
88
|
+
const val = m[1].trim().replace(/^["']|["']$/g, '');
|
|
89
|
+
return { found: true, value: val, source: path.basename(expand(rc)) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { found: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- check builders. Each returns { name, ok, kind, detail, summary, warn? }. ---
|
|
96
|
+
// kind: 'structural' (decides installed) | 'aux' (wiring health only).
|
|
97
|
+
// summary: a short reason shown inline on the tampered line.
|
|
98
|
+
|
|
99
|
+
function configCheck(label, file, marker, opts = {}) {
|
|
100
|
+
const kind = opts.kind || 'structural';
|
|
101
|
+
const data = marker.json ? readJson(file) : readText(file);
|
|
102
|
+
let ok = false;
|
|
103
|
+
let detail;
|
|
104
|
+
let summary;
|
|
105
|
+
if (data == null) {
|
|
106
|
+
detail = `not found (${file})`;
|
|
107
|
+
summary = opts.short || `${label.toLowerCase()} not found`;
|
|
108
|
+
} else if (data === undefined) {
|
|
109
|
+
detail = `unreadable / corrupt (${file})`;
|
|
110
|
+
summary = `${label.toLowerCase()} unreadable`;
|
|
111
|
+
} else {
|
|
112
|
+
ok = marker.test(data);
|
|
113
|
+
detail = ok ? file : `present but missing the Unbound integration (${file})`;
|
|
114
|
+
summary = opts.short || `${label.toLowerCase()} not integrated`;
|
|
115
|
+
}
|
|
116
|
+
return { name: label, ok, kind, detail, summary };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function scriptCheck(label, file, kind = 'structural') {
|
|
120
|
+
const ok = fileExists(file);
|
|
121
|
+
return { name: label, ok, kind, detail: ok ? file : `not found (${file})`, summary: `${label.toLowerCase()} missing` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function envCheck(label, name, expected, kind = 'aux') {
|
|
125
|
+
const base = label.replace(/ env$/, '');
|
|
126
|
+
const r = readEnvVar(name);
|
|
127
|
+
// An explicitly-empty value is broken wiring, same as absent.
|
|
128
|
+
if (!r.found || !r.value) return { name: label, ok: false, kind, detail: `${name} is not set`, summary: `${base} not set` };
|
|
129
|
+
// A value that doesn't match what setup wrote (stale key, wrong gateway URL) is a
|
|
130
|
+
// real misconfiguration: mark it not-ok so the tool reports tampered, not healthy.
|
|
131
|
+
if (expected && r.value !== expected) {
|
|
132
|
+
return { name: label, ok: false, kind, warn: true, summary: `${base} differs from setup`, detail: `${name} set (${r.source}) but differs from what setup configured` };
|
|
133
|
+
}
|
|
134
|
+
return { name: label, ok: true, kind, detail: `${name} set (${r.source})`, summary: `${base} set` };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// MDM (org-wide) install detection. An Unbound MDM install is only real when the
|
|
138
|
+
// system managed config REFERENCES the Unbound hook AND the hook script exists.
|
|
139
|
+
// A plain managed-settings.json (Claude Enterprise / generic MDM) does NOT count,
|
|
140
|
+
// and a managed config that points at a missing hook script is a broken (tampered)
|
|
141
|
+
// MDM install, not a healthy one. Returns { status: 'healthy'|'tampered'|null, checks }.
|
|
142
|
+
function mdmDetect(family, dirOverride) {
|
|
143
|
+
// Only Cursor / Claude Code / Codex have a managed (MDM) directory. Copilot and
|
|
144
|
+
// Gemini have none — their org install writes the same per-user config into
|
|
145
|
+
// every profile, so they're checked exactly like a user-level tool.
|
|
146
|
+
const configName = { cursor: 'hooks.json', 'claude-code': 'managed-settings.json', codex: 'managed-settings.json' };
|
|
147
|
+
if (!configName[family]) return { status: null, checks: [] };
|
|
148
|
+
|
|
149
|
+
const mac = '/Library/Application Support';
|
|
150
|
+
const win = process.env.ProgramData || 'C:\\ProgramData';
|
|
151
|
+
const dirs = {
|
|
152
|
+
cursor: { darwin: `${mac}/Cursor`, linux: '/etc/cursor', win32: path.join(win, 'Cursor') },
|
|
153
|
+
'claude-code': { darwin: `${mac}/ClaudeCode`, linux: '/etc/claude-code', win32: path.join(win, 'ClaudeCode') },
|
|
154
|
+
codex: { darwin: `${mac}/Codex`, linux: '/etc/codex', win32: path.join(win, 'Codex') },
|
|
155
|
+
};
|
|
156
|
+
const dir = dirOverride || dirs[family][process.platform] || dirs[family].linux;
|
|
157
|
+
const configPath = path.join(dir, configName[family]);
|
|
158
|
+
const scriptPath = path.join(dir, 'hooks', 'unbound.py');
|
|
159
|
+
|
|
160
|
+
const cfgText = readText(configPath);
|
|
161
|
+
if (cfgText == null || !cfgText.includes('unbound.py')) return { status: null, checks: [] };
|
|
162
|
+
|
|
163
|
+
const scriptOk = fileExists(scriptPath);
|
|
164
|
+
const checks = [
|
|
165
|
+
{ name: 'MDM config', ok: true, kind: 'structural', detail: configPath, summary: 'managed config' },
|
|
166
|
+
{ name: 'MDM hook script', ok: scriptOk, kind: 'structural', summary: 'managed hook not installed', detail: scriptOk ? scriptPath : `managed config references a hook that isn't installed (${scriptPath})` },
|
|
167
|
+
];
|
|
168
|
+
return { status: scriptOk ? 'healthy' : 'tampered', checks };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Marker that a claude/codex/cursor hooks block references unbound.py.
|
|
172
|
+
function refsUnbound(obj) {
|
|
173
|
+
return JSON.stringify(obj || {}).includes('unbound.py');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// One descriptor per (tool, mode). `family` groups the two-mode tools so the
|
|
177
|
+
// collapsed view shows a single line per product.
|
|
178
|
+
function buildVariants(gatewayUrl, apiKey) {
|
|
179
|
+
const gw = (gatewayUrl || GATEWAY_DEFAULT).replace(/\/+$/, ''); // setup rstrips too
|
|
180
|
+
return [
|
|
181
|
+
{
|
|
182
|
+
key: 'cursor', label: 'Cursor', family: 'cursor', mode: null,
|
|
183
|
+
checks: () => [
|
|
184
|
+
configCheck('Config', '~/.cursor/hooks.json', { json: true, test: refsUnbound }),
|
|
185
|
+
scriptCheck('Hook script', '~/.cursor/hooks/unbound.py'),
|
|
186
|
+
envCheck('API key env', 'UNBOUND_CURSOR_API_KEY', apiKey),
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
key: 'claude-code-subscription', label: 'Claude Code (subscription)', family: 'claude-code', mode: 'subscription',
|
|
191
|
+
checks: () => [
|
|
192
|
+
configCheck('Config', '~/.claude/settings.json', { json: true, test: refsUnbound }),
|
|
193
|
+
scriptCheck('Hook script', '~/.claude/hooks/unbound.py'),
|
|
194
|
+
envCheck('API key env', 'UNBOUND_CLAUDE_API_KEY', apiKey),
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
key: 'claude-code-gateway', label: 'Claude Code (gateway)', family: 'claude-code', mode: 'gateway',
|
|
199
|
+
checks: () => [
|
|
200
|
+
configCheck('Config', '~/.claude/settings.json', { json: true, test: (j) => typeof j.apiKeyHelper === 'string' && j.apiKeyHelper.includes('anthropic_key.sh') }),
|
|
201
|
+
scriptCheck('Key helper', '~/.claude/anthropic_key.sh'),
|
|
202
|
+
envCheck('API key env', 'UNBOUND_API_KEY', apiKey),
|
|
203
|
+
envCheck('Gateway URL env', 'ANTHROPIC_BASE_URL', gw),
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
key: 'codex-subscription', label: 'Codex (subscription)', family: 'codex', mode: 'subscription',
|
|
208
|
+
checks: () => [
|
|
209
|
+
configCheck('Config', '~/.codex/hooks.json', { json: true, test: refsUnbound }),
|
|
210
|
+
configCheck('Hooks feature flag', '~/.codex/config.toml', { json: false, test: (t) => /codex_hooks\s*=\s*true/.test(t) }, { short: 'codex hooks not enabled' }),
|
|
211
|
+
scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
|
|
212
|
+
envCheck('API key env', 'UNBOUND_CODEX_API_KEY', apiKey),
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
key: 'codex-gateway', label: 'Codex (gateway)', family: 'codex', mode: 'gateway',
|
|
217
|
+
checks: () => [
|
|
218
|
+
configCheck('Config', '~/.codex/config.toml', { json: false, test: (t) => /openai_base_url\s*=/.test(t) }),
|
|
219
|
+
envCheck('API key env', 'OPENAI_API_KEY', apiKey),
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
key: 'copilot', label: 'GitHub Copilot', family: 'copilot', mode: null,
|
|
224
|
+
checks: () => [
|
|
225
|
+
// Copilot has no managed (MDM) directory: the org install writes the same
|
|
226
|
+
// ~/.copilot config into every user profile, so it's checked like a
|
|
227
|
+
// user-level tool and never reports "managed by MDM".
|
|
228
|
+
configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound }),
|
|
229
|
+
scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'),
|
|
230
|
+
envCheck('API key env', 'UNBOUND_COPILOT_API_KEY', apiKey),
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
// Gemini CLI is intentionally omitted here — it isn't part of `setup --all`
|
|
234
|
+
// and has no managed directory. Add it back when its scope is settled.
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function statusOf(checks) {
|
|
239
|
+
const structural = checks.filter((c) => c.kind === 'structural');
|
|
240
|
+
const present = structural.filter((c) => c.ok);
|
|
241
|
+
if (present.length === 0) return 'not-installed';
|
|
242
|
+
if (present.length === structural.length && checks.every((c) => c.ok)) return 'healthy';
|
|
243
|
+
return 'tampered';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function detectVariant(variant) {
|
|
247
|
+
const checks = variant.checks();
|
|
248
|
+
return { key: variant.key, label: variant.label, family: variant.family, mode: variant.mode, status: statusOf(checks), checks };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Collapse the per-(tool,mode) variants to one entry per product family. Picks
|
|
252
|
+
// the installed/tampered mode if any; otherwise reports managed-by-mdm or
|
|
253
|
+
// not-installed. This is what both `doctor` and `status` render.
|
|
254
|
+
// `_mdmDirs` (test-only) overrides the system MDM directories per family so the
|
|
255
|
+
// org-managed scenarios can be exercised without writing under /Library or /etc.
|
|
256
|
+
function detectTools({ gatewayUrl, apiKey, _mdmDirs } = {}) {
|
|
257
|
+
const variants = buildVariants(gatewayUrl, apiKey).map(detectVariant);
|
|
258
|
+
const families = [];
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
for (const v of variants) {
|
|
261
|
+
if (seen.has(v.family)) continue;
|
|
262
|
+
seen.add(v.family);
|
|
263
|
+
const sameFamily = variants.filter((x) => x.family === v.family);
|
|
264
|
+
const present = sameFamily.filter((x) => x.status !== 'not-installed');
|
|
265
|
+
if (present.length) {
|
|
266
|
+
// Prefer a tampered variant so a conflicting/partial install surfaces (and
|
|
267
|
+
// `--fix` reinstalls it). Flag when more than one mode is present at once.
|
|
268
|
+
const active = present.find((x) => x.status === 'tampered') || present[0];
|
|
269
|
+
if (present.length > 1) active.conflict = true;
|
|
270
|
+
active.label = active.label.replace(/ \(.*\)$/, ''); // mode is shown via .mode
|
|
271
|
+
families.push(active);
|
|
272
|
+
} else {
|
|
273
|
+
const family = v.family;
|
|
274
|
+
const label = v.label.replace(/ \(.*\)$/, '');
|
|
275
|
+
const mdm = mdmDetect(family, _mdmDirs && _mdmDirs[family]);
|
|
276
|
+
if (mdm.status === 'healthy') {
|
|
277
|
+
families.push({ key: family, label, family, mode: null, status: 'managed-by-mdm', checks: mdm.checks, scope: 'mdm' });
|
|
278
|
+
} else if (mdm.status === 'tampered') {
|
|
279
|
+
families.push({ key: family, label, family, mode: null, status: 'tampered', checks: mdm.checks, scope: 'mdm' });
|
|
280
|
+
} else {
|
|
281
|
+
families.push({ key: family, label, family, mode: null, status: 'not-installed', checks: [] });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return families;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = { detectTools, statusOf, readEnvVar, GATEWAY_DEFAULT };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const { fixExitCode } = require('../src/commands/doctor');
|
|
5
|
+
|
|
6
|
+
// `--fix` exit code: a CI gate runs `unbound doctor --fix` and reads the exit code.
|
|
7
|
+
// It must be 0 only when the machine is actually clean after the repair.
|
|
8
|
+
|
|
9
|
+
test('fixExitCode: spawn/signal error (null status) → 1', () => {
|
|
10
|
+
assert.equal(fixExitCode(null, false, false), 1);
|
|
11
|
+
assert.equal(fixExitCode(null, true, true), 1);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('fixExitCode: setup failure propagates its status', () => {
|
|
15
|
+
assert.equal(fixExitCode(2, false, false), 2);
|
|
16
|
+
assert.equal(fixExitCode(1, true, true), 1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('fixExitCode: non-root, user tools fixed but MDM still tampered → 1 (not clean)', () => {
|
|
20
|
+
assert.equal(fixExitCode(0, false, true), 1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('fixExitCode: non-root, nothing org-managed left → 0 (clean)', () => {
|
|
24
|
+
assert.equal(fixExitCode(0, false, false), 0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('fixExitCode: root run repaired the MDM tools too → 0 (clean, no false failure)', () => {
|
|
28
|
+
// Regression guard: sudo `--fix` reinstalls MDM tools, so a status-0 run is clean
|
|
29
|
+
// even though mdmRemaining was true going in.
|
|
30
|
+
assert.equal(fixExitCode(0, true, true), 0);
|
|
31
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
|
|
7
|
+
const { statusOf } = require('../src/toolHealth');
|
|
8
|
+
|
|
9
|
+
// --- statusOf rollup (pure): structural artifacts decide install-state; aux
|
|
10
|
+
// wiring only downgrades an installed tool to tampered. ---
|
|
11
|
+
const chk = (kind, ok) => ({ kind, ok });
|
|
12
|
+
|
|
13
|
+
test('statusOf: no structural artifact present → not-installed (dangling env ignored)', () => {
|
|
14
|
+
assert.equal(statusOf([chk('structural', false), chk('structural', false), chk('aux', true)]), 'not-installed');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('statusOf: all structural + all aux present → healthy', () => {
|
|
18
|
+
assert.equal(statusOf([chk('structural', true), chk('structural', true), chk('aux', true)]), 'healthy');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('statusOf: structural partially present → tampered', () => {
|
|
22
|
+
assert.equal(statusOf([chk('structural', true), chk('structural', false), chk('aux', true)]), 'tampered');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('statusOf: structural complete but wiring (aux) missing → tampered', () => {
|
|
26
|
+
assert.equal(statusOf([chk('structural', true), chk('structural', true), chk('aux', false)]), 'tampered');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// --- detectTools integration: fabricate a tool's on-disk artifacts in a temp
|
|
30
|
+
// HOME and assert the per-tool status. toolHealth reads os.homedir() at load
|
|
31
|
+
// (which honors $HOME on POSIX), so we re-require it under the temp home. ---
|
|
32
|
+
function withHome(fn) {
|
|
33
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-doctor-'));
|
|
34
|
+
const origHome = process.env.HOME;
|
|
35
|
+
process.env.HOME = tmp;
|
|
36
|
+
delete require.cache[require.resolve('../src/toolHealth')];
|
|
37
|
+
try {
|
|
38
|
+
return fn(tmp, require('../src/toolHealth'));
|
|
39
|
+
} finally {
|
|
40
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
41
|
+
else process.env.HOME = origHome;
|
|
42
|
+
delete require.cache[require.resolve('../src/toolHealth')];
|
|
43
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeFile(p, content) {
|
|
48
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
49
|
+
fs.writeFileSync(p, content);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test('detectTools: cursor with config + script + env → healthy', () => {
|
|
53
|
+
withHome((tmp, th) => {
|
|
54
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
55
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
56
|
+
writeFile(script, '# unbound');
|
|
57
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'k';
|
|
58
|
+
try {
|
|
59
|
+
const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.key === 'cursor');
|
|
60
|
+
assert.equal(cursor.status, 'healthy');
|
|
61
|
+
} finally {
|
|
62
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('detectTools: cursor config + script present but env key blank → tampered (empty != set)', () => {
|
|
68
|
+
withHome((tmp, th) => {
|
|
69
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
70
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
71
|
+
writeFile(script, '# unbound');
|
|
72
|
+
process.env.UNBOUND_CURSOR_API_KEY = ''; // explicitly blanked, not absent
|
|
73
|
+
try {
|
|
74
|
+
const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.key === 'cursor');
|
|
75
|
+
assert.equal(cursor.status, 'tampered');
|
|
76
|
+
} finally {
|
|
77
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('detectTools: cursor env key differs from the configured key → tampered, not healthy', () => {
|
|
83
|
+
withHome((tmp, th) => {
|
|
84
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
85
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
86
|
+
writeFile(script, '# unbound');
|
|
87
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'stale-key';
|
|
88
|
+
try {
|
|
89
|
+
const cursor = th.detectTools({ apiKey: 'current-key' }).find((t) => t.key === 'cursor');
|
|
90
|
+
assert.equal(cursor.status, 'tampered');
|
|
91
|
+
} finally {
|
|
92
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('detectTools: cursor with only the hook script (no config) → tampered', () => {
|
|
98
|
+
withHome((tmp, th) => {
|
|
99
|
+
writeFile(path.join(tmp, '.cursor', 'hooks', 'unbound.py'), '# unbound');
|
|
100
|
+
const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.key === 'cursor');
|
|
101
|
+
assert.equal(cursor.status, 'tampered');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('detectTools: cursor with a config that lacks the Unbound marker → not-installed', () => {
|
|
106
|
+
withHome((tmp, th) => {
|
|
107
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: {} })); // no unbound.py reference
|
|
108
|
+
const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.label === 'Cursor');
|
|
109
|
+
assert.equal(cursor.status, 'not-installed');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('detectTools: claude-code collapses to ONE line and reports the installed mode (gateway)', () => {
|
|
114
|
+
withHome((tmp, th) => {
|
|
115
|
+
const helper = path.join(tmp, '.claude', 'anthropic_key.sh');
|
|
116
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ apiKeyHelper: helper }));
|
|
117
|
+
writeFile(helper, 'echo $UNBOUND_API_KEY');
|
|
118
|
+
const claude = th.detectTools({ apiKey: 'k' }).filter((t) => t.family === 'claude-code');
|
|
119
|
+
assert.equal(claude.length, 1, 'one collapsed claude-code line');
|
|
120
|
+
assert.equal(claude[0].mode, 'gateway');
|
|
121
|
+
assert.notEqual(claude[0].status, 'not-installed');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('detectTools: both claude-code modes present at once → one line, flagged as a conflict', () => {
|
|
126
|
+
withHome((tmp, th) => {
|
|
127
|
+
const helper = path.join(tmp, '.claude', 'anthropic_key.sh');
|
|
128
|
+
// settings.json references BOTH an unbound.py hook AND the gateway key helper.
|
|
129
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({
|
|
130
|
+
hooks: { PreToolUse: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] },
|
|
131
|
+
apiKeyHelper: helper,
|
|
132
|
+
}));
|
|
133
|
+
const claude = th.detectTools({ apiKey: 'k' }).filter((t) => t.family === 'claude-code');
|
|
134
|
+
assert.equal(claude.length, 1, 'still one collapsed line');
|
|
135
|
+
assert.equal(claude[0].conflict, true, 'conflict flagged');
|
|
136
|
+
assert.equal(claude[0].status, 'tampered', 'prefers the tampered variant so --fix cleans it up');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// --- MDM (org-managed) scenarios, exercised via the _mdmDirs test override ---
|
|
141
|
+
test('MDM: managed config references unbound + hook present → managed by MDM', () => {
|
|
142
|
+
withHome((tmp, th) => {
|
|
143
|
+
const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
|
|
144
|
+
writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
|
|
145
|
+
writeFile(path.join(mdmDir, 'hooks', 'unbound.py'), '#');
|
|
146
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.family === 'claude-code');
|
|
147
|
+
assert.equal(t.status, 'managed-by-mdm');
|
|
148
|
+
assert.equal(t.scope, 'mdm');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('MDM: managed config references unbound but hook missing → tampered (mdm scope)', () => {
|
|
153
|
+
withHome((tmp, th) => {
|
|
154
|
+
const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
|
|
155
|
+
writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
|
|
156
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.family === 'claude-code');
|
|
157
|
+
assert.equal(t.status, 'tampered');
|
|
158
|
+
assert.equal(t.scope, 'mdm');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('MDM: a generic managed-settings.json without an unbound reference → not installed (no false positive)', () => {
|
|
163
|
+
withHome((tmp, th) => {
|
|
164
|
+
const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
|
|
165
|
+
writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ permissions: { allow: ['*'] } }));
|
|
166
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.label === 'Claude Code');
|
|
167
|
+
assert.equal(t.status, 'not-installed');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('MDM: a user-level install takes precedence over an MDM config', () => {
|
|
172
|
+
withHome((tmp, th) => {
|
|
173
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
|
|
174
|
+
writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
|
|
175
|
+
const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
|
|
176
|
+
writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
|
|
177
|
+
process.env.UNBOUND_CLAUDE_API_KEY = 'k';
|
|
178
|
+
try {
|
|
179
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.family === 'claude-code');
|
|
180
|
+
assert.notEqual(t.scope, 'mdm');
|
|
181
|
+
assert.equal(t.status, 'healthy');
|
|
182
|
+
assert.equal(t.mode, 'subscription');
|
|
183
|
+
} finally { delete process.env.UNBOUND_CLAUDE_API_KEY; }
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('codex subscription: hooks.json + script present but the feature flag is off → tampered', () => {
|
|
188
|
+
withHome((tmp, th) => {
|
|
189
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.codex', 'hooks', 'unbound.py') }] } }));
|
|
190
|
+
writeFile(path.join(tmp, '.codex', 'hooks', 'unbound.py'), '#');
|
|
191
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), 'model = "gpt-5"\n');
|
|
192
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
193
|
+
assert.equal(t.status, 'tampered');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('copilot has no MDM scope — even with a managed dir it never reports managed-by-mdm', () => {
|
|
198
|
+
withHome((tmp, th) => {
|
|
199
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { copilot: path.join(tmp, 'mdm', 'Copilot') } }).find((x) => x.family === 'copilot');
|
|
200
|
+
assert.equal(t.status, 'not-installed');
|
|
201
|
+
assert.notEqual(t.scope, 'mdm');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('only four tools are reported (Gemini CLI is omitted)', () => {
|
|
206
|
+
withHome((tmp, th) => {
|
|
207
|
+
const keys = th.detectTools({ apiKey: 'k' }).map((t) => t.family);
|
|
208
|
+
assert.deepEqual(new Set(keys), new Set(['cursor', 'claude-code', 'codex', 'copilot']));
|
|
209
|
+
});
|
|
210
|
+
});
|
package/src/commands/whoami.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
const config = require('../config');
|
|
2
|
-
const api = require('../api');
|
|
3
|
-
const output = require('../output');
|
|
4
|
-
const { getDeviceSerial } = require('../device-serial');
|
|
5
|
-
|
|
6
|
-
function roleFromPrivileges(privileges) {
|
|
7
|
-
if (privileges.is_admin) return 'Admin';
|
|
8
|
-
if (privileges.is_manager) return 'Manager';
|
|
9
|
-
if (privileges.is_member) return 'Member';
|
|
10
|
-
return 'Unknown';
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function register(program) {
|
|
14
|
-
program
|
|
15
|
-
.command('whoami')
|
|
16
|
-
.description('Display the currently authenticated user, organization, and role. Requires an active login session.')
|
|
17
|
-
.addHelpText('after', `
|
|
18
|
-
Output fields:
|
|
19
|
-
Email - The authenticated user's email address
|
|
20
|
-
Organization - The organization the user belongs to
|
|
21
|
-
Role - One of: Admin, Manager, Member
|
|
22
|
-
|
|
23
|
-
Examples:
|
|
24
|
-
$ unbound whoami
|
|
25
|
-
$ unbound whoami --json
|
|
26
|
-
`)
|
|
27
|
-
.option('--json', 'Output raw JSON')
|
|
28
|
-
.action(async (opts) => {
|
|
29
|
-
if (!config.isLoggedIn()) {
|
|
30
|
-
output.error('Not logged in. Run `unbound login` first.');
|
|
31
|
-
process.exitCode = 1;
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const cfg = config.readConfig();
|
|
36
|
-
const spin = output.spinner('Fetching user info...');
|
|
37
|
-
try {
|
|
38
|
-
const deviceSerial = await getDeviceSerial();
|
|
39
|
-
const privileges = await api.get('/api/v1/users/privileges/', {
|
|
40
|
-
query: { device_serial: deviceSerial },
|
|
41
|
-
});
|
|
42
|
-
spin.stop();
|
|
43
|
-
config.backfillUserInfo(privileges);
|
|
44
|
-
|
|
45
|
-
const role = roleFromPrivileges(privileges);
|
|
46
|
-
const freshCfg = config.readConfig();
|
|
47
|
-
|
|
48
|
-
if (opts.json) {
|
|
49
|
-
output.json({ email: freshCfg.email || null, organization: freshCfg.org_name || null, role });
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
output.keyValue([
|
|
54
|
-
['Email', freshCfg.email || '-'],
|
|
55
|
-
['Organization', freshCfg.org_name || '-'],
|
|
56
|
-
['Role', role],
|
|
57
|
-
]);
|
|
58
|
-
} catch (err) {
|
|
59
|
-
spin.fail(err.message);
|
|
60
|
-
process.exitCode = 1;
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
module.exports = { register };
|