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.
- package/LOCAL_DEV.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/auth-pages.js +34 -31
- package/src/auth.js +29 -2
- package/src/claudePaths.js +82 -0
- package/src/commands/analytics.js +1 -1
- package/src/commands/chat.js +2 -2
- package/src/commands/discover.js +17 -6
- package/src/commands/doctor.js +32 -8
- package/src/commands/download.js +2 -2
- package/src/commands/oacb.js +37 -11
- package/src/commands/onboard.js +34 -39
- package/src/commands/setup.js +62 -5
- package/src/commands/status.js +13 -1
- package/src/device-serial.js +17 -1
- package/src/index.js +3 -3
- package/src/lib/downloads.js +2 -2
- package/src/lib/policy-ai-assist.js +4 -2
- package/src/scheduled.js +3 -6
- package/src/toolHealth.js +115 -40
- package/test/analytics-export-job.test.js +1 -1
- package/test/chat-internals.test.js +1 -1
- package/test/claude-paths.test.js +182 -0
- package/test/discover-exit-code.test.js +16 -1
- package/test/onboard-cron.test.js +13 -11
- package/test/onboard-owner-key.test.js +38 -0
- package/test/onboard-scope.test.js +41 -6
- package/test/setup-args.test.js +44 -1
- package/test/setup-scope.test.js +69 -0
- package/test/tool-health-mdm-paths.test.js +206 -0
- package/test/tool-health.test.js +185 -0
package/LOCAL_DEV.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
cd unbound-cli
|
|
12
12
|
npm install
|
|
13
13
|
```
|
|
14
|
-
|
|
14
|
+
|
|
15
15
|
## Run locally (without installing globally)
|
|
16
16
|
|
|
17
17
|
```bash
|
|
@@ -85,7 +85,7 @@ sudo node src/index.js setup --api-key <ADMIN_KEY> --all --backend-url http://lo
|
|
|
85
85
|
# Onboarding (combined setup + discover)
|
|
86
86
|
node src/index.js onboard --api-key <USER_KEY> \
|
|
87
87
|
--backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
88
|
-
sudo node src/index.js onboard --api-key <ADMIN_KEY>
|
|
88
|
+
sudo node src/index.js onboard --api-key <ADMIN_KEY> \
|
|
89
89
|
--backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -186,7 +186,7 @@ node src/index.js setup --all --api-key <key>
|
|
|
186
186
|
node src/index.js onboard --api-key <USER_KEY>
|
|
187
187
|
|
|
188
188
|
# MDM onboarding (admin device enrollment, requires root — MDM scope auto-detected from sudo)
|
|
189
|
-
sudo node src/index.js onboard --api-key <ADMIN_KEY>
|
|
189
|
+
sudo node src/index.js onboard --api-key <ADMIN_KEY>
|
|
190
190
|
```
|
|
191
191
|
|
|
192
192
|
## Unlink when done
|
package/README.md
CHANGED
|
@@ -40,10 +40,10 @@ npm install -g unbound-cli
|
|
|
40
40
|
unbound onboard --api-key <USER_KEY>
|
|
41
41
|
|
|
42
42
|
# Admin enrolling a device via MDM (requires root)
|
|
43
|
-
sudo unbound onboard --api-key <ADMIN_KEY>
|
|
43
|
+
sudo unbound onboard --api-key <ADMIN_KEY>
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Only your API key is needed. Run with `sudo` to let the discovery step scan all users on the device (the device is attributed to its owner, resolved from the hardware serial); without it, only the current user is scanned.
|
|
47
47
|
|
|
48
48
|
## Commands
|
|
49
49
|
|
package/package.json
CHANGED
package/src/auth-pages.js
CHANGED
|
@@ -23,13 +23,11 @@ function escapeHtml(value) {
|
|
|
23
23
|
.replace(/'/g, ''');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// Unbound
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
'<path d="M12.1878 1.4375C14.6214 1.4375 17.3928 1.8113 20.282 2.91325L18.7654 6.81775C11.5638 4.07068 5.19258 6.85572 5.12833 6.88492L3.42188 3.10697C3.63993 3.00865 7.20178 1.4375 12.1878 1.4375Z" fill="#7B56FB"/>' +
|
|
32
|
-
'</svg>';
|
|
26
|
+
// Full Unbound logo (public/unbound_logo_dark_theme.svg in unbound-fe), inlined
|
|
27
|
+
// verbatim. Kept at the asset's native 100x18 proportions and scaled up to
|
|
28
|
+
// 150px wide — the same treatment as the auto-topup modal — so the wordmark
|
|
29
|
+
// keeps its shape instead of being shrunk or squashed to fit a box.
|
|
30
|
+
const LOGO_SVG = '<svg aria-hidden="true" width="150" height="27" viewBox="0 0 100 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M28.3097 3.82617H30.9472V13.9767H28.3097V12.574C27.5351 13.6571 26.4897 14.1983 25.1742 14.1983C24.0547 14.1983 23.173 13.8383 22.5276 13.1184C21.8823 12.3985 21.5596 11.4241 21.5596 10.1934V3.82617H24.1971V9.62096C24.1971 10.3104 24.3411 10.8269 24.6301 11.1712C24.919 11.5163 25.3956 11.6885 26.0591 11.6885C26.7844 11.6885 27.3408 11.4735 27.7286 11.0427C28.1163 10.6119 28.3097 9.98504 28.3097 9.16051V3.82617Z" fill="#2C2C2C"/><path d="M38.455 3.60352C39.5985 3.60352 40.4957 3.961 41.1477 4.67434C41.7997 5.38767 42.1248 6.36623 42.1248 7.60838V13.9756H39.4873V8.18086C39.4873 7.49142 39.34 6.97495 39.0445 6.63064C38.7489 6.28633 38.2632 6.11335 37.5874 6.11335C36.0629 6.11335 35.3006 6.95601 35.3006 8.64131V13.9748H32.6631V3.82509H35.3006V5.20892C36.0629 4.1381 37.1141 3.60352 38.4542 3.60352H38.455Z" fill="#2C2C2C"/><path d="M49.6116 3.6029C50.9271 3.6029 52.0277 4.09877 52.9126 5.08887C53.7975 6.07979 54.2404 7.34995 54.2404 8.90017C54.2404 10.4504 53.8008 11.7214 52.9217 12.7115C52.0425 13.7024 50.9386 14.1974 49.6108 14.1974C48.283 14.1974 47.1881 13.6686 46.4012 12.6101V13.9759H43.7637V0.968681L46.4012 0V5.24537C47.1881 4.15066 48.2575 3.6029 49.6108 3.6029H49.6116ZM49.0033 11.6868C49.7409 11.6868 50.3525 11.4281 50.8382 10.9117C51.3238 10.3944 51.5667 9.7247 51.5667 8.90017C51.5667 8.07563 51.3271 7.40513 50.8472 6.88867C50.3681 6.37221 49.7532 6.11356 49.0033 6.11356C48.2534 6.11356 47.6318 6.37221 47.1404 6.88867C46.649 7.40596 46.4028 8.07646 46.4028 8.90017C46.4028 9.72387 46.649 10.3952 47.1404 10.9117C47.6318 11.4281 48.2534 11.6868 49.0033 11.6868Z" fill="#2C2C2C"/><path d="M57.0085 5.11667C58.0293 4.10845 59.2772 3.60352 60.7524 3.60352C62.2275 3.60352 63.4788 4.10845 64.5053 5.11667C65.5318 6.12571 66.0455 7.38681 66.0455 8.89996C66.0455 10.4131 65.5384 11.6899 64.5234 12.6923C63.5093 13.6956 62.2514 14.1964 60.7516 14.1964C59.2517 14.1964 58.0128 13.6923 56.9978 12.6833C55.9837 11.6742 55.4766 10.4131 55.4766 8.89996C55.4766 7.38681 55.9869 6.12571 57.0077 5.11667H57.0085ZM62.6243 6.88846C62.1263 6.372 61.5023 6.11335 60.7524 6.11335C60.0025 6.11335 59.3785 6.372 58.8805 6.88846C58.3824 7.40575 58.1338 8.07625 58.1338 8.89996C58.1338 9.72367 58.3832 10.395 58.8805 10.9115C59.3785 11.4279 60.0025 11.6866 60.7524 11.6866C61.5023 11.6866 62.1263 11.4279 62.6243 10.9115C63.1224 10.3942 63.371 9.72449 63.371 8.89996C63.371 8.07543 63.1224 7.40493 62.6243 6.88846Z" fill="#2C2C2C"/><path d="M74.1789 3.82617H76.8164V13.9767H74.1789V12.574C73.4042 13.6571 72.3588 14.1983 71.0433 14.1983C69.9238 14.1983 69.0422 13.8383 68.3968 13.1184C67.7514 12.3985 67.4287 11.4241 67.4287 10.1934V3.82617H70.0662V9.62096C70.0662 10.3104 70.2103 10.8269 70.4992 11.1712C70.7882 11.5163 71.2648 11.6885 71.9283 11.6885C72.6535 11.6885 73.21 11.4735 73.5977 11.0427C73.9854 10.6119 74.1789 9.98504 74.1789 9.16051V3.82617Z" fill="#2C2C2C"/><path d="M84.3232 3.60352C85.4666 3.60352 86.3639 3.961 87.0159 4.67434C87.6678 5.38767 87.993 6.36623 87.993 7.60838V13.9756H85.3555V8.18086C85.3555 7.49142 85.2081 6.97495 84.9126 6.63064C84.6171 6.28633 84.1314 6.11335 83.4556 6.11335C81.931 6.11335 81.1688 6.95601 81.1688 8.64131V13.9748H78.5312V3.82509H81.1688V5.20892C81.931 4.1381 82.9822 3.60352 84.3224 3.60352H84.3232Z" fill="#2C2C2C"/><path d="M97.3623 0.968681L99.9998 0V13.975H97.3623V12.6093C96.5877 13.6678 95.5241 14.1966 94.1716 14.1966C92.8191 14.1966 91.7399 13.7016 90.8607 12.7106C89.9816 11.7205 89.542 10.4496 89.542 8.89934C89.542 7.34912 89.9816 6.07896 90.8607 5.08804C91.7399 4.09795 92.8372 3.60207 94.1527 3.60207C95.4681 3.60207 96.5753 4.14984 97.3623 5.24455V0.968681ZM94.7618 11.6868C95.5118 11.6868 96.1325 11.4281 96.6247 10.9117C97.1162 10.3944 97.3623 9.7247 97.3623 8.90016C97.3623 8.07563 97.1162 7.40513 96.6247 6.88867C96.1325 6.3722 95.5118 6.11356 94.7618 6.11356C94.0119 6.11356 93.397 6.3722 92.9179 6.88867C92.438 7.40596 92.1984 8.07646 92.1984 8.90016C92.1984 9.72387 92.4413 10.3952 92.9269 10.9117C93.4126 11.4281 94.0243 11.6868 94.7618 11.6868Z" fill="#2C2C2C"/><path d="M3.50761 4.90186C4.53166 11.2831 9.27488 14.8432 9.32262 14.8786L7.27041 17.727C7.02593 17.5507 1.2636 13.33 0 5.45292L3.50679 4.90186H3.50761Z" fill="#7B56FB"/><path d="M15.0395 4.84521L18.5347 5.20353C18.5051 5.50336 17.7321 12.607 11.5475 17.6407L9.32324 14.8788C14.3348 10.8006 15.0329 4.90452 15.0395 4.84521Z" fill="#7B56FB"/><path d="M9.47728 0.29248C11.5353 0.29248 13.8789 0.608784 16.3221 1.54122L15.0396 4.84511C8.94961 2.52061 3.56184 4.87724 3.50751 4.90195L2.06445 1.70514C2.24885 1.62194 5.2609 0.29248 9.47728 0.29248Z" fill="#7B56FB"/></svg>';
|
|
33
31
|
|
|
34
32
|
/**
|
|
35
33
|
* Shared page shell. `status` slots the icon block, heading, and body copy;
|
|
@@ -69,15 +67,10 @@ function renderPage({ title, icon, heading, body }) {
|
|
|
69
67
|
.brand {
|
|
70
68
|
display: inline-flex;
|
|
71
69
|
align-items: center;
|
|
72
|
-
gap: 8px;
|
|
73
70
|
margin-bottom: 28px;
|
|
74
71
|
}
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
font-size: 17px;
|
|
78
|
-
font-weight: 600;
|
|
79
|
-
letter-spacing: 0.02em;
|
|
80
|
-
}
|
|
72
|
+
/* The wordmark ships its own intrinsic 150x27; never constrain it to a box. */
|
|
73
|
+
.brand svg { display: block; }
|
|
81
74
|
.icon {
|
|
82
75
|
display: block;
|
|
83
76
|
width: 64px;
|
|
@@ -97,20 +90,27 @@ function renderPage({ title, icon, heading, body }) {
|
|
|
97
90
|
h1 { color: #3d3d3d; font-size: 20px; font-weight: 600; margin-bottom: 10px; }
|
|
98
91
|
p { color: #6b7280; font-size: 14px; line-height: 1.6; }
|
|
99
92
|
.identity {
|
|
100
|
-
display:
|
|
101
|
-
|
|
102
|
-
gap: 2px;
|
|
103
|
-
margin: 14px 0 6px;
|
|
104
|
-
padding: 9px 18px;
|
|
93
|
+
display: block;
|
|
94
|
+
margin: 16px 0 18px;
|
|
105
95
|
border: 1px solid #e5e7eb;
|
|
106
96
|
border-radius: 12px;
|
|
107
97
|
background: #fafafb;
|
|
108
|
-
|
|
109
|
-
max-width: 100%;
|
|
98
|
+
text-align: left;
|
|
110
99
|
overflow-wrap: anywhere;
|
|
111
100
|
}
|
|
112
|
-
|
|
113
|
-
|
|
101
|
+
/* Label above value, not beside it: a 400px card cannot hold a long tenant
|
|
102
|
+
name and a long email on the same line without truncating one of them. */
|
|
103
|
+
.identity .row { padding: 10px 16px; }
|
|
104
|
+
.identity .row + .row { border-top: 1px solid #e5e7eb; }
|
|
105
|
+
.identity dt {
|
|
106
|
+
color: #6b7280;
|
|
107
|
+
font-size: 11px;
|
|
108
|
+
font-weight: 500;
|
|
109
|
+
letter-spacing: 0.04em;
|
|
110
|
+
text-transform: uppercase;
|
|
111
|
+
margin-bottom: 3px;
|
|
112
|
+
}
|
|
113
|
+
.identity dd { color: #3d3d3d; font-size: 13px; font-weight: 500; }
|
|
114
114
|
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
|
115
115
|
@keyframes draw { to { stroke-dashoffset: 0; } }
|
|
116
116
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -120,7 +120,7 @@ function renderPage({ title, icon, heading, body }) {
|
|
|
120
120
|
</head>
|
|
121
121
|
<body>
|
|
122
122
|
<main class="card">
|
|
123
|
-
<div class="brand">${LOGO_SVG}
|
|
123
|
+
<div class="brand">${LOGO_SVG}</div>
|
|
124
124
|
${icon}
|
|
125
125
|
<h1>${heading}</h1>
|
|
126
126
|
${body}
|
|
@@ -148,13 +148,16 @@ const CROSS_ICON =
|
|
|
148
148
|
* query-param values — escaped here, and omitted entirely when absent.
|
|
149
149
|
*/
|
|
150
150
|
function successPage({ email, orgName } = {}) {
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
151
|
+
// Each value carries its own label so the reader can tell an org named after
|
|
152
|
+
// a person from the account email. Rows are omitted when the query param is
|
|
153
|
+
// absent, so a missing org never leaves a dangling "Organization" label.
|
|
154
|
+
const row = (label, value) =>
|
|
155
|
+
`<div class="row"><dt>${label}</dt><dd>${escapeHtml(value)}</dd></div>`;
|
|
156
|
+
const identityRows = [];
|
|
157
|
+
if (orgName) identityRows.push(row('Organization', orgName));
|
|
158
|
+
if (email) identityRows.push(row('Email', email));
|
|
159
|
+
const identity = identityRows.length
|
|
160
|
+
? `<dl class="identity">${identityRows.join('')}</dl>`
|
|
158
161
|
: '';
|
|
159
162
|
|
|
160
163
|
return renderPage({
|
package/src/auth.js
CHANGED
|
@@ -8,7 +8,7 @@ const authPages = require('./auth-pages');
|
|
|
8
8
|
const config = require('./config');
|
|
9
9
|
const output = require('./output');
|
|
10
10
|
const api = require('./api');
|
|
11
|
-
const { getDeviceSerial } = require('./device-serial');
|
|
11
|
+
const { getDeviceSerial, probeDeviceSerialLive } = require('./device-serial');
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Opens the browser for authentication and waits for the callback.
|
|
@@ -223,4 +223,31 @@ async function ensureLoggedIn({ apiKey, baseUrl, frontendUrl } = {}) {
|
|
|
223
223
|
return true;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Exchange the admin key + this device's hardware serial for the device owner's
|
|
228
|
+
* application key, so an org-wide scan is attributed to the owner, not the admin.
|
|
229
|
+
* Throws on a missing serial or unusable response so the caller can abort.
|
|
230
|
+
*/
|
|
231
|
+
async function fetchDeviceOwnerKey(adminApiKey, { baseUrl } = {}) {
|
|
232
|
+
// Probe the serial live, never the user-writable cache — it decides whose key
|
|
233
|
+
// gets minted, so a planted cache value must not be trusted here.
|
|
234
|
+
const deviceSerial = await probeDeviceSerialLive();
|
|
235
|
+
if (!deviceSerial) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
"Could not read this device's hardware serial number. "
|
|
238
|
+
+ 'Re-run without sudo to onboard just the current user.'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const resp = await api.get('/api/v1/automations/mdm/get_application_api_key/', {
|
|
242
|
+
apiKey: adminApiKey,
|
|
243
|
+
baseUrl,
|
|
244
|
+
query: { serial_number: deviceSerial, app_type: 'default' },
|
|
245
|
+
});
|
|
246
|
+
const ownerKey = resp && resp.api_key;
|
|
247
|
+
if (!ownerKey) {
|
|
248
|
+
throw new Error('The backend did not return a key for this device.');
|
|
249
|
+
}
|
|
250
|
+
return ownerKey;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = { loginWithBrowser, loginWithApiKey, ensureLoggedIn, fetchDeviceOwnerKey };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
5
|
+
|
|
6
|
+
// Mirrors Claude Code's own resolution: it reads CLAUDE_CONFIG_DIR verbatim,
|
|
7
|
+
// with no tilde expansion. Expanding "~" would install to $HOME/x while Claude
|
|
8
|
+
// reads ./~/x. A blank value is treated as unset so setup never targets the cwd.
|
|
9
|
+
function resolveClaudeConfigDir() {
|
|
10
|
+
const raw = (process.env.CLAUDE_CONFIG_DIR || '').trim();
|
|
11
|
+
return raw ? path.resolve(raw) : DEFAULT_CLAUDE_DIR;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Claude Code resolves CLAUDE_CONFIG_DIR against the cwd when it is set but
|
|
15
|
+
// empty, rather than falling back to ~/.claude — so a default install is one it
|
|
16
|
+
// never reads. Callers surface this instead of reporting a healthy setup.
|
|
17
|
+
const BLANK_CONFIG_DIR_MESSAGE =
|
|
18
|
+
'CLAUDE_CONFIG_DIR is set but empty — Claude Code reads that as a path relative to the ' +
|
|
19
|
+
'current directory, so it will not load hooks installed in ~/.claude. Unset it, or point ' +
|
|
20
|
+
'it at a real directory.';
|
|
21
|
+
|
|
22
|
+
function isBlankConfigDir() {
|
|
23
|
+
const raw = process.env.CLAUDE_CONFIG_DIR;
|
|
24
|
+
return typeof raw === 'string' && raw.trim() === '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Windows paths are case-insensitive, so C:\\Users\\jane\\.claude and
|
|
28
|
+
// c:\\users\\jane\\.claude name one directory. Comparing them literally would
|
|
29
|
+
// class the default dir as relocated and forward a --config-dir nobody needs.
|
|
30
|
+
const samePath = process.platform === 'win32'
|
|
31
|
+
? (a, b) => a.toLowerCase() === b.toLowerCase()
|
|
32
|
+
: (a, b) => a === b;
|
|
33
|
+
|
|
34
|
+
function isNonDefaultConfigDir() {
|
|
35
|
+
return !samePath(resolveClaudeConfigDir(), DEFAULT_CLAUDE_DIR);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function claudeHooksDir(dir = resolveClaudeConfigDir()) {
|
|
39
|
+
return path.join(dir, 'hooks');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function claudeSettingsPath(dir = resolveClaudeConfigDir()) {
|
|
43
|
+
return path.join(dir, 'settings.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Renders a path under the home dir with a leading ~, so a default install
|
|
47
|
+
// still reads as ~/.claude/... in status output rather than a long absolute path.
|
|
48
|
+
function displayPath(p) {
|
|
49
|
+
const home = os.homedir();
|
|
50
|
+
if (samePath(p, home)) return '~';
|
|
51
|
+
const prefix = home + path.sep;
|
|
52
|
+
return samePath(p.slice(0, prefix.length), prefix) ? '~' + p.slice(home.length) : p;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CLAUDE_INSTALL_MARKERS = [path.join('hooks', 'unbound.py'), 'anthropic_key.sh'];
|
|
56
|
+
|
|
57
|
+
// Reports hooks sitting in ~/.claude while a different dir is active, so the
|
|
58
|
+
// caller can fail loudly instead of showing a green status Claude never honors.
|
|
59
|
+
function detectConfigDirMismatch(markers = CLAUDE_INSTALL_MARKERS, { fs = require('fs') } = {}) {
|
|
60
|
+
const activeDir = resolveClaudeConfigDir();
|
|
61
|
+
if (samePath(activeDir, DEFAULT_CLAUDE_DIR)) return null;
|
|
62
|
+
const anyExists = (base) => markers.some((rel) => {
|
|
63
|
+
try { return fs.existsSync(path.join(base, rel)); } catch { return false; }
|
|
64
|
+
});
|
|
65
|
+
if (!anyExists(activeDir) && anyExists(DEFAULT_CLAUDE_DIR)) {
|
|
66
|
+
return { activeDir, staleDir: DEFAULT_CLAUDE_DIR };
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
DEFAULT_CLAUDE_DIR,
|
|
73
|
+
resolveClaudeConfigDir,
|
|
74
|
+
samePath,
|
|
75
|
+
isNonDefaultConfigDir,
|
|
76
|
+
isBlankConfigDir,
|
|
77
|
+
BLANK_CONFIG_DIR_MESSAGE,
|
|
78
|
+
claudeHooksDir,
|
|
79
|
+
claudeSettingsPath,
|
|
80
|
+
displayPath,
|
|
81
|
+
detectConfigDirMismatch,
|
|
82
|
+
};
|
|
@@ -280,7 +280,7 @@ Examples:
|
|
|
280
280
|
$ unbound analytics tool-use mcp-actions --server claude_ai_notion
|
|
281
281
|
$ unbound analytics tool-use unsanctioned --format csv -o blocked.csv
|
|
282
282
|
|
|
283
|
-
Admin
|
|
283
|
+
Admin or Analytics Viewer. Applies to listing as well as CSV export.
|
|
284
284
|
${FILTER_HELP}${AGENT_HELP}`);
|
|
285
285
|
|
|
286
286
|
attach(toolUse, 'terminal', DATASETS.terminal, `
|
package/src/commands/chat.js
CHANGED
|
@@ -179,7 +179,7 @@ function normalizeAssistantTurn(turn) {
|
|
|
179
179
|
// backend-supplied strings out of the shell line so copy-pastes stay safe.
|
|
180
180
|
function friendlyChatError(err) {
|
|
181
181
|
if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
|
|
182
|
-
if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound status` to check yours.';
|
|
182
|
+
if (err?.statusCode === 403) return 'Chat requires Admin, Analytics Viewer or Manager role. Run `unbound status` to check yours.';
|
|
183
183
|
if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
|
|
184
184
|
if (err?.statusCode === 504) return 'The turn took too long and the connection was closed. Try narrowing the question.';
|
|
185
185
|
return sanitizeForTerminal(err?.message || 'Request failed.');
|
|
@@ -666,7 +666,7 @@ INTERACTIVE SESSION
|
|
|
666
666
|
/exit quit (Ctrl+C also works)
|
|
667
667
|
|
|
668
668
|
NOTES
|
|
669
|
-
* Requires Admin or Manager role in your organization. Run 'unbound status' to check.
|
|
669
|
+
* Requires Admin, Analytics Viewer or Manager role in your organization. Run 'unbound status' to check.
|
|
670
670
|
* A question the assistant asks back is a normal reply, not an error: the turn
|
|
671
671
|
exits 0, and you answer it in the next one with -c.
|
|
672
672
|
* Each -m invocation is its own process and sends no history. -c carries the
|
package/src/commands/discover.js
CHANGED
|
@@ -5,6 +5,7 @@ const os = require('os');
|
|
|
5
5
|
const config = require('../config');
|
|
6
6
|
const output = require('../output');
|
|
7
7
|
const telemetry = require('../telemetry');
|
|
8
|
+
const { Option } = require('commander');
|
|
8
9
|
const { shellEscape, downloadToFile, withSecureTempFile, DISCOVER_BASE_URL } = require('../utils');
|
|
9
10
|
const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
|
|
10
11
|
|
|
@@ -17,7 +18,7 @@ const DISCOVERY_EXIT_UNSUPPORTED_OS = 3;
|
|
|
17
18
|
// indefinitely. Discovery enforces this itself — on expiry it releases its lock,
|
|
18
19
|
// reports the run as failed, and exits non-zero. Kept in sync with
|
|
19
20
|
// setup/mdm/onboard.py's DISCOVERY_TIMEOUT_SECONDS.
|
|
20
|
-
const DISCOVERY_TIMEOUT_SECONDS =
|
|
21
|
+
const DISCOVERY_TIMEOUT_SECONDS = 12000;
|
|
21
22
|
|
|
22
23
|
// Classifies a discovery subprocess exit code:
|
|
23
24
|
// 'success' (scan ran), 'unsupported' (skipped on this OS), or 'failure'.
|
|
@@ -34,8 +35,10 @@ function isWindowsNative() {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function isRoot() {
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// Delegate to the shared, Windows-aware privilege check so `discover` and
|
|
39
|
+
// `onboard` agree on scope on every platform. Lazy require avoids any
|
|
40
|
+
// module-load cycle between discover and setup.
|
|
41
|
+
return require('./setup').hasRootPrivileges();
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/**
|
|
@@ -194,6 +197,7 @@ function register(program) {
|
|
|
194
197
|
.option('--api-key <key>', 'Discovery API key (required)')
|
|
195
198
|
.option('--domain <url>', 'Backend URL (defaults to configured backend)')
|
|
196
199
|
.option('--set-cron', 'Set up a daily scheduled discovery scan at 09:00 (cross-platform)')
|
|
200
|
+
.addOption(new Option('--discovery-key <key>', 'Deprecated and ignored. The scan uses your API key.').hideHelp())
|
|
197
201
|
.addHelpText('after', `
|
|
198
202
|
Scans this device for installed AI coding tools (Cursor, Claude Code,
|
|
199
203
|
Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
|
|
@@ -201,7 +205,7 @@ and more) and reports findings to the Unbound backend.
|
|
|
201
205
|
|
|
202
206
|
Which --api-key to use depends on scope:
|
|
203
207
|
- Single-user scan (no sudo): use your own user API key.
|
|
204
|
-
- All-users scan (sudo): use
|
|
208
|
+
- All-users scan (sudo): use your admin API key.
|
|
205
209
|
The --domain defaults to the backend URL configured via "unbound config set-backend-url"
|
|
206
210
|
(falls back to https://backend.getunbound.ai when unset).
|
|
207
211
|
|
|
@@ -218,8 +222,15 @@ Examples:
|
|
|
218
222
|
`)
|
|
219
223
|
.action(telemetry.wrapAction('discover', async (opts) => {
|
|
220
224
|
let scanSucceeded = false;
|
|
221
|
-
// Register the
|
|
225
|
+
// Register the api key so beforeSend redacts it from any event.
|
|
222
226
|
telemetry.rememberSecret(opts.apiKey);
|
|
227
|
+
// Accept and ignore a stale discovery key (flag or env) instead of erroring;
|
|
228
|
+
// the scan uses the API key. Redact it, then warn.
|
|
229
|
+
const staleDiscoveryKey = opts.discoveryKey || process.env.UNBOUND_DISCOVERY_KEY;
|
|
230
|
+
if (staleDiscoveryKey) {
|
|
231
|
+
telemetry.rememberSecret(staleDiscoveryKey);
|
|
232
|
+
output.warn('--discovery-key is deprecated and ignored — the scan uses your API key.');
|
|
233
|
+
}
|
|
223
234
|
try {
|
|
224
235
|
if (!opts.apiKey) {
|
|
225
236
|
output.error('--api-key is required.');
|
|
@@ -254,7 +265,7 @@ Examples:
|
|
|
254
265
|
const domain = opts.domain || config.getBaseUrl();
|
|
255
266
|
const suffix = domain && domain !== config.DEFAULT_BASE_URL ? ` --domain ${domain}` : '';
|
|
256
267
|
console.error(' Discovery completed successfully — only scheduled-run setup failed.');
|
|
257
|
-
console.error(` Re-run cron setup with: unbound discover --set-cron --api-key <
|
|
268
|
+
console.error(` Re-run cron setup with: unbound discover --set-cron --api-key <KEY>${suffix}`);
|
|
258
269
|
}
|
|
259
270
|
process.exitCode = 1;
|
|
260
271
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -4,7 +4,8 @@ const api = require('../api');
|
|
|
4
4
|
const output = require('../output');
|
|
5
5
|
const { getDeviceSerial } = require('../device-serial');
|
|
6
6
|
const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
|
|
7
|
-
const { hasRootPrivileges } = require('./setup');
|
|
7
|
+
const { hasRootPrivileges, elevated } = require('./setup');
|
|
8
|
+
const claudePaths = require('../claudePaths');
|
|
8
9
|
|
|
9
10
|
// Validate the stored API key against the backend. Returns one of:
|
|
10
11
|
// valid | invalid | not-logged-in | unverified (network/other error).
|
|
@@ -74,7 +75,9 @@ Examples:
|
|
|
74
75
|
spin.stop();
|
|
75
76
|
|
|
76
77
|
const tampered = tools.filter((t) => t.status === 'tampered');
|
|
77
|
-
const
|
|
78
|
+
const configMismatch = claudePaths.detectConfigDirMismatch();
|
|
79
|
+
const blankConfigDir = claudePaths.isBlankConfigDir();
|
|
80
|
+
const unhealthy = tampered.length > 0 || key.state === 'invalid' || !!configMismatch || blankConfigDir;
|
|
78
81
|
|
|
79
82
|
if (opts.json) {
|
|
80
83
|
output.json({
|
|
@@ -92,12 +95,19 @@ Examples:
|
|
|
92
95
|
})),
|
|
93
96
|
healthy: !unhealthy,
|
|
94
97
|
key_validations_skipped: keySkippedCount,
|
|
98
|
+
claude_config_dir_mismatch: configMismatch,
|
|
99
|
+
claude_config_dir_blank: blankConfigDir,
|
|
95
100
|
});
|
|
96
101
|
if (unhealthy) process.exitCode = 1;
|
|
97
102
|
return;
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
const C = output.colors;
|
|
106
|
+
const reportConfigMismatch = (m) => {
|
|
107
|
+
console.log(C.red('✗ Claude Code is installed where Claude Code won’t read it.'));
|
|
108
|
+
console.log(` Install lives in ${C.bold(m.staleDir)} but ${C.bold('CLAUDE_CONFIG_DIR')} points at ${C.bold(m.activeDir)}.`);
|
|
109
|
+
console.log(` Re-run ${C.bold('unbound setup')} with CLAUDE_CONFIG_DIR set so it installs under the active dir.`);
|
|
110
|
+
};
|
|
101
111
|
const labelOf = (t) => t.label + (t.mode && t.mode !== 'subscription' ? ` (${t.mode})` : '');
|
|
102
112
|
const W = Math.max(7, ...tools.map((t) => labelOf(t).length)); // 7 = "API key"
|
|
103
113
|
|
|
@@ -146,7 +156,16 @@ Examples:
|
|
|
146
156
|
const anyInstalled = tools.some((t) => t.status !== 'not-installed');
|
|
147
157
|
|
|
148
158
|
if (opts.fix) {
|
|
159
|
+
if (blankConfigDir) {
|
|
160
|
+
output.error(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
|
|
161
|
+
console.log('');
|
|
162
|
+
}
|
|
163
|
+
if (configMismatch) {
|
|
164
|
+
reportConfigMismatch(configMismatch);
|
|
165
|
+
console.log('');
|
|
166
|
+
}
|
|
149
167
|
if (!tampered.length) {
|
|
168
|
+
if (configMismatch || blankConfigDir) { process.exitCode = 1; return; }
|
|
150
169
|
if (!anyInstalled) console.log(`${C.yellow('No tools are set up yet')} — nothing to fix. Run ${C.bold('unbound setup')} first.`);
|
|
151
170
|
else output.success('Nothing to fix — all set up tools are healthy.');
|
|
152
171
|
console.log('');
|
|
@@ -157,7 +176,7 @@ Examples:
|
|
|
157
176
|
// tools only, and point at sudo for whatever's org-managed.
|
|
158
177
|
if (!root && !userFix.length) {
|
|
159
178
|
console.log(`${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
160
|
-
console.log(` Run ${C.bold('
|
|
179
|
+
console.log(` Run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
161
180
|
console.log('');
|
|
162
181
|
process.exitCode = 1;
|
|
163
182
|
return;
|
|
@@ -167,13 +186,13 @@ Examples:
|
|
|
167
186
|
output.info(`Reinstalling: ${fixDisplay}${root ? ' (org-wide, all users)' : ''}`);
|
|
168
187
|
console.log('');
|
|
169
188
|
const r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow], { stdio: 'inherit' });
|
|
170
|
-
if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('
|
|
171
|
-
process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0);
|
|
189
|
+
if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
190
|
+
process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0) || ((configMismatch || blankConfigDir) ? 1 : 0);
|
|
172
191
|
return;
|
|
173
192
|
}
|
|
174
193
|
|
|
175
194
|
// No `--fix`: report and show the command(s) that fix it.
|
|
176
|
-
if (!anyInstalled && key.state !== 'invalid') {
|
|
195
|
+
if (!anyInstalled && key.state !== 'invalid' && !configMismatch && !blankConfigDir) {
|
|
177
196
|
console.log(`${C.yellow('No AI tools are set up yet.')} Run ${C.bold('unbound setup')} to get started.`);
|
|
178
197
|
console.log('');
|
|
179
198
|
return;
|
|
@@ -183,17 +202,22 @@ Examples:
|
|
|
183
202
|
console.log('');
|
|
184
203
|
return;
|
|
185
204
|
}
|
|
205
|
+
if (blankConfigDir) {
|
|
206
|
+
output.error(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
}
|
|
209
|
+
if (configMismatch) reportConfigMismatch(configMismatch);
|
|
186
210
|
if (tampered.length) {
|
|
187
211
|
console.log(C.red(`✗ ${tampered.length} tool${tampered.length === 1 ? ' needs' : 's need'} attention.`));
|
|
188
212
|
if (!mdmFix.length) {
|
|
189
213
|
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
190
214
|
} else if (!userFix.length) {
|
|
191
215
|
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
192
|
-
console.log(` Run ${C.bold('
|
|
216
|
+
console.log(` Run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
193
217
|
} else {
|
|
194
218
|
// Both: a user without sudo can still fix their own tools; sudo fixes everything.
|
|
195
219
|
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
196
|
-
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('
|
|
220
|
+
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'} too.`);
|
|
197
221
|
}
|
|
198
222
|
}
|
|
199
223
|
if (key.state === 'invalid') {
|
package/src/commands/download.js
CHANGED
|
@@ -162,7 +162,7 @@ Examples:
|
|
|
162
162
|
$ unbound download start logs-export --check --last 90d --json
|
|
163
163
|
$ unbound download get 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6
|
|
164
164
|
|
|
165
|
-
Admin
|
|
165
|
+
Admin or Analytics Viewer. Exports are kept for ${RETENTION_HOURS} hours.
|
|
166
166
|
`);
|
|
167
167
|
|
|
168
168
|
group
|
|
@@ -203,7 +203,7 @@ Examples:
|
|
|
203
203
|
$ unbound download start analytics-export --filter dataset=terminal-runs --last 7d
|
|
204
204
|
$ unbound download start logs-export --check --last 90d --json
|
|
205
205
|
|
|
206
|
-
Admin
|
|
206
|
+
Admin or Analytics Viewer. Exports are kept for ${RETENTION_HOURS} hours.
|
|
207
207
|
${FILTER_HELP}${AGENT_HELP}`)
|
|
208
208
|
.action(async (kind, opts) => {
|
|
209
209
|
try {
|
package/src/commands/oacb.js
CHANGED
|
@@ -17,13 +17,16 @@ const PKG_VERSION = '0.2.1';
|
|
|
17
17
|
const TIERS = ['shadow', 'receipts', 'baseline', 'strict', 'paranoid'];
|
|
18
18
|
const AGENTS = ['claude-code', 'codex'];
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
const
|
|
20
|
+
const claudePaths = require('../claudePaths');
|
|
21
|
+
const CLAUDE_DIR = claudePaths.resolveClaudeConfigDir();
|
|
22
|
+
|
|
23
|
+
const CONSENT_RECEIPT_PATH = path.join(CLAUDE_DIR, 'oacb-consent.json');
|
|
24
|
+
const LEGACY_CONSENT_RECEIPT_PATH = path.join(claudePaths.DEFAULT_CLAUDE_DIR, 'oacb-consent.json');
|
|
22
25
|
|
|
23
26
|
// Claude Code paths
|
|
24
|
-
const CLAUDE_HOOKS_DIR =
|
|
25
|
-
const CLAUDE_SETTINGS_PATH =
|
|
26
|
-
const CLAUDE_SETTINGS_BACKUP_PATH = path.join(
|
|
27
|
+
const CLAUDE_HOOKS_DIR = claudePaths.claudeHooksDir(CLAUDE_DIR);
|
|
28
|
+
const CLAUDE_SETTINGS_PATH = claudePaths.claudeSettingsPath(CLAUDE_DIR);
|
|
29
|
+
const CLAUDE_SETTINGS_BACKUP_PATH = path.join(CLAUDE_DIR, 'settings.json.oacb-backup');
|
|
27
30
|
|
|
28
31
|
// Codex paths
|
|
29
32
|
const CODEX_HOOKS_DIR = path.join(os.homedir(), '.codex', 'hooks');
|
|
@@ -203,7 +206,7 @@ async function handleCheck() {
|
|
|
203
206
|
['Unbound session', hasUnboundSession ? 'logged in' : 'not logged in (optional)'],
|
|
204
207
|
['─── Claude Code ───', ''],
|
|
205
208
|
['Claude Code on PATH', claudeCodeVersion || 'not detected'],
|
|
206
|
-
[
|
|
209
|
+
[claudePaths.displayPath(CLAUDE_SETTINGS_PATH), claudeSettingsExist ? 'found' : 'not found'],
|
|
207
210
|
['OACB tier applied', claudeTier !== 'none' ? claudeTier : 'none (run `unbound oacb apply`)'],
|
|
208
211
|
['Hooks installed', `${claudeHookStates.filter(Boolean).length} / ${HOOK_NAMES.length}`],
|
|
209
212
|
['─── Codex CLI ─────', ''],
|
|
@@ -225,7 +228,22 @@ async function handleCheck() {
|
|
|
225
228
|
output.warn('No OACB tier applied to any agent. Run `unbound oacb apply` to start with shadow tier.');
|
|
226
229
|
}
|
|
227
230
|
|
|
228
|
-
|
|
231
|
+
const blankConfigDir = claudePaths.isBlankConfigDir();
|
|
232
|
+
if (blankConfigDir) {
|
|
233
|
+
output.error(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const mismatch = claudePaths.detectConfigDirMismatch([path.join('hooks', HOOK_NAMES[0])]);
|
|
238
|
+
if (mismatch) {
|
|
239
|
+
output.error(
|
|
240
|
+
`OACB hooks installed in ${mismatch.staleDir} but CLAUDE_CONFIG_DIR points at ${mismatch.activeDir} — ` +
|
|
241
|
+
'Claude will not load them; re-run `unbound oacb apply`.'
|
|
242
|
+
);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!mismatch && !blankConfigDir) output.success('Pre-flight complete.');
|
|
229
247
|
}
|
|
230
248
|
|
|
231
249
|
async function handleAudit({ agent, tier, format }) {
|
|
@@ -464,7 +482,7 @@ async function _applyClaudeCode({ baseline, tier, overrides, localHooks }) {
|
|
|
464
482
|
|
|
465
483
|
output.success(`OACB ${tier} applied (claude-code).`);
|
|
466
484
|
if (tier === 'shadow') {
|
|
467
|
-
output.info(
|
|
485
|
+
output.info(`Shadow: hooks log but never block. Review ${path.join(CLAUDE_HOOKS_DIR, 'oacb-audit.log')} for 2 weeks, then \`unbound oacb apply --tier baseline\`.`);
|
|
468
486
|
} else if (tier === 'receipts') {
|
|
469
487
|
output.info('Receipts: engineer sees WARN banners for risky commands and can abort. Expires in 30 days.');
|
|
470
488
|
}
|
|
@@ -762,6 +780,11 @@ async function handleRemove({ agent, dryRun }) {
|
|
|
762
780
|
output.success(`Wrote cleaned ${CLAUDE_SETTINGS_PATH}`);
|
|
763
781
|
|
|
764
782
|
try { await fs.unlink(CONSENT_RECEIPT_PATH); } catch (_) {}
|
|
783
|
+
// readConsentReceipt falls back to the legacy receipt, so a leftover copy
|
|
784
|
+
// would keep reporting OACB as installed after it was removed.
|
|
785
|
+
if (!claudePaths.samePath(CONSENT_RECEIPT_PATH, LEGACY_CONSENT_RECEIPT_PATH)) {
|
|
786
|
+
try { await fs.unlink(LEGACY_CONSENT_RECEIPT_PATH); } catch (_) {}
|
|
787
|
+
}
|
|
765
788
|
// The backup is no longer load-bearing after a successful remove — clean it
|
|
766
789
|
// up so the user's ~/.claude/ doesn't accumulate stale OACB state.
|
|
767
790
|
try { await fs.unlink(CLAUDE_SETTINGS_BACKUP_PATH); } catch (_) {}
|
|
@@ -1517,7 +1540,7 @@ async function historyDryRun(tier, agent) {
|
|
|
1517
1540
|
const commands = [];
|
|
1518
1541
|
|
|
1519
1542
|
// Try Claude Code session logs first (most accurate for auto-mode context)
|
|
1520
|
-
const ccProjectsDir = path.join(
|
|
1543
|
+
const ccProjectsDir = path.join(CLAUDE_DIR, 'projects');
|
|
1521
1544
|
try {
|
|
1522
1545
|
const projectDirs = await fs.readdir(ccProjectsDir);
|
|
1523
1546
|
for (const proj of projectDirs.slice(-5)) { // last 5 projects to bound cost
|
|
@@ -1676,6 +1699,9 @@ async function readConsentReceipt() {
|
|
|
1676
1699
|
try {
|
|
1677
1700
|
return JSON.parse(await fs.readFile(CONSENT_RECEIPT_PATH, 'utf8'));
|
|
1678
1701
|
} catch (_) {
|
|
1702
|
+
if (!claudePaths.samePath(CONSENT_RECEIPT_PATH, LEGACY_CONSENT_RECEIPT_PATH)) {
|
|
1703
|
+
try { return JSON.parse(await fs.readFile(LEGACY_CONSENT_RECEIPT_PATH, 'utf8')); } catch (_) {}
|
|
1704
|
+
}
|
|
1679
1705
|
return null;
|
|
1680
1706
|
}
|
|
1681
1707
|
}
|
|
@@ -1685,7 +1711,7 @@ async function readConsentReceipt() {
|
|
|
1685
1711
|
async function handleWhy({ agent }) {
|
|
1686
1712
|
const logPath = agent === 'codex'
|
|
1687
1713
|
? path.join(os.homedir(), '.codex', 'hooks', 'oacb-audit.log')
|
|
1688
|
-
: path.join(
|
|
1714
|
+
: path.join(CLAUDE_HOOKS_DIR, 'oacb-audit.log');
|
|
1689
1715
|
|
|
1690
1716
|
let entry = null;
|
|
1691
1717
|
try {
|
|
@@ -1778,7 +1804,7 @@ async function handleStatus({ agent }) {
|
|
|
1778
1804
|
|
|
1779
1805
|
const logPath = agent === 'codex'
|
|
1780
1806
|
? path.join(os.homedir(), '.codex', 'hooks', 'oacb-audit.log')
|
|
1781
|
-
: path.join(
|
|
1807
|
+
: path.join(CLAUDE_HOOKS_DIR, 'oacb-audit.log');
|
|
1782
1808
|
|
|
1783
1809
|
// Expiry countdown
|
|
1784
1810
|
let expiryNote = '';
|