@phnx-labs/agents-cli 1.20.45 → 1.20.47
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/CHANGELOG.md +12 -0
- package/dist/commands/secrets-import.d.ts +18 -0
- package/dist/commands/secrets-import.js +74 -0
- package/dist/commands/secrets.js +2 -0
- package/dist/index.js +14 -119
- package/dist/lib/daemon.js +40 -6
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/secrets/fallback.d.ts +48 -0
- package/dist/lib/secrets/fallback.js +48 -0
- package/dist/lib/secrets/index.d.ts +11 -0
- package/dist/lib/secrets/index.js +20 -2
- package/dist/lib/secrets/linux.d.ts +7 -0
- package/dist/lib/secrets/linux.js +113 -5
- package/dist/lib/secrets/windows.d.ts +7 -0
- package/dist/lib/secrets/windows.js +110 -5
- package/dist/lib/self-heal/checks/path.d.ts +2 -0
- package/dist/lib/self-heal/checks/path.js +30 -0
- package/dist/lib/self-heal/checks/resources.d.ts +2 -0
- package/dist/lib/self-heal/checks/resources.js +36 -0
- package/dist/lib/self-heal/checks/shadowing.d.ts +2 -0
- package/dist/lib/self-heal/checks/shadowing.js +48 -0
- package/dist/lib/self-heal/checks/shims.d.ts +2 -0
- package/dist/lib/self-heal/checks/shims.js +35 -0
- package/dist/lib/self-heal/registry.d.ts +22 -0
- package/dist/lib/self-heal/registry.js +66 -0
- package/dist/lib/self-heal/types.d.ts +41 -0
- package/dist/lib/self-heal/types.js +21 -0
- package/dist/lib/session/discover.js +41 -0
- package/dist/lib/shim-heal.d.ts +23 -0
- package/dist/lib/shim-heal.js +109 -0
- package/dist/lib/shims.d.ts +6 -0
- package/dist/lib/shims.js +1 -1
- package/dist/lib/versions.d.ts +16 -0
- package/dist/lib/versions.js +83 -12
- package/package.json +1 -1
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { spawnSync } from 'child_process';
|
|
20
20
|
import * as os from 'os';
|
|
21
21
|
import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
|
|
22
|
+
import { noteNativeShadow, _resetFallbackNoticeForTest, } from './fallback.js';
|
|
22
23
|
// Re-exported so existing importers (and tests) can keep reaching these via
|
|
23
24
|
// './linux.js'. The implementations live in ./filestore.ts.
|
|
24
25
|
export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
|
|
@@ -35,13 +36,16 @@ let isAvailable = false;
|
|
|
35
36
|
// ---------- file fallback state ----------
|
|
36
37
|
let useFileFallback = false;
|
|
37
38
|
let warnedFallback = false;
|
|
39
|
+
// Set once the keyring is observed locked/unreachable in this process, so the
|
|
40
|
+
// read-through in get/has stops re-probing it (and stops re-emitting notices).
|
|
41
|
+
let nativeUnreachable = false;
|
|
38
42
|
function activateFileFallback() {
|
|
39
43
|
if (useFileFallback)
|
|
40
44
|
return;
|
|
41
45
|
useFileFallback = true;
|
|
42
46
|
if (!warnedFallback) {
|
|
43
47
|
warnedFallback = true;
|
|
44
|
-
process.stderr.write(`[agents]
|
|
48
|
+
process.stderr.write(`[agents] using the encrypted file store at ${fileDir()}\n`);
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
function isLockedCollectionError(stderr) {
|
|
@@ -111,8 +115,21 @@ export function usesFileFallback() {
|
|
|
111
115
|
/** secret-tool lookup attributes:
|
|
112
116
|
* service=agents-cli account=<user> item=<itemName> */
|
|
113
117
|
export function hasSecretToolToken(item) {
|
|
114
|
-
if (preflight() === 'file')
|
|
115
|
-
|
|
118
|
+
if (preflight() === 'file') {
|
|
119
|
+
if (fileStore.has(item))
|
|
120
|
+
return true;
|
|
121
|
+
// The file store is primary under the fallback, but an item can still live
|
|
122
|
+
// only in an (unlocked) keyring that predates it — read through so it isn't
|
|
123
|
+
// silently shadowed.
|
|
124
|
+
const probe = readNativeItemRaw(item);
|
|
125
|
+
if (probe.value !== undefined) {
|
|
126
|
+
noteNativeShadow('shadowed', fileDir());
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (probe.locked)
|
|
130
|
+
noteNativeShadow('locked', fileDir());
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
116
133
|
const user = os.userInfo().username;
|
|
117
134
|
const result = spawnSync('secret-tool', [
|
|
118
135
|
'lookup',
|
|
@@ -125,14 +142,25 @@ export function hasSecretToolToken(item) {
|
|
|
125
142
|
}
|
|
126
143
|
const stderr = result.stderr?.toString() ?? '';
|
|
127
144
|
if (isLockedCollectionError(stderr)) {
|
|
145
|
+
nativeUnreachable = true;
|
|
128
146
|
activateFileFallback();
|
|
129
147
|
return fileStore.has(item);
|
|
130
148
|
}
|
|
131
149
|
return false;
|
|
132
150
|
}
|
|
133
151
|
export function getSecretToolToken(item) {
|
|
134
|
-
if (preflight() === 'file')
|
|
135
|
-
|
|
152
|
+
if (preflight() === 'file') {
|
|
153
|
+
if (fileStore.has(item))
|
|
154
|
+
return fileStore.get(item);
|
|
155
|
+
const probe = readNativeItemRaw(item);
|
|
156
|
+
if (probe.value !== undefined) {
|
|
157
|
+
noteNativeShadow('shadowed', fileDir());
|
|
158
|
+
return probe.value;
|
|
159
|
+
}
|
|
160
|
+
if (probe.locked)
|
|
161
|
+
noteNativeShadow('locked', fileDir());
|
|
162
|
+
throw new Error(`Secret '${item}' not found in the file store or keyring.`);
|
|
163
|
+
}
|
|
136
164
|
const user = os.userInfo().username;
|
|
137
165
|
const result = spawnSync('secret-tool', [
|
|
138
166
|
'lookup',
|
|
@@ -148,6 +176,7 @@ export function getSecretToolToken(item) {
|
|
|
148
176
|
}
|
|
149
177
|
const stderr = result.stderr?.toString() ?? '';
|
|
150
178
|
if (isLockedCollectionError(stderr)) {
|
|
179
|
+
nativeUnreachable = true;
|
|
151
180
|
activateFileFallback();
|
|
152
181
|
return fileStore.get(item);
|
|
153
182
|
}
|
|
@@ -171,6 +200,7 @@ export function setSecretToolToken(item, value) {
|
|
|
171
200
|
return;
|
|
172
201
|
const stderr = result.stderr?.toString().trim() ?? '';
|
|
173
202
|
if (isLockedCollectionError(stderr)) {
|
|
203
|
+
nativeUnreachable = true;
|
|
174
204
|
activateFileFallback();
|
|
175
205
|
fileStore.set(item, value);
|
|
176
206
|
return;
|
|
@@ -193,6 +223,7 @@ export function deleteSecretToolToken(item) {
|
|
|
193
223
|
return true;
|
|
194
224
|
const stderr = result.stderr?.toString() ?? '';
|
|
195
225
|
if (isLockedCollectionError(stderr)) {
|
|
226
|
+
nativeUnreachable = true;
|
|
196
227
|
activateFileFallback();
|
|
197
228
|
return fileStore.delete(item);
|
|
198
229
|
}
|
|
@@ -252,6 +283,81 @@ export function listSecretToolItems(prefix) {
|
|
|
252
283
|
const output = `${result.stdout?.toString() || ''}\n${result.stderr?.toString() || ''}`;
|
|
253
284
|
return parseSecretToolItems(output, prefix);
|
|
254
285
|
}
|
|
286
|
+
// ---------- native-direct helpers (bypass preflight routing) ----------
|
|
287
|
+
//
|
|
288
|
+
// These always talk to secret-tool regardless of whether the process has fallen
|
|
289
|
+
// back to the file store. They power (a) the read-through that keeps the file
|
|
290
|
+
// store from silently shadowing keyring items, and (b) `import-keyring`.
|
|
291
|
+
/**
|
|
292
|
+
* Read one item straight from the keyring. Returns `{value}` on a hit,
|
|
293
|
+
* `{locked:true}` when the collection is locked/unreachable, and `{}` on a plain
|
|
294
|
+
* miss. Never throws and never emits — the caller decides whether to notice.
|
|
295
|
+
*/
|
|
296
|
+
function readNativeItemRaw(item) {
|
|
297
|
+
if (nativeUnreachable)
|
|
298
|
+
return { locked: true };
|
|
299
|
+
if (!secretToolAvailable())
|
|
300
|
+
return {};
|
|
301
|
+
const user = os.userInfo().username;
|
|
302
|
+
const r = spawnSync('secret-tool', [
|
|
303
|
+
'lookup', 'service', SERVICE, 'account', user, 'item', item,
|
|
304
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
305
|
+
if (r.status === 0) {
|
|
306
|
+
const v = r.stdout?.toString().trim();
|
|
307
|
+
return v && v.length ? { value: v } : {};
|
|
308
|
+
}
|
|
309
|
+
if (isLockedCollectionError(r.stderr?.toString() ?? '')) {
|
|
310
|
+
nativeUnreachable = true;
|
|
311
|
+
return { locked: true };
|
|
312
|
+
}
|
|
313
|
+
return {};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Enumerate agents-cli items in the keyring whose name starts with `prefix`.
|
|
317
|
+
* `available` is false when secret-tool isn't installed; `locked` is true when
|
|
318
|
+
* the collection is locked.
|
|
319
|
+
*/
|
|
320
|
+
function listNativeItemsRaw(prefix) {
|
|
321
|
+
if (!secretToolAvailable())
|
|
322
|
+
return { items: [], locked: false, available: false };
|
|
323
|
+
const r = spawnSync('secret-tool', [
|
|
324
|
+
'search', '--all', 'service', SERVICE,
|
|
325
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
326
|
+
if (r.status !== 0) {
|
|
327
|
+
const locked = isLockedCollectionError(r.stderr?.toString() ?? '');
|
|
328
|
+
if (locked)
|
|
329
|
+
nativeUnreachable = true;
|
|
330
|
+
return { items: [], locked, available: true };
|
|
331
|
+
}
|
|
332
|
+
const output = `${r.stdout?.toString() || ''}\n${r.stderr?.toString() || ''}`;
|
|
333
|
+
return { items: parseSecretToolItems(output, prefix), locked: false, available: true };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Copy agents-cli items from the keyring into the file store (the `import-keyring`
|
|
337
|
+
* backend for Linux). Requires an unlocked keyring; items already in the file
|
|
338
|
+
* store are left untouched. With `commit=false` it reports what it *would* do.
|
|
339
|
+
*/
|
|
340
|
+
export function importNativeSecretToolItems(prefix, commit) {
|
|
341
|
+
const { items, locked, available } = listNativeItemsRaw(prefix);
|
|
342
|
+
if (!available || locked)
|
|
343
|
+
return { available, locked, results: [] };
|
|
344
|
+
const results = [];
|
|
345
|
+
for (const item of items) {
|
|
346
|
+
if (fileStore.has(item)) {
|
|
347
|
+
results.push({ item, status: 'exists' });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const probe = readNativeItemRaw(item);
|
|
351
|
+
if (probe.value === undefined) {
|
|
352
|
+
results.push({ item, status: 'failed', detail: probe.locked ? 'keyring locked' : 'unreadable' });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (commit)
|
|
356
|
+
fileStore.set(item, probe.value);
|
|
357
|
+
results.push({ item, status: commit ? 'imported' : 'would-import' });
|
|
358
|
+
}
|
|
359
|
+
return { available, locked, results };
|
|
360
|
+
}
|
|
255
361
|
/** KeychainBackend implementation for Linux. Routes through secret-tool
|
|
256
362
|
* with a transparent encrypted-file fallback when the default Secret
|
|
257
363
|
* Service collection is locked (or libsecret-tools is not installed but
|
|
@@ -280,6 +386,8 @@ export function _resetForTest(opts = {}) {
|
|
|
280
386
|
_resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
|
|
281
387
|
useFileFallback = opts.forceFileFallback ?? false;
|
|
282
388
|
warnedFallback = false;
|
|
389
|
+
nativeUnreachable = false;
|
|
283
390
|
checkedAvailability = false;
|
|
284
391
|
isAvailable = false;
|
|
392
|
+
_resetFallbackNoticeForTest();
|
|
285
393
|
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* names directly.
|
|
28
28
|
*/
|
|
29
29
|
import type { KeychainBackend } from './index.js';
|
|
30
|
+
import { type NativeImportReport } from './fallback.js';
|
|
30
31
|
export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
|
|
31
32
|
/**
|
|
32
33
|
* CRED_MAX_CREDENTIAL_BLOB_SIZE — Credential Manager rejects a generic
|
|
@@ -53,6 +54,12 @@ export declare function listCredManItems(prefix: string): string[];
|
|
|
53
54
|
* parseSecretToolItems (linux.ts). Exported for tests.
|
|
54
55
|
*/
|
|
55
56
|
export declare function parseWindowsCredList(output: string, prefix: string): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Copy agents-cli credentials from Credential Manager into the file store (the
|
|
59
|
+
* `import-keyring` backend for Windows). Requires a reachable store; items
|
|
60
|
+
* already in the file store are left untouched.
|
|
61
|
+
*/
|
|
62
|
+
export declare function importNativeCredManItems(prefix: string, commit: boolean): NativeImportReport;
|
|
56
63
|
/**
|
|
57
64
|
* KeychainBackend implementation for Windows. Routes through Windows Credential
|
|
58
65
|
* Manager (via PowerShell P/Invoke) with a transparent encrypted-file fallback
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { spawnSync } from 'child_process';
|
|
30
30
|
import { encodePwshBase64 } from '../pwsh.js';
|
|
31
31
|
import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
|
|
32
|
+
import { noteNativeShadow, _resetFallbackNoticeForTest, } from './fallback.js';
|
|
32
33
|
// Re-exported so importers (and tests) can keep reaching these via './windows.js'.
|
|
33
34
|
export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
|
|
34
35
|
const POWERSHELL = 'powershell.exe';
|
|
@@ -250,13 +251,16 @@ let isAvailable = false;
|
|
|
250
251
|
// ---------- file fallback state ----------
|
|
251
252
|
let useFileFallback = false;
|
|
252
253
|
let warnedFallback = false;
|
|
254
|
+
// Set once Credential Manager is observed unreachable in this process, so the
|
|
255
|
+
// read-through in get/has stops re-probing it (and re-emitting notices).
|
|
256
|
+
let nativeUnreachable = false;
|
|
253
257
|
function activateFileFallback() {
|
|
254
258
|
if (useFileFallback)
|
|
255
259
|
return;
|
|
256
260
|
useFileFallback = true;
|
|
257
261
|
if (!warnedFallback) {
|
|
258
262
|
warnedFallback = true;
|
|
259
|
-
process.stderr.write(`[agents]
|
|
263
|
+
process.stderr.write(`[agents] using the encrypted file store at ${fileDir()}\n`);
|
|
260
264
|
}
|
|
261
265
|
}
|
|
262
266
|
/**
|
|
@@ -316,22 +320,45 @@ export function usesFileFallback() {
|
|
|
316
320
|
}
|
|
317
321
|
// ---------- Credential Manager ops with fallback ----------
|
|
318
322
|
export function hasCredManToken(item) {
|
|
319
|
-
if (preflight() === 'file')
|
|
320
|
-
|
|
323
|
+
if (preflight() === 'file') {
|
|
324
|
+
if (fileStore.has(item))
|
|
325
|
+
return true;
|
|
326
|
+
// Read through to Credential Manager so an item that predates the fallback
|
|
327
|
+
// isn't silently shadowed by the file store.
|
|
328
|
+
const probe = readNativeCredItemRaw(item);
|
|
329
|
+
if (probe.value !== undefined) {
|
|
330
|
+
noteNativeShadow('shadowed', fileDir());
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
if (probe.unavailable)
|
|
334
|
+
noteNativeShadow('locked', fileDir());
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
321
337
|
const r = runCred('has', { target: item });
|
|
322
338
|
if (r.status === 0)
|
|
323
339
|
return true;
|
|
324
340
|
if (r.status === 3)
|
|
325
341
|
return false;
|
|
326
342
|
if (isCredManUnavailableError(r)) {
|
|
343
|
+
nativeUnreachable = true;
|
|
327
344
|
activateFileFallback();
|
|
328
345
|
return fileStore.has(item);
|
|
329
346
|
}
|
|
330
347
|
return false;
|
|
331
348
|
}
|
|
332
349
|
export function getCredManToken(item) {
|
|
333
|
-
if (preflight() === 'file')
|
|
334
|
-
|
|
350
|
+
if (preflight() === 'file') {
|
|
351
|
+
if (fileStore.has(item))
|
|
352
|
+
return fileStore.get(item);
|
|
353
|
+
const probe = readNativeCredItemRaw(item);
|
|
354
|
+
if (probe.value !== undefined) {
|
|
355
|
+
noteNativeShadow('shadowed', fileDir());
|
|
356
|
+
return probe.value;
|
|
357
|
+
}
|
|
358
|
+
if (probe.unavailable)
|
|
359
|
+
noteNativeShadow('locked', fileDir());
|
|
360
|
+
throw new Error(`Secret '${item}' not found in the file store or Credential Manager.`);
|
|
361
|
+
}
|
|
335
362
|
const r = runCred('get', { target: item });
|
|
336
363
|
if (r.status === 0) {
|
|
337
364
|
// stdout is base64 of the raw UTF-8 blob (dodges PowerShell encoding corruption).
|
|
@@ -340,6 +367,7 @@ export function getCredManToken(item) {
|
|
|
340
367
|
if (r.status === 3)
|
|
341
368
|
throw new Error(`Secret '${item}' not found in Credential Manager.`);
|
|
342
369
|
if (isCredManUnavailableError(r)) {
|
|
370
|
+
nativeUnreachable = true;
|
|
343
371
|
activateFileFallback();
|
|
344
372
|
return fileStore.get(item);
|
|
345
373
|
}
|
|
@@ -362,6 +390,7 @@ export function setCredManToken(item, value) {
|
|
|
362
390
|
if (r.status === 0)
|
|
363
391
|
return;
|
|
364
392
|
if (isCredManUnavailableError(r)) {
|
|
393
|
+
nativeUnreachable = true;
|
|
365
394
|
activateFileFallback();
|
|
366
395
|
fileStore.set(item, value);
|
|
367
396
|
return;
|
|
@@ -377,6 +406,7 @@ export function deleteCredManToken(item) {
|
|
|
377
406
|
if (r.status === 3)
|
|
378
407
|
return false;
|
|
379
408
|
if (isCredManUnavailableError(r)) {
|
|
409
|
+
nativeUnreachable = true;
|
|
380
410
|
activateFileFallback();
|
|
381
411
|
return fileStore.delete(item);
|
|
382
412
|
}
|
|
@@ -389,6 +419,7 @@ export function listCredManItems(prefix) {
|
|
|
389
419
|
if (r.status === 0)
|
|
390
420
|
return parseWindowsCredList(r.stdout, prefix);
|
|
391
421
|
if (isCredManUnavailableError(r)) {
|
|
422
|
+
nativeUnreachable = true;
|
|
392
423
|
activateFileFallback();
|
|
393
424
|
return fileStore.list(prefix);
|
|
394
425
|
}
|
|
@@ -407,6 +438,78 @@ export function parseWindowsCredList(output, prefix) {
|
|
|
407
438
|
.filter((s) => s.startsWith(prefix));
|
|
408
439
|
return [...new Set(items)]; // dedupe
|
|
409
440
|
}
|
|
441
|
+
// ---------- native-direct helpers (bypass preflight routing) ----------
|
|
442
|
+
//
|
|
443
|
+
// Always talk to Credential Manager regardless of the file fallback. They power
|
|
444
|
+
// (a) the read-through that keeps the file store from shadowing credman items,
|
|
445
|
+
// and (b) `import-keyring`.
|
|
446
|
+
/**
|
|
447
|
+
* Read one item straight from Credential Manager. `{value}` on hit,
|
|
448
|
+
* `{unavailable:true}` when the store is unreachable, `{}` on a plain miss.
|
|
449
|
+
* Never throws, never emits.
|
|
450
|
+
*/
|
|
451
|
+
function readNativeCredItemRaw(item) {
|
|
452
|
+
if (nativeUnreachable)
|
|
453
|
+
return { unavailable: true };
|
|
454
|
+
if (!powershellAvailable())
|
|
455
|
+
return {};
|
|
456
|
+
const r = runCred('get', { target: item });
|
|
457
|
+
if (r.status === 0)
|
|
458
|
+
return { value: Buffer.from(r.stdout.trim(), 'base64').toString('utf8') };
|
|
459
|
+
if (r.status === 3)
|
|
460
|
+
return {};
|
|
461
|
+
if (isCredManUnavailableError(r)) {
|
|
462
|
+
nativeUnreachable = true;
|
|
463
|
+
return { unavailable: true };
|
|
464
|
+
}
|
|
465
|
+
return {};
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Enumerate agents-cli credentials under `prefix`. Windows credentials have no
|
|
469
|
+
* service scoping — the target IS the identifier — so we NEVER enumerate with an
|
|
470
|
+
* empty filter (that returns unrelated machine credentials). The filter is
|
|
471
|
+
* floored to the `agents-cli.` namespace; bare items (unprefixed targets) are
|
|
472
|
+
* therefore out of scope for auto-discovery on Windows.
|
|
473
|
+
*/
|
|
474
|
+
function listNativeCredItemsRaw(prefix) {
|
|
475
|
+
if (!powershellAvailable())
|
|
476
|
+
return { items: [], locked: false, available: false };
|
|
477
|
+
const floor = prefix && prefix.startsWith('agents-cli.') ? prefix : 'agents-cli.';
|
|
478
|
+
const r = runCred('list', { prefix: floor });
|
|
479
|
+
if (r.status === 0)
|
|
480
|
+
return { items: parseWindowsCredList(r.stdout, floor), locked: false, available: true };
|
|
481
|
+
if (isCredManUnavailableError(r)) {
|
|
482
|
+
nativeUnreachable = true;
|
|
483
|
+
return { items: [], locked: true, available: true };
|
|
484
|
+
}
|
|
485
|
+
return { items: [], locked: false, available: true };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Copy agents-cli credentials from Credential Manager into the file store (the
|
|
489
|
+
* `import-keyring` backend for Windows). Requires a reachable store; items
|
|
490
|
+
* already in the file store are left untouched.
|
|
491
|
+
*/
|
|
492
|
+
export function importNativeCredManItems(prefix, commit) {
|
|
493
|
+
const { items, locked, available } = listNativeCredItemsRaw(prefix);
|
|
494
|
+
if (!available || locked)
|
|
495
|
+
return { available, locked, results: [] };
|
|
496
|
+
const results = [];
|
|
497
|
+
for (const item of items) {
|
|
498
|
+
if (fileStore.has(item)) {
|
|
499
|
+
results.push({ item, status: 'exists' });
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const probe = readNativeCredItemRaw(item);
|
|
503
|
+
if (probe.value === undefined) {
|
|
504
|
+
results.push({ item, status: 'failed', detail: probe.unavailable ? 'credential manager unavailable' : 'unreadable' });
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (commit)
|
|
508
|
+
fileStore.set(item, probe.value);
|
|
509
|
+
results.push({ item, status: commit ? 'imported' : 'would-import' });
|
|
510
|
+
}
|
|
511
|
+
return { available, locked, results };
|
|
512
|
+
}
|
|
410
513
|
/**
|
|
411
514
|
* KeychainBackend implementation for Windows. Routes through Windows Credential
|
|
412
515
|
* Manager (via PowerShell P/Invoke) with a transparent encrypted-file fallback
|
|
@@ -440,6 +543,8 @@ export function _resetForTest(opts = {}) {
|
|
|
440
543
|
_resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
|
|
441
544
|
useFileFallback = opts.forceFileFallback ?? false;
|
|
442
545
|
warnedFallback = false;
|
|
546
|
+
nativeUnreachable = false;
|
|
547
|
+
_resetFallbackNoticeForTest();
|
|
443
548
|
if (opts.forceAvailable === undefined || opts.forceAvailable === null) {
|
|
444
549
|
checkedAvailability = false;
|
|
445
550
|
isAvailable = false;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// path check — ensures the shims dir is on PATH. On POSIX it appends to the shell
|
|
2
|
+
// rc file; on Windows it registers on the user PATH (registry). addShimsToPath is a
|
|
3
|
+
// no-op when already present, so this is idempotent. Formerly an interactive prompt
|
|
4
|
+
// in index.ts fired on every new shell; here the daemon does it once, silently.
|
|
5
|
+
//
|
|
6
|
+
// Caveat: an already-open shell won't pick up the new rc/PATH entry until it reloads
|
|
7
|
+
// — but new shells will, so the recurring prompt stops.
|
|
8
|
+
import { resultOf } from '../types.js';
|
|
9
|
+
import { isShimsInPath, addShimsToPath } from '../../shims.js';
|
|
10
|
+
export const pathCheck = {
|
|
11
|
+
id: 'path',
|
|
12
|
+
title: 'Shims directory on PATH',
|
|
13
|
+
cadence: 'startup',
|
|
14
|
+
async run(ctx) {
|
|
15
|
+
if (isShimsInPath())
|
|
16
|
+
return resultOf([], []);
|
|
17
|
+
if (ctx.dryRun)
|
|
18
|
+
return resultOf(['add shims dir to PATH'], []);
|
|
19
|
+
const r = addShimsToPath();
|
|
20
|
+
if (r.success && !r.alreadyPresent) {
|
|
21
|
+
return resultOf([`added shims to PATH (${r.location ?? r.rcFile ?? 'PATH'})`], []);
|
|
22
|
+
}
|
|
23
|
+
if (r.success && r.alreadyPresent) {
|
|
24
|
+
// Present in the rc file but not in THIS process's PATH — a reload issue,
|
|
25
|
+
// not something to fix again. Report quietly.
|
|
26
|
+
return resultOf([], [`shims dir in ${r.rcFile ?? 'rc file'} but not loaded — open a new terminal`]);
|
|
27
|
+
}
|
|
28
|
+
return resultOf([], [`could not add shims to PATH: ${r.error ?? 'unknown'}`]);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// resources check — reconciles each installed version's home against the DotAgents
|
|
2
|
+
// definitions (commands, skills, hooks, rules, mcp, plugins). This is a thin adapter
|
|
3
|
+
// over the existing, battle-tested heal() engine (lib/heal.ts) — no behavior change;
|
|
4
|
+
// it just re-expresses heal()'s result in the unified CheckResult shape.
|
|
5
|
+
import { resultOf } from '../types.js';
|
|
6
|
+
export const resourcesCheck = {
|
|
7
|
+
id: 'resources',
|
|
8
|
+
title: 'Resource sync (commands, skills, hooks, rules, plugins)',
|
|
9
|
+
cadence: 'periodic',
|
|
10
|
+
async run(ctx) {
|
|
11
|
+
// Lazy import so the (heavy) heal graph only loads when this check actually runs.
|
|
12
|
+
const { heal } = await import('../../heal.js');
|
|
13
|
+
const result = await heal({ mode: ctx.mode, dryRun: ctx.dryRun });
|
|
14
|
+
const fixed = [];
|
|
15
|
+
const needsAttention = [];
|
|
16
|
+
let healed = 0;
|
|
17
|
+
for (const v of result.versions) {
|
|
18
|
+
healed += v.healed.length;
|
|
19
|
+
for (const s of v.skipped) {
|
|
20
|
+
needsAttention.push(`${v.agent}@${v.version}: ${s.kind}/${s.name} (${s.reason})`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (healed > 0)
|
|
24
|
+
fixed.push(`${healed} resource(s) reconciled`);
|
|
25
|
+
for (const m of result.repairedManifests) {
|
|
26
|
+
fixed.push(`plugin ${m.plugin}: dropped ${m.droppedFields.join(', ')}`);
|
|
27
|
+
}
|
|
28
|
+
for (const p of result.refreshedPlugins) {
|
|
29
|
+
fixed.push(`plugin ${p.plugin}: ${p.from} -> ${p.to}`);
|
|
30
|
+
}
|
|
31
|
+
for (const s of result.skippedPlugins) {
|
|
32
|
+
needsAttention.push(`plugin ${s.plugin}: ${s.reason} (${s.from} vs ${s.upstream})`);
|
|
33
|
+
}
|
|
34
|
+
return resultOf(fixed, needsAttention);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// shadowing check — when a harness's own launcher shadows our shim on PATH, adopt
|
|
2
|
+
// it (symlink-only, reversible) so version management wins regardless of PATH order.
|
|
3
|
+
// A REAL native binary is never moved — it's surfaced as needsAttention so the
|
|
4
|
+
// interactive layer can inform the user once. POSIX-only (the launcher convention
|
|
5
|
+
// and PATH-order problem are POSIX; Windows resolves via the registry PATH).
|
|
6
|
+
import { resultOf } from '../types.js';
|
|
7
|
+
import { AGENTS } from '../../agents.js';
|
|
8
|
+
import { getPathShadowingExecutable, adoptShadowingLauncher, listAgentsWithInstalledVersions, } from '../../shims.js';
|
|
9
|
+
import { getGlobalDefault } from '../../versions.js';
|
|
10
|
+
export const shadowingCheck = {
|
|
11
|
+
id: 'shadowing',
|
|
12
|
+
title: 'Launcher shadowing the version-managed shim',
|
|
13
|
+
platforms: ['darwin', 'linux'],
|
|
14
|
+
cadence: 'frequent',
|
|
15
|
+
async run(ctx) {
|
|
16
|
+
const fixed = [];
|
|
17
|
+
const needsAttention = [];
|
|
18
|
+
for (const agent of listAgentsWithInstalledVersions()) {
|
|
19
|
+
if (!getGlobalDefault(agent))
|
|
20
|
+
continue; // only default agents, like the interactive flow
|
|
21
|
+
const cmd = AGENTS[agent].cliCommand;
|
|
22
|
+
const shadowedBy = getPathShadowingExecutable(agent);
|
|
23
|
+
if (!shadowedBy)
|
|
24
|
+
continue;
|
|
25
|
+
if (ctx.dryRun) {
|
|
26
|
+
// Classify without mutating: adoption only ever touches a symlink.
|
|
27
|
+
let isSymlink = false;
|
|
28
|
+
try {
|
|
29
|
+
const fs = await import('node:fs');
|
|
30
|
+
isSymlink = fs.lstatSync(shadowedBy).isSymbolicLink();
|
|
31
|
+
}
|
|
32
|
+
catch { /* treat as real binary */ }
|
|
33
|
+
if (isSymlink)
|
|
34
|
+
fixed.push(`${cmd} launcher (${shadowedBy})`);
|
|
35
|
+
else
|
|
36
|
+
needsAttention.push(`${cmd}: real binary shadows the shim (${shadowedBy})`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const res = adoptShadowingLauncher(agent);
|
|
40
|
+
if (res.adopted)
|
|
41
|
+
fixed.push(`adopted ${cmd} launcher (${res.launcher})`);
|
|
42
|
+
else if (res.reason === 'not-a-symlink') {
|
|
43
|
+
needsAttention.push(`${cmd}: real binary shadows the shim (${shadowedBy})`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return resultOf(fixed, needsAttention);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// shims check — keeps the dispatch shims and versioned aliases current, and clears
|
|
2
|
+
// pre-split legacy shim files. Formerly done in the interactive index.ts startup
|
|
3
|
+
// (which PRINTED "Updated <cli> shim" on every run); here it runs silently in the
|
|
4
|
+
// background so the shim schema settles without user-facing churn.
|
|
5
|
+
import { resultOf } from '../types.js';
|
|
6
|
+
import { AGENTS } from '../../agents.js';
|
|
7
|
+
import { ensureShimCurrent, ensureVersionedAliasCurrent, isShimCurrent, isVersionedAliasCurrent, removeLegacyUserShim, listAgentsWithInstalledVersions, } from '../../shims.js';
|
|
8
|
+
import { listInstalledVersions } from '../../versions.js';
|
|
9
|
+
export const shimsCheck = {
|
|
10
|
+
id: 'shims',
|
|
11
|
+
title: 'Dispatch shims + versioned aliases',
|
|
12
|
+
cadence: 'frequent',
|
|
13
|
+
async run(ctx) {
|
|
14
|
+
const fixed = [];
|
|
15
|
+
for (const agent of listAgentsWithInstalledVersions()) {
|
|
16
|
+
const cmd = AGENTS[agent].cliCommand;
|
|
17
|
+
if (!isShimCurrent(agent)) {
|
|
18
|
+
if (!ctx.dryRun)
|
|
19
|
+
ensureShimCurrent(agent);
|
|
20
|
+
fixed.push(`${cmd} shim`);
|
|
21
|
+
}
|
|
22
|
+
for (const version of listInstalledVersions(agent)) {
|
|
23
|
+
if (!isVersionedAliasCurrent(agent, version)) {
|
|
24
|
+
if (!ctx.dryRun)
|
|
25
|
+
ensureVersionedAliasCurrent(agent, version);
|
|
26
|
+
fixed.push(`${cmd}@${version} alias`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Pre-split ~/.agents/shims/<cli> files cause false-positive shadow hits.
|
|
30
|
+
if (!ctx.dryRun && removeLegacyUserShim(agent))
|
|
31
|
+
fixed.push(`removed legacy ${cmd} shim`);
|
|
32
|
+
}
|
|
33
|
+
return resultOf(fixed, []);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HealCheck, HealCheckId, HealCadence, SelfHealReport } from './types.js';
|
|
2
|
+
export declare const HEAL_CHECKS: HealCheck[];
|
|
3
|
+
export interface SelfHealOptions {
|
|
4
|
+
/** Restrict to these check ids; omit to run every registered check. */
|
|
5
|
+
checks?: HealCheckId[];
|
|
6
|
+
/** Only run checks whose cadence is in this set (daemon scheduling). */
|
|
7
|
+
cadences?: HealCadence[];
|
|
8
|
+
/** 'safe' (daemon default) or 'full' (doctor --fix). Default 'safe'. */
|
|
9
|
+
mode?: 'safe' | 'full';
|
|
10
|
+
/** Detect only — never write. Default false. */
|
|
11
|
+
dryRun?: boolean;
|
|
12
|
+
/** Override the platform gate (tests). Default process.platform. */
|
|
13
|
+
platform?: NodeJS.Platform;
|
|
14
|
+
}
|
|
15
|
+
/** Run the selected checks, isolating per-check failures. */
|
|
16
|
+
export declare function runSelfHeal(opts?: SelfHealOptions): Promise<SelfHealReport>;
|
|
17
|
+
/** True if any check repaired something (for daemon logging / notification). */
|
|
18
|
+
export declare function selfHealChangedAnything(report: SelfHealReport): boolean;
|
|
19
|
+
/** True if any check surfaced something a human should look at. */
|
|
20
|
+
export declare function selfHealNeedsAttention(report: SelfHealReport): boolean;
|
|
21
|
+
/** One-line human summary, e.g. "shims: 2 fixed; path: 1 fixed". */
|
|
22
|
+
export declare function summarizeSelfHeal(report: SelfHealReport): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The self-heal registry + runner.
|
|
2
|
+
//
|
|
3
|
+
// One ordered list of HealChecks; one runner that executes the requested subset,
|
|
4
|
+
// isolating failures (one check throwing never aborts the rest) and aggregating a
|
|
5
|
+
// SelfHealReport. Both front doors — the daemon (by cadence) and `agents doctor`
|
|
6
|
+
// (all, or by id) — call runSelfHeal.
|
|
7
|
+
import { resourcesCheck } from './checks/resources.js';
|
|
8
|
+
import { shimsCheck } from './checks/shims.js';
|
|
9
|
+
import { shadowingCheck } from './checks/shadowing.js';
|
|
10
|
+
import { pathCheck } from './checks/path.js';
|
|
11
|
+
// Order matters: cheap structural fixes (shims, shadow adoption, PATH) before the
|
|
12
|
+
// heavier resource reconciliation, so a freshly-repaired shim is in place first.
|
|
13
|
+
export const HEAL_CHECKS = [
|
|
14
|
+
shimsCheck,
|
|
15
|
+
shadowingCheck,
|
|
16
|
+
pathCheck,
|
|
17
|
+
resourcesCheck,
|
|
18
|
+
];
|
|
19
|
+
/** Run the selected checks, isolating per-check failures. */
|
|
20
|
+
export async function runSelfHeal(opts = {}) {
|
|
21
|
+
const platform = opts.platform ?? process.platform;
|
|
22
|
+
const ctx = { mode: opts.mode ?? 'safe', dryRun: opts.dryRun ?? false };
|
|
23
|
+
const selected = HEAL_CHECKS.filter((c) => {
|
|
24
|
+
if (opts.checks && !opts.checks.includes(c.id))
|
|
25
|
+
return false;
|
|
26
|
+
if (opts.cadences && !opts.cadences.includes(c.cadence))
|
|
27
|
+
return false;
|
|
28
|
+
if (c.platforms && !c.platforms.includes(platform))
|
|
29
|
+
return false;
|
|
30
|
+
return true;
|
|
31
|
+
});
|
|
32
|
+
const reports = [];
|
|
33
|
+
for (const check of selected) {
|
|
34
|
+
try {
|
|
35
|
+
const result = await check.run(ctx);
|
|
36
|
+
reports.push({ id: check.id, title: check.title, result });
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
reports.push({ id: check.id, title: check.title, result: null, error: err.message });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { checks: reports };
|
|
43
|
+
}
|
|
44
|
+
/** True if any check repaired something (for daemon logging / notification). */
|
|
45
|
+
export function selfHealChangedAnything(report) {
|
|
46
|
+
return report.checks.some((c) => (c.result?.fixed.length ?? 0) > 0);
|
|
47
|
+
}
|
|
48
|
+
/** True if any check surfaced something a human should look at. */
|
|
49
|
+
export function selfHealNeedsAttention(report) {
|
|
50
|
+
return report.checks.some((c) => (c.result?.needsAttention.length ?? 0) > 0 || Boolean(c.error));
|
|
51
|
+
}
|
|
52
|
+
/** One-line human summary, e.g. "shims: 2 fixed; path: 1 fixed". */
|
|
53
|
+
export function summarizeSelfHeal(report) {
|
|
54
|
+
const parts = [];
|
|
55
|
+
for (const c of report.checks) {
|
|
56
|
+
if (c.error) {
|
|
57
|
+
parts.push(`${c.id}: error (${c.error})`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const n = c.result?.fixed.length ?? 0;
|
|
61
|
+
const a = c.result?.needsAttention.length ?? 0;
|
|
62
|
+
if (n > 0 || a > 0)
|
|
63
|
+
parts.push(`${c.id}: ${n} fixed${a > 0 ? `, ${a} to review` : ''}`);
|
|
64
|
+
}
|
|
65
|
+
return parts.join('; ') || 'nothing to heal';
|
|
66
|
+
}
|