@phnx-labs/agents-cli 1.22.22 → 1.22.24
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 +402 -0
- package/README.md +13 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/attach.d.ts +2 -0
- package/dist/commands/attach.js +32 -7
- package/dist/commands/defaults.js +2 -0
- package/dist/commands/doctor.js +20 -7
- package/dist/commands/exec.js +100 -34
- package/dist/commands/feed.d.ts +18 -0
- package/dist/commands/feed.js +44 -1
- package/dist/commands/focus.d.ts +108 -5
- package/dist/commands/focus.js +455 -32
- package/dist/commands/fork.d.ts +20 -2
- package/dist/commands/fork.js +91 -64
- package/dist/commands/go.d.ts +39 -1
- package/dist/commands/go.js +112 -7
- 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/insights.js +6 -2
- package/dist/commands/inspect.js +39 -5
- package/dist/commands/menubar.js +6 -1
- package/dist/commands/models.js +1 -0
- package/dist/commands/modes.d.ts +12 -0
- package/dist/commands/modes.js +147 -0
- package/dist/commands/resume.js +10 -3
- package/dist/commands/secrets-sync.js +11 -13
- package/dist/commands/secrets.d.ts +2 -0
- package/dist/commands/secrets.js +79 -47
- package/dist/commands/sessions-browser.d.ts +35 -0
- package/dist/commands/sessions-browser.js +140 -14
- package/dist/commands/sessions-resume.d.ts +23 -3
- package/dist/commands/sessions-resume.js +73 -19
- package/dist/commands/sessions.d.ts +91 -5
- package/dist/commands/sessions.js +359 -125
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/sync.js +246 -42
- package/dist/commands/view.js +2 -0
- package/dist/commands/watchdog.js +13 -2
- package/dist/index.js +2 -1
- package/dist/lib/agent-modes.d.ts +49 -0
- package/dist/lib/agent-modes.js +70 -0
- 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/doctor-diff.d.ts +3 -0
- package/dist/lib/doctor-diff.js +15 -13
- package/dist/lib/event-stream.d.ts +3 -1
- package/dist/lib/event-stream.js +14 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +175 -62
- package/dist/lib/feed-broadcast.d.ts +1 -20
- package/dist/lib/feed-broadcast.js +31 -1
- package/dist/lib/hooks/cache.js +36 -3
- package/dist/lib/hooks.d.ts +19 -7
- package/dist/lib/hooks.js +100 -40
- package/dist/lib/hosts/session-index.d.ts +4 -0
- package/dist/lib/hosts/session-index.js +7 -0
- package/dist/lib/manifest.d.ts +12 -2
- package/dist/lib/manifest.js +60 -5
- package/dist/lib/mcp.js +44 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +52 -2
- package/dist/lib/menubar/install-menubar.js +128 -6
- 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/refresh.d.ts +5 -0
- package/dist/lib/refresh.js +37 -33
- package/dist/lib/remote-agents-json.d.ts +29 -1
- package/dist/lib/remote-agents-json.js +47 -10
- package/dist/lib/resource-inventory.d.ts +79 -0
- package/dist/lib/resource-inventory.js +122 -0
- 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/resources.js +8 -5
- package/dist/lib/run-defaults.d.ts +2 -0
- package/dist/lib/run-defaults.js +23 -2
- package/dist/lib/runner.js +50 -36
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +157 -65
- package/dist/lib/secrets/filestore.d.ts +5 -3
- package/dist/lib/secrets/filestore.js +12 -8
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +148 -53
- package/dist/lib/secrets/reaper.d.ts +97 -0
- package/dist/lib/secrets/reaper.js +219 -0
- package/dist/lib/secrets/remote.d.ts +29 -0
- package/dist/lib/secrets/remote.js +37 -1
- package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
- package/dist/lib/secrets/sync-passphrase.js +78 -0
- 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/recovery.d.ts +37 -0
- package/dist/lib/session/recovery.js +95 -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 +62 -8
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +5 -4
- package/dist/lib/tmux/session.d.ts +4 -2
- package/dist/lib/tmux/session.js +5 -5
- package/dist/lib/types.d.ts +3 -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
|
@@ -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,28 @@ 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
|
-
|
|
784
|
+
const r = spawnKeychainHelper(bin, ['has', item, os.userInfo().username], {
|
|
734
785
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
735
|
-
})
|
|
786
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
787
|
+
// The helper exits 0 = present (incl. errSecInteractionNotAllowed — a locked or
|
|
788
|
+
// biometry-ACL'd item still EXISTS), 1 = genuinely absent. Any other outcome
|
|
789
|
+
// (bad args, helper failure, spawn error) means the keychain could not be
|
|
790
|
+
// reached — and a false "absent" silently disarms every destructive-write guard
|
|
791
|
+
// that calls this (see the docblock), so it MUST fail loud rather than answer
|
|
792
|
+
// "no" (RUSH-2235). Timeouts already throw inside spawnKeychainHelper.
|
|
793
|
+
if (r.status === 0)
|
|
794
|
+
return true;
|
|
795
|
+
if (r.status === 1)
|
|
796
|
+
return false;
|
|
797
|
+
const stderr = r.stderr?.toString().trim();
|
|
798
|
+
throw new Error(stderr ||
|
|
799
|
+
`keychain existence check for '${item}' failed (helper exit ${r.status ?? 'null'}` +
|
|
800
|
+
`${r.error ? `: ${r.error.message}` : ''}) — keychain unreachable, not a proven absence.`);
|
|
736
801
|
}
|
|
737
802
|
/**
|
|
738
803
|
* The detector the raw-read storm guard consults, overridable so tests on any
|
|
@@ -808,9 +873,9 @@ export function getKeychainToken(item, context = {}) {
|
|
|
808
873
|
if (isWindows())
|
|
809
874
|
return windowsBackend.get(item);
|
|
810
875
|
if (!isOurItem(item)) {
|
|
811
|
-
const sec =
|
|
876
|
+
const sec = spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
|
|
812
877
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
813
|
-
});
|
|
878
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
814
879
|
if (sec.status === 0) {
|
|
815
880
|
const token = sec.stdout?.toString().trim();
|
|
816
881
|
if (token) {
|
|
@@ -821,15 +886,24 @@ export function getKeychainToken(item, context = {}) {
|
|
|
821
886
|
throw new Error(`Keychain item '${requested}' not found.`);
|
|
822
887
|
}
|
|
823
888
|
const bin = getKeychainHelperPath();
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
889
|
+
let result;
|
|
890
|
+
try {
|
|
891
|
+
result = spawnKeychainHelper(bin, ['get', item, os.userInfo().username], {
|
|
892
|
+
env: {
|
|
893
|
+
...process.env,
|
|
894
|
+
AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
|
|
895
|
+
AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
|
|
896
|
+
AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
|
|
897
|
+
},
|
|
898
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
899
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
900
|
+
}
|
|
901
|
+
catch (err) {
|
|
902
|
+
if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
|
|
903
|
+
noteKeychainReadFailure(requested);
|
|
904
|
+
}
|
|
905
|
+
throw err;
|
|
906
|
+
}
|
|
833
907
|
// Exit 1 is a plain miss — no prompt was raised, so it opens no back-off.
|
|
834
908
|
if (result.status === 1)
|
|
835
909
|
throw new Error(`Keychain item '${requested}' not found.`);
|
|
@@ -913,19 +987,28 @@ export function getKeychainTokens(items, context = {}) {
|
|
|
913
987
|
return result;
|
|
914
988
|
}
|
|
915
989
|
const bin = getKeychainHelperPath();
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
990
|
+
let child;
|
|
991
|
+
try {
|
|
992
|
+
child = spawnKeychainHelper(bin, ['get-batch', os.userInfo().username, ...storageItems], {
|
|
993
|
+
env: {
|
|
994
|
+
...process.env,
|
|
995
|
+
AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
|
|
996
|
+
AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
|
|
997
|
+
AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
|
|
998
|
+
// The signed helper's own vocabulary is unchanged (it predates the rename
|
|
999
|
+
// and ships as a separately-versioned binary), so map to its legacy token.
|
|
1000
|
+
AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
|
|
1001
|
+
AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
|
|
1002
|
+
},
|
|
1003
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1004
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1005
|
+
}
|
|
1006
|
+
catch (err) {
|
|
1007
|
+
if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
|
|
1008
|
+
noteKeychainReadFailure(backoffKey);
|
|
1009
|
+
}
|
|
1010
|
+
throw err;
|
|
1011
|
+
}
|
|
929
1012
|
if (child.status !== 0 && !context.silentNoAcl)
|
|
930
1013
|
noteKeychainReadFailure(backoffKey);
|
|
931
1014
|
if (child.status === 4) {
|
|
@@ -1063,7 +1146,7 @@ export function setKeychainToken(item, value, opts) {
|
|
|
1063
1146
|
// controlling terminal, so readpassphrase always falls back to our piped
|
|
1064
1147
|
// stdin. Without it, an interactive `agents view` (refreshing+saving a Claude
|
|
1065
1148
|
// OAuth token) or `agents secrets add` pops a keychain password sheet.
|
|
1066
|
-
const sec =
|
|
1149
|
+
const sec = spawnKeychainHelper('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value), KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1067
1150
|
if (sec.status !== 0) {
|
|
1068
1151
|
const msg = sec.stderr?.toString().trim();
|
|
1069
1152
|
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
@@ -1076,10 +1159,10 @@ export function setKeychainToken(item, value, opts) {
|
|
|
1076
1159
|
// re-notarized helper; an older pinned helper dies with "Unknown command:
|
|
1077
1160
|
// set-no-acl" (exit 2), surfaced below — never a silent ACL'd downgrade.
|
|
1078
1161
|
const helperCmd = opts?.noAcl ? 'set-no-acl' : 'set';
|
|
1079
|
-
const result =
|
|
1162
|
+
const result = spawnKeychainHelper(bin, [helperCmd, item, os.userInfo().username], {
|
|
1080
1163
|
input: value,
|
|
1081
1164
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1082
|
-
});
|
|
1165
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1083
1166
|
if (result.status !== 0) {
|
|
1084
1167
|
const msg = result.stderr?.toString().trim();
|
|
1085
1168
|
if (opts?.noAcl && /unknown command/i.test(msg ?? '')) {
|
|
@@ -1106,14 +1189,26 @@ export function deleteKeychainToken(item) {
|
|
|
1106
1189
|
if (isWindows())
|
|
1107
1190
|
return windowsBackend.delete(item);
|
|
1108
1191
|
const bin = getKeychainHelperPath();
|
|
1109
|
-
const
|
|
1192
|
+
const r = spawnKeychainHelper(bin, ['delete', item, os.userInfo().username], {
|
|
1110
1193
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1111
|
-
})
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
|
|
1194
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1195
|
+
// The helper exits 0 = an item was removed from at least one keychain, 1 =
|
|
1196
|
+
// nothing to delete (genuinely absent). Any other outcome means the keychain
|
|
1197
|
+
// could not be reached; like hasKeychainToken this must fail loud rather than
|
|
1198
|
+
// report a false "nothing was there" (RUSH-2235) — a swallowed failure lets a
|
|
1199
|
+
// rename/purge believe it cleared a name it did not.
|
|
1200
|
+
if (r.status === 0) {
|
|
1201
|
+
// A deleted item must fail its next read as plain "not found", not with a
|
|
1202
|
+
// stale back-off error left over from a pre-delete cancel.
|
|
1115
1203
|
clearKeychainReadBackoff(requested);
|
|
1116
|
-
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
if (r.status === 1)
|
|
1207
|
+
return false;
|
|
1208
|
+
const stderr = r.stderr?.toString().trim();
|
|
1209
|
+
throw new Error(stderr ||
|
|
1210
|
+
`keychain delete for '${item}' failed (helper exit ${r.status ?? 'null'}` +
|
|
1211
|
+
`${r.error ? `: ${r.error.message}` : ''}) — keychain unreachable, deletion unproven.`);
|
|
1117
1212
|
}
|
|
1118
1213
|
/**
|
|
1119
1214
|
* True when the active keychain backend transparently routes reads/writes to
|
|
@@ -1149,9 +1244,9 @@ export function listKeychainItems(prefix) {
|
|
|
1149
1244
|
if (isWindows())
|
|
1150
1245
|
return apply(windowsBackend.list(mapped.prefix));
|
|
1151
1246
|
const bin = getKeychainHelperPath();
|
|
1152
|
-
const result =
|
|
1247
|
+
const result = spawnKeychainHelper(bin, ['list', mapped.prefix], {
|
|
1153
1248
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1154
|
-
});
|
|
1249
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1155
1250
|
if (result.status !== 0) {
|
|
1156
1251
|
const msg = result.stderr?.toString().trim();
|
|
1157
1252
|
throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
|
|
@@ -1176,9 +1271,9 @@ export function listLegacyKeychainItems(prefix) {
|
|
|
1176
1271
|
if (isLinux())
|
|
1177
1272
|
return [];
|
|
1178
1273
|
const bin = getKeychainHelperPath();
|
|
1179
|
-
const result =
|
|
1274
|
+
const result = spawnKeychainHelper(bin, ['list-legacy', prefix], {
|
|
1180
1275
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1181
|
-
});
|
|
1276
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1182
1277
|
if (result.status !== 0) {
|
|
1183
1278
|
const msg = result.stderr?.toString().trim();
|
|
1184
1279
|
throw new Error(msg || `Failed to enumerate legacy keychain items with prefix '${prefix}'.`);
|
|
@@ -1209,9 +1304,9 @@ export function listSyncedKeychainItems(prefix) {
|
|
|
1209
1304
|
if (isLinux() || isWindows())
|
|
1210
1305
|
return [];
|
|
1211
1306
|
const bin = getKeychainHelperPath();
|
|
1212
|
-
const result =
|
|
1307
|
+
const result = spawnKeychainHelper(bin, ['list-synced', prefix], {
|
|
1213
1308
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1214
|
-
});
|
|
1309
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1215
1310
|
if (result.status !== 0) {
|
|
1216
1311
|
const msg = result.stderr?.toString().trim();
|
|
1217
1312
|
throw new Error(msg || `Failed to enumerate iCloud keychain items with prefix '${prefix}'.`);
|
|
@@ -1237,9 +1332,9 @@ export function getSyncedKeychainTokens(items) {
|
|
|
1237
1332
|
if (isLinux() || isWindows())
|
|
1238
1333
|
return result;
|
|
1239
1334
|
const bin = getKeychainHelperPath();
|
|
1240
|
-
const child =
|
|
1335
|
+
const child = spawnKeychainHelper(bin, ['get-batch-synced', os.userInfo().username, ...items], {
|
|
1241
1336
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1242
|
-
});
|
|
1337
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1243
1338
|
if (child.status === 4) {
|
|
1244
1339
|
throw new Error(`Auth cancelled while reading ${items.length} iCloud keychain item(s).`);
|
|
1245
1340
|
}
|
|
@@ -1265,9 +1360,9 @@ export function deleteSyncedKeychainItem(item) {
|
|
|
1265
1360
|
if (isLinux() || isWindows())
|
|
1266
1361
|
return false;
|
|
1267
1362
|
const bin = getKeychainHelperPath();
|
|
1268
|
-
return
|
|
1363
|
+
return spawnKeychainHelper(bin, ['delete-synced', item, os.userInfo().username], {
|
|
1269
1364
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1270
|
-
}).status === 0;
|
|
1365
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
|
|
1271
1366
|
}
|
|
1272
1367
|
/**
|
|
1273
1368
|
* One-time upgrade for a keychain item that was written by a previous helper
|
|
@@ -1286,9 +1381,9 @@ export function migrateKeychainItem(item) {
|
|
|
1286
1381
|
if (isWindows())
|
|
1287
1382
|
return windowsBackend.has(item);
|
|
1288
1383
|
const bin = getKeychainHelperPath();
|
|
1289
|
-
const result =
|
|
1384
|
+
const result = spawnKeychainHelper(bin, ['migrate-acl', item, os.userInfo().username], {
|
|
1290
1385
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1291
|
-
});
|
|
1386
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1292
1387
|
if (result.status === 0)
|
|
1293
1388
|
return true;
|
|
1294
1389
|
if (result.status === 1)
|
|
@@ -1310,9 +1405,9 @@ export function listOrphanedKeychainItems(prefix) {
|
|
|
1310
1405
|
if (isLinux() || isWindows())
|
|
1311
1406
|
return [];
|
|
1312
1407
|
const bin = getKeychainHelperPath();
|
|
1313
|
-
const result =
|
|
1408
|
+
const result = spawnKeychainHelper(bin, ['list-orphans', prefix, os.userInfo().username], {
|
|
1314
1409
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1315
|
-
});
|
|
1410
|
+
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1316
1411
|
if (result.status !== 0) {
|
|
1317
1412
|
const msg = result.stderr?.toString().trim();
|
|
1318
1413
|
throw new Error(msg || `Failed to enumerate orphaned keychain items with prefix '${prefix}'.`);
|
|
@@ -1368,9 +1463,9 @@ export function migrateOrphanedKeychainItems(prefix) {
|
|
|
1368
1463
|
if (isLinux() || isWindows())
|
|
1369
1464
|
return [];
|
|
1370
1465
|
const bin = getKeychainHelperPath();
|
|
1371
|
-
const result =
|
|
1466
|
+
const result = spawnKeychainHelper(bin, ['migrate-orphans', prefix, os.userInfo().username], {
|
|
1372
1467
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1373
|
-
});
|
|
1468
|
+
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1374
1469
|
if (result.status === 4)
|
|
1375
1470
|
throw new Error('Touch ID cancelled during orphan migration.');
|
|
1376
1471
|
if (result.status !== 0) {
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
/**
|
|
72
|
+
* Parse macOS `ps -o etime=` elapsed time into whole seconds.
|
|
73
|
+
*
|
|
74
|
+
* BSD `etime` renders as `[[dd-]hh:]mm:ss` (e.g. `05:03`, `01:02:03`,
|
|
75
|
+
* `14-04:10:52`). This is the portable keyword: `etimes` (raw seconds) is a
|
|
76
|
+
* GNU/Linux procps extension that macOS `ps` rejects with a non-zero exit, so
|
|
77
|
+
* the reaper — which only ever runs on darwin — must read `etime`.
|
|
78
|
+
* Returns null for an unparseable value so the caller drops the row.
|
|
79
|
+
*/
|
|
80
|
+
export declare function parseEtimeToSeconds(raw: string): number | null;
|
|
81
|
+
/** Test seam: reset the persisted candidate state. */
|
|
82
|
+
export declare function resetKeychainReaperCandidatesForTest(): void;
|
|
83
|
+
/**
|
|
84
|
+
* Impure driver: snapshot all processes once with `ps`, plan the reap, then
|
|
85
|
+
* execute kills through {@link killTree}.
|
|
86
|
+
*
|
|
87
|
+
* Path-matching uses the full helper path (proc_pidpath-style), not just the
|
|
88
|
+
* executable name, so an unrelated binary named "Agents CLI" is never targeted.
|
|
89
|
+
*
|
|
90
|
+
* Returns on non-darwin platforms without shelling anything — the helper only
|
|
91
|
+
* exists on macOS.
|
|
92
|
+
*/
|
|
93
|
+
export declare function reapOrphanedKeychainProcesses(): {
|
|
94
|
+
reaped: number;
|
|
95
|
+
details: string[];
|
|
96
|
+
plan: ReapPlan;
|
|
97
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
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 macOS `ps -o etime=` elapsed time into whole seconds.
|
|
95
|
+
*
|
|
96
|
+
* BSD `etime` renders as `[[dd-]hh:]mm:ss` (e.g. `05:03`, `01:02:03`,
|
|
97
|
+
* `14-04:10:52`). This is the portable keyword: `etimes` (raw seconds) is a
|
|
98
|
+
* GNU/Linux procps extension that macOS `ps` rejects with a non-zero exit, so
|
|
99
|
+
* the reaper — which only ever runs on darwin — must read `etime`.
|
|
100
|
+
* Returns null for an unparseable value so the caller drops the row.
|
|
101
|
+
*/
|
|
102
|
+
export function parseEtimeToSeconds(raw) {
|
|
103
|
+
const m = raw.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
|
|
104
|
+
if (!m)
|
|
105
|
+
return null;
|
|
106
|
+
const days = m[1] ? parseInt(m[1], 10) : 0;
|
|
107
|
+
const hours = m[2] ? parseInt(m[2], 10) : 0;
|
|
108
|
+
const mins = parseInt(m[3], 10);
|
|
109
|
+
const secs = parseInt(m[4], 10);
|
|
110
|
+
if ([days, hours, mins, secs].some(isNaN))
|
|
111
|
+
return null;
|
|
112
|
+
return ((days * 24 + hours) * 60 + mins) * 60 + secs;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Parse one `ps` output line.
|
|
116
|
+
*
|
|
117
|
+
* Expected format from `ps -ax -o pid=,ppid=,etime=,command=`:
|
|
118
|
+
* "<pid> <ppid> <etime> <command...>"
|
|
119
|
+
* where `<etime>` is BSD elapsed time (`[[dd-]hh:]mm:ss`). The command field is
|
|
120
|
+
* the remainder of the line and may contain spaces.
|
|
121
|
+
*/
|
|
122
|
+
function parsePsLine(line) {
|
|
123
|
+
const trimmed = line.trim();
|
|
124
|
+
if (!trimmed)
|
|
125
|
+
return null;
|
|
126
|
+
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
|
|
127
|
+
if (!m)
|
|
128
|
+
return null;
|
|
129
|
+
const pid = parseInt(m[1], 10);
|
|
130
|
+
const ppid = parseInt(m[2], 10);
|
|
131
|
+
const elapsedSec = parseEtimeToSeconds(m[3]);
|
|
132
|
+
const command = m[4];
|
|
133
|
+
if (isNaN(pid) || isNaN(ppid) || elapsedSec == null)
|
|
134
|
+
return null;
|
|
135
|
+
return { pid, ppid, elapsedSec, command };
|
|
136
|
+
}
|
|
137
|
+
/** Module-state for the two-sweep stuck-parent debounce. */
|
|
138
|
+
let stuckParentCandidates = new Map();
|
|
139
|
+
/** Test seam: reset the persisted candidate state. */
|
|
140
|
+
export function resetKeychainReaperCandidatesForTest() {
|
|
141
|
+
stuckParentCandidates = new Map();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Impure driver: snapshot all processes once with `ps`, plan the reap, then
|
|
145
|
+
* execute kills through {@link killTree}.
|
|
146
|
+
*
|
|
147
|
+
* Path-matching uses the full helper path (proc_pidpath-style), not just the
|
|
148
|
+
* executable name, so an unrelated binary named "Agents CLI" is never targeted.
|
|
149
|
+
*
|
|
150
|
+
* Returns on non-darwin platforms without shelling anything — the helper only
|
|
151
|
+
* exists on macOS.
|
|
152
|
+
*/
|
|
153
|
+
export function reapOrphanedKeychainProcesses() {
|
|
154
|
+
const details = [];
|
|
155
|
+
if (process.platform !== 'darwin') {
|
|
156
|
+
return { reaped: 0, details, plan: { kill: [], nextCandidates: new Map() } };
|
|
157
|
+
}
|
|
158
|
+
let helperPath;
|
|
159
|
+
try {
|
|
160
|
+
helperPath = getKeychainHelperPath();
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
return { reaped: 0, details: [`helper path resolution failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
|
|
164
|
+
}
|
|
165
|
+
let out;
|
|
166
|
+
try {
|
|
167
|
+
out = execFileSync('ps', ['-ax', '-o', 'pid=,ppid=,etime=,command='], {
|
|
168
|
+
encoding: 'utf-8',
|
|
169
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
return { reaped: 0, details: [`ps failed: ${err.message}`], plan: { kill: [], nextCandidates: new Map() } };
|
|
174
|
+
}
|
|
175
|
+
const rows = [];
|
|
176
|
+
for (const line of out.split('\n')) {
|
|
177
|
+
const parsed = parsePsLine(line);
|
|
178
|
+
if (!parsed)
|
|
179
|
+
continue;
|
|
180
|
+
const { pid, ppid, elapsedSec, command } = parsed;
|
|
181
|
+
// Exact path-match: the helper invocation's command line begins with the
|
|
182
|
+
// absolute helper path, followed by a space and its arguments (or nothing).
|
|
183
|
+
const isHelper = command === helperPath || command.startsWith(`${helperPath} `);
|
|
184
|
+
rows.push({ pid, ppid, elapsedSec, isHelper, startTime: null });
|
|
185
|
+
}
|
|
186
|
+
const rowByPid = new Map(rows.map((r) => [r.pid, r]));
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
if (!row.isHelper)
|
|
189
|
+
continue;
|
|
190
|
+
row.startTime = captureProcessStartTime(row.pid);
|
|
191
|
+
if (row.ppid !== 1 && row.elapsedSec > STUCK_GRACE_SEC) {
|
|
192
|
+
const parent = rowByPid.get(row.ppid);
|
|
193
|
+
if (parent && parent.startTime === null) {
|
|
194
|
+
parent.startTime = captureProcessStartTime(row.ppid);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const snapshots = rows.map((r) => ({
|
|
199
|
+
pid: r.pid,
|
|
200
|
+
ppid: r.ppid,
|
|
201
|
+
elapsedSec: r.elapsedSec,
|
|
202
|
+
startTime: r.startTime,
|
|
203
|
+
isHelper: r.isHelper,
|
|
204
|
+
}));
|
|
205
|
+
const plan = planKeychainReap(snapshots, Date.now(), stuckParentCandidates);
|
|
206
|
+
stuckParentCandidates = plan.nextCandidates;
|
|
207
|
+
for (const pid of plan.kill) {
|
|
208
|
+
if (pid === process.pid)
|
|
209
|
+
continue; // never self-terminate the reaper
|
|
210
|
+
try {
|
|
211
|
+
killTree(pid);
|
|
212
|
+
details.push(`killed pid ${pid}`);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
details.push(`failed to kill pid ${pid}: ${err.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { reaped: plan.kill.length, details, plan };
|
|
219
|
+
}
|