@phnx-labs/agents-cli 1.22.22 → 1.22.23
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 +209 -0
- package/README.md +8 -2
- package/dist/commands/doctor.js +15 -7
- package/dist/commands/exec.js +20 -6
- package/dist/commands/focus.d.ts +76 -4
- package/dist/commands/focus.js +219 -40
- package/dist/commands/fork.d.ts +20 -2
- package/dist/commands/fork.js +91 -64
- package/dist/commands/go.d.ts +25 -0
- package/dist/commands/go.js +63 -2
- package/dist/commands/harness-wizard.d.ts +206 -0
- package/dist/commands/harness-wizard.js +403 -0
- package/dist/commands/harness.d.ts +12 -0
- package/dist/commands/harness.js +97 -101
- package/dist/commands/resume.js +10 -3
- package/dist/commands/secrets.js +25 -30
- package/dist/commands/sessions-resume.d.ts +21 -3
- package/dist/commands/sessions-resume.js +50 -11
- package/dist/commands/sessions.d.ts +81 -5
- package/dist/commands/sessions.js +325 -66
- package/dist/commands/watchdog.js +13 -2
- package/dist/lib/agents.d.ts +1 -1
- package/dist/lib/agents.js +132 -0
- package/dist/lib/codex-policy.d.ts +17 -0
- package/dist/lib/codex-policy.js +48 -0
- package/dist/lib/crabbox/lease.d.ts +25 -0
- package/dist/lib/crabbox/lease.js +62 -0
- package/dist/lib/daemon.js +70 -0
- package/dist/lib/exec.d.ts +4 -0
- package/dist/lib/exec.js +88 -54
- package/dist/lib/feed-broadcast.d.ts +1 -20
- package/dist/lib/feed-broadcast.js +31 -1
- package/dist/lib/hooks.js +12 -2
- package/dist/lib/mcp.js +44 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
- package/dist/lib/models.d.ts +0 -5
- package/dist/lib/models.js +48 -0
- package/dist/lib/plugin-marketplace.js +9 -0
- package/dist/lib/pricing/prices.json +119 -92
- package/dist/lib/pricing/table.js +13 -0
- package/dist/lib/remote-agents-json.d.ts +29 -1
- package/dist/lib/remote-agents-json.js +47 -10
- package/dist/lib/resources/mcp.js +2 -0
- package/dist/lib/resources/permissions.js +3 -0
- package/dist/lib/resources/types.d.ts +2 -1
- package/dist/lib/runner.js +28 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +118 -49
- package/dist/lib/secrets/reaper.d.ts +87 -0
- package/dist/lib/secrets/reaper.js +184 -0
- package/dist/lib/secrets/remote.d.ts +29 -0
- package/dist/lib/secrets/remote.js +37 -1
- package/dist/lib/session/active.d.ts +36 -1
- package/dist/lib/session/active.js +60 -19
- package/dist/lib/session/actor-sidecar.d.ts +14 -0
- package/dist/lib/session/actor-sidecar.js +67 -4
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +32 -1
- package/dist/lib/session/discover.js +168 -0
- package/dist/lib/session/parse.d.ts +10 -0
- package/dist/lib/session/parse.js +98 -0
- package/dist/lib/session/remote-list.d.ts +10 -1
- package/dist/lib/session/remote-list.js +2 -8
- package/dist/lib/session/remote.d.ts +57 -3
- package/dist/lib/session/remote.js +90 -26
- package/dist/lib/session/resume-command.d.ts +6 -0
- package/dist/lib/session/resume-command.js +8 -0
- package/dist/lib/session/session-cache.d.ts +173 -0
- package/dist/lib/session/session-cache.js +399 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +1 -1
- package/dist/lib/session/width.js +12 -2
- package/dist/lib/shims.d.ts +2 -2
- package/dist/lib/shims.js +40 -5
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/usage.d.ts +13 -0
- package/dist/lib/usage.js +215 -0
- package/dist/lib/versions.js +13 -2
- package/package.json +1 -1
- package/dist/bin/agents +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
|
@@ -36,6 +36,57 @@ import { deriveShortId } from '../session/short-id.js';
|
|
|
36
36
|
const SERVICE_PREFIX = 'agents-cli';
|
|
37
37
|
export const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
|
|
38
38
|
const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
|
|
39
|
+
/** Timeout for keychain-helper verbs that never raise a user prompt. */
|
|
40
|
+
const KEYCHAIN_SILENT_TIMEOUT_MS = 8_000;
|
|
41
|
+
/** Timeout for verbs that may raise Touch ID / password auth UI. */
|
|
42
|
+
const KEYCHAIN_INTERACTIVE_TIMEOUT_MS = 60_000;
|
|
43
|
+
/**
|
|
44
|
+
* Thrown when a keychain helper / security spawnSync is killed because it
|
|
45
|
+
* exceeded its timeout. A wedged coreauthd / LocalAuthentication dialog can hang
|
|
46
|
+
* the parent forever; this makes the failure explicit and arms the read back-off.
|
|
47
|
+
*/
|
|
48
|
+
export class KeychainHelperTimeoutError extends Error {
|
|
49
|
+
bin;
|
|
50
|
+
args;
|
|
51
|
+
constructor(bin, args) {
|
|
52
|
+
super(`keychain helper timed out (${bin} ${args.join(' ')}) — keychain locked / ` +
|
|
53
|
+
`LocalAuthentication unresponsive — retry; if it persists, lock/unlock the screen or reboot`);
|
|
54
|
+
this.bin = bin;
|
|
55
|
+
this.args = args;
|
|
56
|
+
this.name = 'KeychainHelperTimeoutError';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let keychainDaemonBootEnabled = true;
|
|
60
|
+
let keychainDaemonBootAttempted = false;
|
|
61
|
+
/** Test seam: suppress the side-effect daemon boot in unit tests. */
|
|
62
|
+
export function setKeychainDaemonBootForTest(enabled) {
|
|
63
|
+
keychainDaemonBootEnabled = enabled;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Single wrapper for every keychain-helper (and /usr/bin/security) spawnSync.
|
|
67
|
+
* Applies a hard timeout + SIGKILL so a wedged coreauthd can never hang the
|
|
68
|
+
* parent process. Throws {@link KeychainHelperTimeoutError} when the child is
|
|
69
|
+
* killed by the timeout. Also boots the daemon once per process so the reaper
|
|
70
|
+
* can clean up any stuck helpers this or prior invocations left behind.
|
|
71
|
+
*/
|
|
72
|
+
function spawnKeychainHelper(bin, args, opts, timeoutMs) {
|
|
73
|
+
if (keychainDaemonBootEnabled && !keychainDaemonBootAttempted) {
|
|
74
|
+
keychainDaemonBootAttempted = true;
|
|
75
|
+
// Fire-and-forget: daemon start must not block the foreground secrets op.
|
|
76
|
+
import('../daemon.js')
|
|
77
|
+
.then(({ ensureDaemonStarted }) => ensureDaemonStarted())
|
|
78
|
+
.catch(() => { });
|
|
79
|
+
}
|
|
80
|
+
const result = spawnSync(bin, args, { ...opts, timeout: timeoutMs, killSignal: 'SIGKILL' });
|
|
81
|
+
if (result.signal) {
|
|
82
|
+
throw new KeychainHelperTimeoutError(bin, args);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
/** Test seam: exercise the timeout wrapper with an arbitrary binary. */
|
|
87
|
+
export function spawnKeychainHelperForTest(bin, args, opts, timeoutMs) {
|
|
88
|
+
return spawnKeychainHelper(bin, args, opts, timeoutMs);
|
|
89
|
+
}
|
|
39
90
|
const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
|
|
40
91
|
/** Parse a bundle value into either a literal string or a typed secret ref. */
|
|
41
92
|
export function parseBundleValue(raw) {
|
|
@@ -725,14 +776,14 @@ export function hasKeychainToken(item) {
|
|
|
725
776
|
if (isWindows())
|
|
726
777
|
return windowsBackend.has(item);
|
|
727
778
|
if (!isOurItem(item)) {
|
|
728
|
-
return
|
|
779
|
+
return spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
|
|
729
780
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
730
|
-
}).status === 0;
|
|
781
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
|
|
731
782
|
}
|
|
732
783
|
const bin = getKeychainHelperPath();
|
|
733
|
-
return
|
|
784
|
+
return spawnKeychainHelper(bin, ['has', item, os.userInfo().username], {
|
|
734
785
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
735
|
-
}).status === 0;
|
|
786
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
|
|
736
787
|
}
|
|
737
788
|
/**
|
|
738
789
|
* The detector the raw-read storm guard consults, overridable so tests on any
|
|
@@ -808,9 +859,9 @@ export function getKeychainToken(item, context = {}) {
|
|
|
808
859
|
if (isWindows())
|
|
809
860
|
return windowsBackend.get(item);
|
|
810
861
|
if (!isOurItem(item)) {
|
|
811
|
-
const sec =
|
|
862
|
+
const sec = spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
|
|
812
863
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
813
|
-
});
|
|
864
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
814
865
|
if (sec.status === 0) {
|
|
815
866
|
const token = sec.stdout?.toString().trim();
|
|
816
867
|
if (token) {
|
|
@@ -821,15 +872,24 @@ export function getKeychainToken(item, context = {}) {
|
|
|
821
872
|
throw new Error(`Keychain item '${requested}' not found.`);
|
|
822
873
|
}
|
|
823
874
|
const bin = getKeychainHelperPath();
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
875
|
+
let result;
|
|
876
|
+
try {
|
|
877
|
+
result = spawnKeychainHelper(bin, ['get', item, os.userInfo().username], {
|
|
878
|
+
env: {
|
|
879
|
+
...process.env,
|
|
880
|
+
AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
|
|
881
|
+
AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
|
|
882
|
+
AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
|
|
883
|
+
},
|
|
884
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
885
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
|
|
889
|
+
noteKeychainReadFailure(requested);
|
|
890
|
+
}
|
|
891
|
+
throw err;
|
|
892
|
+
}
|
|
833
893
|
// Exit 1 is a plain miss — no prompt was raised, so it opens no back-off.
|
|
834
894
|
if (result.status === 1)
|
|
835
895
|
throw new Error(`Keychain item '${requested}' not found.`);
|
|
@@ -913,19 +973,28 @@ export function getKeychainTokens(items, context = {}) {
|
|
|
913
973
|
return result;
|
|
914
974
|
}
|
|
915
975
|
const bin = getKeychainHelperPath();
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
976
|
+
let child;
|
|
977
|
+
try {
|
|
978
|
+
child = spawnKeychainHelper(bin, ['get-batch', os.userInfo().username, ...storageItems], {
|
|
979
|
+
env: {
|
|
980
|
+
...process.env,
|
|
981
|
+
AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
|
|
982
|
+
AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
|
|
983
|
+
AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
|
|
984
|
+
// The signed helper's own vocabulary is unchanged (it predates the rename
|
|
985
|
+
// and ships as a separately-versioned binary), so map to its legacy token.
|
|
986
|
+
AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
|
|
987
|
+
AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
|
|
988
|
+
},
|
|
989
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
990
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
991
|
+
}
|
|
992
|
+
catch (err) {
|
|
993
|
+
if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
|
|
994
|
+
noteKeychainReadFailure(backoffKey);
|
|
995
|
+
}
|
|
996
|
+
throw err;
|
|
997
|
+
}
|
|
929
998
|
if (child.status !== 0 && !context.silentNoAcl)
|
|
930
999
|
noteKeychainReadFailure(backoffKey);
|
|
931
1000
|
if (child.status === 4) {
|
|
@@ -1063,7 +1132,7 @@ export function setKeychainToken(item, value, opts) {
|
|
|
1063
1132
|
// controlling terminal, so readpassphrase always falls back to our piped
|
|
1064
1133
|
// stdin. Without it, an interactive `agents view` (refreshing+saving a Claude
|
|
1065
1134
|
// OAuth token) or `agents secrets add` pops a keychain password sheet.
|
|
1066
|
-
const sec =
|
|
1135
|
+
const sec = spawnKeychainHelper('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value), KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1067
1136
|
if (sec.status !== 0) {
|
|
1068
1137
|
const msg = sec.stderr?.toString().trim();
|
|
1069
1138
|
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
@@ -1076,10 +1145,10 @@ export function setKeychainToken(item, value, opts) {
|
|
|
1076
1145
|
// re-notarized helper; an older pinned helper dies with "Unknown command:
|
|
1077
1146
|
// set-no-acl" (exit 2), surfaced below — never a silent ACL'd downgrade.
|
|
1078
1147
|
const helperCmd = opts?.noAcl ? 'set-no-acl' : 'set';
|
|
1079
|
-
const result =
|
|
1148
|
+
const result = spawnKeychainHelper(bin, [helperCmd, item, os.userInfo().username], {
|
|
1080
1149
|
input: value,
|
|
1081
1150
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1082
|
-
});
|
|
1151
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1083
1152
|
if (result.status !== 0) {
|
|
1084
1153
|
const msg = result.stderr?.toString().trim();
|
|
1085
1154
|
if (opts?.noAcl && /unknown command/i.test(msg ?? '')) {
|
|
@@ -1106,9 +1175,9 @@ export function deleteKeychainToken(item) {
|
|
|
1106
1175
|
if (isWindows())
|
|
1107
1176
|
return windowsBackend.delete(item);
|
|
1108
1177
|
const bin = getKeychainHelperPath();
|
|
1109
|
-
const deleted =
|
|
1178
|
+
const deleted = spawnKeychainHelper(bin, ['delete', item, os.userInfo().username], {
|
|
1110
1179
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1111
|
-
}).status === 0;
|
|
1180
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
|
|
1112
1181
|
// A deleted item must fail its next read as plain "not found", not with a
|
|
1113
1182
|
// stale back-off error left over from a pre-delete cancel.
|
|
1114
1183
|
if (deleted)
|
|
@@ -1149,9 +1218,9 @@ export function listKeychainItems(prefix) {
|
|
|
1149
1218
|
if (isWindows())
|
|
1150
1219
|
return apply(windowsBackend.list(mapped.prefix));
|
|
1151
1220
|
const bin = getKeychainHelperPath();
|
|
1152
|
-
const result =
|
|
1221
|
+
const result = spawnKeychainHelper(bin, ['list', mapped.prefix], {
|
|
1153
1222
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1154
|
-
});
|
|
1223
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1155
1224
|
if (result.status !== 0) {
|
|
1156
1225
|
const msg = result.stderr?.toString().trim();
|
|
1157
1226
|
throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
|
|
@@ -1176,9 +1245,9 @@ export function listLegacyKeychainItems(prefix) {
|
|
|
1176
1245
|
if (isLinux())
|
|
1177
1246
|
return [];
|
|
1178
1247
|
const bin = getKeychainHelperPath();
|
|
1179
|
-
const result =
|
|
1248
|
+
const result = spawnKeychainHelper(bin, ['list-legacy', prefix], {
|
|
1180
1249
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1181
|
-
});
|
|
1250
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1182
1251
|
if (result.status !== 0) {
|
|
1183
1252
|
const msg = result.stderr?.toString().trim();
|
|
1184
1253
|
throw new Error(msg || `Failed to enumerate legacy keychain items with prefix '${prefix}'.`);
|
|
@@ -1209,9 +1278,9 @@ export function listSyncedKeychainItems(prefix) {
|
|
|
1209
1278
|
if (isLinux() || isWindows())
|
|
1210
1279
|
return [];
|
|
1211
1280
|
const bin = getKeychainHelperPath();
|
|
1212
|
-
const result =
|
|
1281
|
+
const result = spawnKeychainHelper(bin, ['list-synced', prefix], {
|
|
1213
1282
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1214
|
-
});
|
|
1283
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1215
1284
|
if (result.status !== 0) {
|
|
1216
1285
|
const msg = result.stderr?.toString().trim();
|
|
1217
1286
|
throw new Error(msg || `Failed to enumerate iCloud keychain items with prefix '${prefix}'.`);
|
|
@@ -1237,9 +1306,9 @@ export function getSyncedKeychainTokens(items) {
|
|
|
1237
1306
|
if (isLinux() || isWindows())
|
|
1238
1307
|
return result;
|
|
1239
1308
|
const bin = getKeychainHelperPath();
|
|
1240
|
-
const child =
|
|
1309
|
+
const child = spawnKeychainHelper(bin, ['get-batch-synced', os.userInfo().username, ...items], {
|
|
1241
1310
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1242
|
-
});
|
|
1311
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1243
1312
|
if (child.status === 4) {
|
|
1244
1313
|
throw new Error(`Auth cancelled while reading ${items.length} iCloud keychain item(s).`);
|
|
1245
1314
|
}
|
|
@@ -1265,9 +1334,9 @@ export function deleteSyncedKeychainItem(item) {
|
|
|
1265
1334
|
if (isLinux() || isWindows())
|
|
1266
1335
|
return false;
|
|
1267
1336
|
const bin = getKeychainHelperPath();
|
|
1268
|
-
return
|
|
1337
|
+
return spawnKeychainHelper(bin, ['delete-synced', item, os.userInfo().username], {
|
|
1269
1338
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1270
|
-
}).status === 0;
|
|
1339
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
|
|
1271
1340
|
}
|
|
1272
1341
|
/**
|
|
1273
1342
|
* One-time upgrade for a keychain item that was written by a previous helper
|
|
@@ -1286,9 +1355,9 @@ export function migrateKeychainItem(item) {
|
|
|
1286
1355
|
if (isWindows())
|
|
1287
1356
|
return windowsBackend.has(item);
|
|
1288
1357
|
const bin = getKeychainHelperPath();
|
|
1289
|
-
const result =
|
|
1358
|
+
const result = spawnKeychainHelper(bin, ['migrate-acl', item, os.userInfo().username], {
|
|
1290
1359
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1291
|
-
});
|
|
1360
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1292
1361
|
if (result.status === 0)
|
|
1293
1362
|
return true;
|
|
1294
1363
|
if (result.status === 1)
|
|
@@ -1310,9 +1379,9 @@ export function listOrphanedKeychainItems(prefix) {
|
|
|
1310
1379
|
if (isLinux() || isWindows())
|
|
1311
1380
|
return [];
|
|
1312
1381
|
const bin = getKeychainHelperPath();
|
|
1313
|
-
const result =
|
|
1382
|
+
const result = spawnKeychainHelper(bin, ['list-orphans', prefix, os.userInfo().username], {
|
|
1314
1383
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1315
|
-
});
|
|
1384
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1316
1385
|
if (result.status !== 0) {
|
|
1317
1386
|
const msg = result.stderr?.toString().trim();
|
|
1318
1387
|
throw new Error(msg || `Failed to enumerate orphaned keychain items with prefix '${prefix}'.`);
|
|
@@ -1368,9 +1437,9 @@ export function migrateOrphanedKeychainItems(prefix) {
|
|
|
1368
1437
|
if (isLinux() || isWindows())
|
|
1369
1438
|
return [];
|
|
1370
1439
|
const bin = getKeychainHelperPath();
|
|
1371
|
-
const result =
|
|
1440
|
+
const result = spawnKeychainHelper(bin, ['migrate-orphans', prefix, os.userInfo().username], {
|
|
1372
1441
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1373
|
-
});
|
|
1442
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1374
1443
|
if (result.status === 4)
|
|
1375
1444
|
throw new Error('Touch ID cancelled during orphan migration.');
|
|
1376
1445
|
if (result.status !== 0) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaper for orphaned / wedged keychain-helper processes.
|
|
3
|
+
*
|
|
4
|
+
* macOS keychain-helper calls are synchronous spawnSync invocations. When
|
|
5
|
+
* coreauthd / LocalAuthentication hangs, the helper process (and sometimes its
|
|
6
|
+
* parent `agents` process) can pile up forever. This module detects two classes
|
|
7
|
+
* of stale process and kills them.
|
|
8
|
+
*
|
|
9
|
+
* The design splits cleanly into:
|
|
10
|
+
* - a pure planner ({@link planKeychainReap}) that is unit-testable without a
|
|
11
|
+
* real `ps` shell, and
|
|
12
|
+
* - an impure driver ({@link reapOrphanedKeychainProcesses}) that shells `ps`
|
|
13
|
+
* once per tick and executes kills through {@link killTree}.
|
|
14
|
+
*/
|
|
15
|
+
/** Elapsed grace before a helper whose parent exited is considered an orphan. */
|
|
16
|
+
export declare const ORPHAN_GRACE_SEC = 30;
|
|
17
|
+
/** Elapsed grace before a helper with a live parent is considered stuck. */
|
|
18
|
+
export declare const STUCK_GRACE_SEC = 90;
|
|
19
|
+
/** Snapshot of one process row from `ps`, fed into the pure planner. */
|
|
20
|
+
export interface KeychainProcessSnapshot {
|
|
21
|
+
pid: number;
|
|
22
|
+
ppid: number;
|
|
23
|
+
/** Elapsed seconds since the process started. */
|
|
24
|
+
elapsedSec: number;
|
|
25
|
+
/**
|
|
26
|
+
* Stable start-time fingerprint from {@link captureProcessStartTime}.
|
|
27
|
+
* `null` means "could not capture" — the planner must fail closed.
|
|
28
|
+
*/
|
|
29
|
+
startTime: string | null;
|
|
30
|
+
/** True when this process's executable path matches the installed helper. */
|
|
31
|
+
isHelper: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Tracked state for a stuck `agents` parent that has a live helper child.
|
|
35
|
+
* Keyed by parent PID. The two-sweep debounce avoids acting on a racy `ps`
|
|
36
|
+
* snapshot; the `stage` field drives child-first kill then parent escalation.
|
|
37
|
+
*/
|
|
38
|
+
export interface StuckParentCandidate {
|
|
39
|
+
pid: number;
|
|
40
|
+
startTime: string | null;
|
|
41
|
+
helperPid: number;
|
|
42
|
+
helperStartTime: string | null;
|
|
43
|
+
firstSeenAt: number;
|
|
44
|
+
stage: 'watch' | 'escalate';
|
|
45
|
+
}
|
|
46
|
+
/** Result of one planning pass. */
|
|
47
|
+
export interface ReapPlan {
|
|
48
|
+
/** PIDs that should be killed this tick. */
|
|
49
|
+
kill: number[];
|
|
50
|
+
/** Candidates to carry forward to the next sweep. */
|
|
51
|
+
nextCandidates: Map<number, StuckParentCandidate>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Pure predicate: decide which processes to kill given a `ps`-like snapshot.
|
|
55
|
+
*
|
|
56
|
+
* Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
|
|
57
|
+
* a side-effect-free classifier that the impure driver shells `ps` for. Two
|
|
58
|
+
* conservative reap classes:
|
|
59
|
+
*
|
|
60
|
+
* 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
|
|
61
|
+
* {@link ORPHAN_GRACE_SEC}.
|
|
62
|
+
* 2. Stuck `agents` parent: helper child alive longer than
|
|
63
|
+
* {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
|
|
64
|
+
* second consecutive sweep with the same PID + startTime, parent killed on
|
|
65
|
+
* the third sweep if the helper child is still present.
|
|
66
|
+
*
|
|
67
|
+
* Never reaps a process whose start time could not be captured, whose path does
|
|
68
|
+
* not match the helper, or whose parent is no longer in the snapshot.
|
|
69
|
+
*/
|
|
70
|
+
export declare function planKeychainReap(snapshots: KeychainProcessSnapshot[], now: number, prevCandidates: ReadonlyMap<number, StuckParentCandidate>): ReapPlan;
|
|
71
|
+
/** Test seam: reset the persisted candidate state. */
|
|
72
|
+
export declare function resetKeychainReaperCandidatesForTest(): void;
|
|
73
|
+
/**
|
|
74
|
+
* Impure driver: snapshot all processes once with `ps`, plan the reap, then
|
|
75
|
+
* execute kills through {@link killTree}.
|
|
76
|
+
*
|
|
77
|
+
* Path-matching uses the full helper path (proc_pidpath-style), not just the
|
|
78
|
+
* executable name, so an unrelated binary named "Agents CLI" is never targeted.
|
|
79
|
+
*
|
|
80
|
+
* Returns on non-darwin platforms without shelling anything — the helper only
|
|
81
|
+
* exists on macOS.
|
|
82
|
+
*/
|
|
83
|
+
export declare function reapOrphanedKeychainProcesses(): {
|
|
84
|
+
reaped: number;
|
|
85
|
+
details: string[];
|
|
86
|
+
plan: ReapPlan;
|
|
87
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaper for orphaned / wedged keychain-helper processes.
|
|
3
|
+
*
|
|
4
|
+
* macOS keychain-helper calls are synchronous spawnSync invocations. When
|
|
5
|
+
* coreauthd / LocalAuthentication hangs, the helper process (and sometimes its
|
|
6
|
+
* parent `agents` process) can pile up forever. This module detects two classes
|
|
7
|
+
* of stale process and kills them.
|
|
8
|
+
*
|
|
9
|
+
* The design splits cleanly into:
|
|
10
|
+
* - a pure planner ({@link planKeychainReap}) that is unit-testable without a
|
|
11
|
+
* real `ps` shell, and
|
|
12
|
+
* - an impure driver ({@link reapOrphanedKeychainProcesses}) that shells `ps`
|
|
13
|
+
* once per tick and executes kills through {@link killTree}.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'child_process';
|
|
16
|
+
import { killTree, captureProcessStartTime } from '../platform/process.js';
|
|
17
|
+
import { getKeychainHelperPath } from './install-helper.js';
|
|
18
|
+
/** Elapsed grace before a helper whose parent exited is considered an orphan. */
|
|
19
|
+
export const ORPHAN_GRACE_SEC = 30;
|
|
20
|
+
/** Elapsed grace before a helper with a live parent is considered stuck. */
|
|
21
|
+
export const STUCK_GRACE_SEC = 90;
|
|
22
|
+
/**
|
|
23
|
+
* Pure predicate: decide which processes to kill given a `ps`-like snapshot.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors the shape of {@link isExpiredPoolStray} in `lib/crabbox/lease.ts`:
|
|
26
|
+
* a side-effect-free classifier that the impure driver shells `ps` for. Two
|
|
27
|
+
* conservative reap classes:
|
|
28
|
+
*
|
|
29
|
+
* 1. Orphaned helper: PPID == 1, path-matches the helper, alive longer than
|
|
30
|
+
* {@link ORPHAN_GRACE_SEC}.
|
|
31
|
+
* 2. Stuck `agents` parent: helper child alive longer than
|
|
32
|
+
* {@link STUCK_GRACE_SEC}. Recorded on first sight, child killed on the
|
|
33
|
+
* second consecutive sweep with the same PID + startTime, parent killed on
|
|
34
|
+
* the third sweep if the helper child is still present.
|
|
35
|
+
*
|
|
36
|
+
* Never reaps a process whose start time could not be captured, whose path does
|
|
37
|
+
* not match the helper, or whose parent is no longer in the snapshot.
|
|
38
|
+
*/
|
|
39
|
+
export function planKeychainReap(snapshots, now, prevCandidates) {
|
|
40
|
+
const pidMap = new Map(snapshots.map((s) => [s.pid, s]));
|
|
41
|
+
const kill = [];
|
|
42
|
+
const nextCandidates = new Map();
|
|
43
|
+
for (const s of snapshots) {
|
|
44
|
+
if (!s.isHelper)
|
|
45
|
+
continue;
|
|
46
|
+
if (s.ppid === 1) {
|
|
47
|
+
// Orphaned helper: parent is init/launchd. Fail closed if we can't prove
|
|
48
|
+
// the process identity with a start-time fingerprint.
|
|
49
|
+
if (s.elapsedSec <= ORPHAN_GRACE_SEC)
|
|
50
|
+
continue;
|
|
51
|
+
if (s.startTime == null)
|
|
52
|
+
continue;
|
|
53
|
+
kill.push(s.pid);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Stuck agents: a helper with a live parent that has been wedged for longer
|
|
57
|
+
// than the interactive timeout. The parent must still exist in the snapshot.
|
|
58
|
+
if (s.elapsedSec <= STUCK_GRACE_SEC)
|
|
59
|
+
continue;
|
|
60
|
+
const parent = pidMap.get(s.ppid);
|
|
61
|
+
if (!parent)
|
|
62
|
+
continue;
|
|
63
|
+
if (s.startTime == null || parent.startTime == null)
|
|
64
|
+
continue;
|
|
65
|
+
const prev = prevCandidates.get(parent.pid);
|
|
66
|
+
if (prev &&
|
|
67
|
+
prev.helperPid === s.pid &&
|
|
68
|
+
prev.helperStartTime === s.startTime &&
|
|
69
|
+
prev.startTime === parent.startTime) {
|
|
70
|
+
if (prev.stage === 'watch') {
|
|
71
|
+
// Second sweep: kill the child first so the parent's spawnSync returns.
|
|
72
|
+
// Keep the parent candidate at stage 'escalate' for the next sweep.
|
|
73
|
+
kill.push(s.pid);
|
|
74
|
+
nextCandidates.set(parent.pid, { ...prev, stage: 'escalate' });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Third sweep: child did not free the parent, so the parent itself is wedged.
|
|
78
|
+
kill.push(parent.pid);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// First sight of this (parent, helper) pair — record it and wait.
|
|
82
|
+
nextCandidates.set(parent.pid, {
|
|
83
|
+
pid: parent.pid,
|
|
84
|
+
startTime: parent.startTime,
|
|
85
|
+
helperPid: s.pid,
|
|
86
|
+
helperStartTime: s.startTime,
|
|
87
|
+
firstSeenAt: now,
|
|
88
|
+
stage: 'watch',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return { kill, nextCandidates };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Parse one `ps` output line.
|
|
95
|
+
*
|
|
96
|
+
* Expected format from `ps -ax -o pid=,ppid=,etimes=,command=`:
|
|
97
|
+
* "<pid> <ppid> <etimes> <command...>"
|
|
98
|
+
* The command field is the remainder of the line and may contain spaces.
|
|
99
|
+
*/
|
|
100
|
+
function parsePsLine(line) {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (!trimmed)
|
|
103
|
+
return null;
|
|
104
|
+
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/);
|
|
105
|
+
if (!m)
|
|
106
|
+
return null;
|
|
107
|
+
const pid = parseInt(m[1], 10);
|
|
108
|
+
const ppid = parseInt(m[2], 10);
|
|
109
|
+
const elapsedSec = parseInt(m[3], 10);
|
|
110
|
+
const command = m[4];
|
|
111
|
+
if (isNaN(pid) || isNaN(ppid) || isNaN(elapsedSec))
|
|
112
|
+
return null;
|
|
113
|
+
return { pid, ppid, elapsedSec, command };
|
|
114
|
+
}
|
|
115
|
+
/** Module-state for the two-sweep stuck-parent debounce. */
|
|
116
|
+
let stuckParentCandidates = new Map();
|
|
117
|
+
/** Test seam: reset the persisted candidate state. */
|
|
118
|
+
export function resetKeychainReaperCandidatesForTest() {
|
|
119
|
+
stuckParentCandidates = new Map();
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Impure driver: snapshot all processes once with `ps`, plan the reap, then
|
|
123
|
+
* execute kills through {@link killTree}.
|
|
124
|
+
*
|
|
125
|
+
* Path-matching uses the full helper path (proc_pidpath-style), not just the
|
|
126
|
+
* executable name, so an unrelated binary named "Agents CLI" is never targeted.
|
|
127
|
+
*
|
|
128
|
+
* Returns on non-darwin platforms without shelling anything — the helper only
|
|
129
|
+
* exists on macOS.
|
|
130
|
+
*/
|
|
131
|
+
export function reapOrphanedKeychainProcesses() {
|
|
132
|
+
const details = [];
|
|
133
|
+
if (process.platform !== 'darwin') {
|
|
134
|
+
return { reaped: 0, details, plan: { kill: [], nextCandidates: new Map() } };
|
|
135
|
+
}
|
|
136
|
+
let helperPath;
|
|
137
|
+
try {
|
|
138
|
+
helperPath = getKeychainHelperPath();
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
return { reaped: 0, details: [`helper path resolution failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
|
|
142
|
+
}
|
|
143
|
+
let out;
|
|
144
|
+
try {
|
|
145
|
+
out = execFileSync('ps', ['-ax', '-o', 'pid=,ppid=,etimes=,command='], {
|
|
146
|
+
encoding: 'utf-8',
|
|
147
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
return { reaped: 0, details: [`ps failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
|
|
152
|
+
}
|
|
153
|
+
const snapshots = [];
|
|
154
|
+
for (const line of out.split('\n')) {
|
|
155
|
+
const parsed = parsePsLine(line);
|
|
156
|
+
if (!parsed)
|
|
157
|
+
continue;
|
|
158
|
+
const { pid, ppid, elapsedSec, command } = parsed;
|
|
159
|
+
// Exact path-match: the helper invocation's command line begins with the
|
|
160
|
+
// absolute helper path, followed by a space and its arguments (or nothing).
|
|
161
|
+
const isHelper = command === helperPath || command.startsWith(`${helperPath} `);
|
|
162
|
+
snapshots.push({
|
|
163
|
+
pid,
|
|
164
|
+
ppid,
|
|
165
|
+
elapsedSec,
|
|
166
|
+
startTime: captureProcessStartTime(pid),
|
|
167
|
+
isHelper,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const plan = planKeychainReap(snapshots, Date.now(), stuckParentCandidates);
|
|
171
|
+
stuckParentCandidates = plan.nextCandidates;
|
|
172
|
+
for (const pid of plan.kill) {
|
|
173
|
+
if (pid === process.pid)
|
|
174
|
+
continue; // never self-terminate the reaper
|
|
175
|
+
try {
|
|
176
|
+
killTree(pid);
|
|
177
|
+
details.push(`killed pid ${pid}`);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
details.push(`failed to kill pid ${pid}: ${err.message}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { reaped: plan.kill.length, details, plan };
|
|
184
|
+
}
|
|
@@ -177,3 +177,32 @@ export declare function keychainWriteFailureMessage(host: string, bundle: string
|
|
|
177
177
|
export declare function verifyRemoteKeychainPush(target: string, bundle: string, pushedKeys: string[], opts?: {
|
|
178
178
|
osLookupName?: string;
|
|
179
179
|
}): RemoteKeychainWriteVerification;
|
|
180
|
+
/**
|
|
181
|
+
* The `bash -lc` command + stdin payload that drives a **file-backed** remote
|
|
182
|
+
* import for `secrets export --host … --remote-backend file`.
|
|
183
|
+
*
|
|
184
|
+
* The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
|
|
185
|
+
* unset the remote `agents secrets import --backend file` auto-provisions the
|
|
186
|
+
* remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
|
|
187
|
+
* reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
|
|
188
|
+
* OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
|
|
189
|
+
* key the bundle off-disk under a shared secret):
|
|
190
|
+
*
|
|
191
|
+
* - **No passphrase** → the remote runs `import … --backend file` directly with
|
|
192
|
+
* ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
|
|
193
|
+
* prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
|
|
194
|
+
* machine-local key path → headless reads.
|
|
195
|
+
* - **Passphrase set** → forward it as the FIRST stdin line, consumed by
|
|
196
|
+
* `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
|
|
197
|
+
* then the .env. The remote then keys the bundle under that shared passphrase.
|
|
198
|
+
*
|
|
199
|
+
* Pure — no I/O — so the exact command string and stdin ordering are unit-testable
|
|
200
|
+
* against the SSH boundary the same way `remoteSecretsRaw` is.
|
|
201
|
+
*/
|
|
202
|
+
export declare function buildRemoteFileImportCommand(bundle: string, dotenv: string, opts?: {
|
|
203
|
+
passphrase?: string;
|
|
204
|
+
force?: boolean;
|
|
205
|
+
}): {
|
|
206
|
+
remoteCmd: string;
|
|
207
|
+
input: string;
|
|
208
|
+
};
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
16
|
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
17
|
*/
|
|
18
|
-
import { sshExec, sshStream, assertValidSshTarget } from '../ssh-exec.js';
|
|
18
|
+
import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
19
|
import { resolveHost } from '../hosts/registry.js';
|
|
20
20
|
import { emitSecretAudit } from './audit.js';
|
|
21
21
|
import { sshTargetFor } from '../hosts/types.js';
|
|
@@ -348,3 +348,39 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
|
|
|
348
348
|
}
|
|
349
349
|
return evaluateKeychainWriteVerification(pushedKeys, { ok: true, keys });
|
|
350
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* The `bash -lc` command + stdin payload that drives a **file-backed** remote
|
|
353
|
+
* import for `secrets export --host … --remote-backend file`.
|
|
354
|
+
*
|
|
355
|
+
* The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
|
|
356
|
+
* unset the remote `agents secrets import --backend file` auto-provisions the
|
|
357
|
+
* remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
|
|
358
|
+
* reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
|
|
359
|
+
* OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
|
|
360
|
+
* key the bundle off-disk under a shared secret):
|
|
361
|
+
*
|
|
362
|
+
* - **No passphrase** → the remote runs `import … --backend file` directly with
|
|
363
|
+
* ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
|
|
364
|
+
* prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
|
|
365
|
+
* machine-local key path → headless reads.
|
|
366
|
+
* - **Passphrase set** → forward it as the FIRST stdin line, consumed by
|
|
367
|
+
* `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
|
|
368
|
+
* then the .env. The remote then keys the bundle under that shared passphrase.
|
|
369
|
+
*
|
|
370
|
+
* Pure — no I/O — so the exact command string and stdin ordering are unit-testable
|
|
371
|
+
* against the SSH boundary the same way `remoteSecretsRaw` is.
|
|
372
|
+
*/
|
|
373
|
+
export function buildRemoteFileImportCommand(bundle, dotenv, opts = {}) {
|
|
374
|
+
const force = opts.force ? ' --force' : '';
|
|
375
|
+
const importCmd = `agents secrets import ${shellQuote(bundle)} --from - --backend file${force}`;
|
|
376
|
+
const passphrase = opts.passphrase ?? '';
|
|
377
|
+
if (passphrase) {
|
|
378
|
+
// Opt-in shared passphrase: read it off the FIRST stdin line, export it, then
|
|
379
|
+
// let `import --from -` read the .env remainder.
|
|
380
|
+
const remoteAgents = `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ${importCmd}`;
|
|
381
|
+
return { remoteCmd: `bash -lc ${shellQuote(remoteAgents)}`, input: `${passphrase}\n${dotenv}` };
|
|
382
|
+
}
|
|
383
|
+
// No passphrase: NO prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the
|
|
384
|
+
// remote, so the file store falls back to its machine-local key (headless).
|
|
385
|
+
return { remoteCmd: `bash -lc ${shellQuote(importCmd)}`, input: dotenv };
|
|
386
|
+
}
|