@phnx-labs/agents-cli 1.22.78 → 1.22.80

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +28 -1
  3. package/dist/bootstrap.js +40 -12
  4. package/dist/commands/accounts.d.ts +22 -0
  5. package/dist/commands/accounts.js +158 -35
  6. package/dist/commands/config.js +37 -0
  7. package/dist/commands/exec.js +21 -10
  8. package/dist/commands/update.js +169 -18
  9. package/dist/commands/versions.d.ts +11 -0
  10. package/dist/commands/versions.js +30 -4
  11. package/dist/commands/view.d.ts +69 -3
  12. package/dist/commands/view.js +235 -75
  13. package/dist/index.js +35 -2
  14. package/dist/lib/account-catalog.d.ts +97 -1
  15. package/dist/lib/account-catalog.js +134 -6
  16. package/dist/lib/account-registry.d.ts +43 -0
  17. package/dist/lib/account-registry.js +97 -2
  18. package/dist/lib/accounting/rotate.d.ts +2 -2
  19. package/dist/lib/accounting/rotate.js +5 -5
  20. package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
  21. package/dist/lib/accounts/auth-operation-lock.js +55 -0
  22. package/dist/lib/accounts/connect.d.ts +170 -0
  23. package/dist/lib/accounts/connect.js +383 -0
  24. package/dist/lib/capabilities.js +2 -0
  25. package/dist/lib/commands.js +2 -0
  26. package/dist/lib/config-keys.d.ts +11 -2
  27. package/dist/lib/config-keys.js +21 -1
  28. package/dist/lib/daemon/daemon.js +5 -0
  29. package/dist/lib/daemon/harness-update-service.d.ts +110 -0
  30. package/dist/lib/daemon/harness-update-service.js +216 -0
  31. package/dist/lib/daemon-services.d.ts +1 -1
  32. package/dist/lib/daemon-services.js +5 -0
  33. package/dist/lib/device-config.d.ts +0 -1
  34. package/dist/lib/device-config.js +50 -5
  35. package/dist/lib/exec.js +26 -2
  36. package/dist/lib/fs-atomic.d.ts +2 -0
  37. package/dist/lib/fs-atomic.js +2 -0
  38. package/dist/lib/hooks/install.js +7 -2
  39. package/dist/lib/installations/active-check.d.ts +48 -0
  40. package/dist/lib/installations/active-check.js +84 -0
  41. package/dist/lib/installations/index.d.ts +5 -1
  42. package/dist/lib/installations/index.js +4 -0
  43. package/dist/lib/installations/installation-lock.d.ts +6 -0
  44. package/dist/lib/installations/installation-lock.js +29 -0
  45. package/dist/lib/installations/launch-gate.d.ts +69 -0
  46. package/dist/lib/installations/launch-gate.js +133 -0
  47. package/dist/lib/installations/native-command.d.ts +5 -0
  48. package/dist/lib/installations/native-command.js +52 -0
  49. package/dist/lib/installations/shims.d.ts +8 -2
  50. package/dist/lib/installations/shims.js +105 -2
  51. package/dist/lib/installations/store.d.ts +5 -1
  52. package/dist/lib/installations/store.js +24 -3
  53. package/dist/lib/installations/strategies.js +55 -35
  54. package/dist/lib/installations/types.d.ts +17 -0
  55. package/dist/lib/installations/update-cancellation.d.ts +82 -0
  56. package/dist/lib/installations/update-cancellation.js +122 -0
  57. package/dist/lib/installations/update-policy.d.ts +69 -0
  58. package/dist/lib/installations/update-policy.js +114 -0
  59. package/dist/lib/installations/update-runtime.d.ts +118 -0
  60. package/dist/lib/installations/update-runtime.js +321 -0
  61. package/dist/lib/installations/update.d.ts +25 -0
  62. package/dist/lib/installations/update.js +141 -2
  63. package/dist/lib/installations/versions.d.ts +1 -0
  64. package/dist/lib/installations/versions.js +166 -131
  65. package/dist/lib/platform/process.d.ts +3 -1
  66. package/dist/lib/platform/process.js +2 -2
  67. package/dist/lib/staleness/detectors/commands.d.ts +1 -2
  68. package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
  69. package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
  70. package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
  71. package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
  72. package/dist/lib/staleness/detectors/rules.d.ts +1 -2
  73. package/dist/lib/staleness/detectors/skills.d.ts +1 -2
  74. package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
  75. package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
  76. package/dist/lib/staleness/writers/commands.d.ts +1 -2
  77. package/dist/lib/staleness/writers/hooks.d.ts +1 -2
  78. package/dist/lib/staleness/writers/mcp.d.ts +1 -2
  79. package/dist/lib/staleness/writers/permissions.d.ts +1 -12
  80. package/dist/lib/staleness/writers/plugins.d.ts +1 -6
  81. package/dist/lib/staleness/writers/rules.d.ts +1 -2
  82. package/dist/lib/staleness/writers/skills.d.ts +1 -2
  83. package/dist/lib/staleness/writers/subagents.d.ts +1 -2
  84. package/dist/lib/staleness/writers/workflows.d.ts +1 -8
  85. package/dist/lib/state.d.ts +3 -1
  86. package/dist/lib/state.js +38 -13
  87. package/dist/lib/types.d.ts +26 -1
  88. package/dist/lib/types.js +5 -0
  89. package/dist/lib/view-types.d.ts +6 -0
  90. package/package.json +1 -1
@@ -0,0 +1,383 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { nativeAccountCapability, nativeAccountNamingRefusal } from '../account-capabilities.js';
3
+ import { listNativeAccounts } from '../account-registry.js';
4
+ import { readMeta } from '../state.js';
5
+ import { acquireAuthOperationLock } from './auth-operation-lock.js';
6
+ /**
7
+ * `agents accounts connect <harness> [name]` — the stable-account front door
8
+ * (PHNX-3940).
9
+ *
10
+ * An account is stable INDEPENDENT of releases: connecting a NEW account mints a
11
+ * fresh opaque installation label, installs the current release into that
12
+ * label's isolated home (even when the same release is already installed under
13
+ * another label), and launches the harness's own native login there — no
14
+ * credential is copied or refreshed, the OAuth login stays owned by the harness.
15
+ * Reconnecting a NAMED EXISTING account reuses that account's existing home and
16
+ * fails CLOSED if the completed login is a different identity, so a reconnect can
17
+ * never overwrite some other account's home.
18
+ *
19
+ * This module owns the release-independent decision (plan), the opaque label,
20
+ * and the fail-closed identity check. The actual install + login spawn is
21
+ * injected (`ConnectRunners`) so the pure decision path is testable without a
22
+ * network install or a TTY, and so the parent can own the real E2E login+verify.
23
+ */
24
+ /**
25
+ * Ambient provider-credential env vars stripped before a native login spawn, so
26
+ * the OAuth flow authenticates as the human's identity rather than being
27
+ * short-circuited by an injected API key / setup-token that would impersonate a
28
+ * different account into the fresh home.
29
+ */
30
+ const PROVIDER_AUTH_ENV_KEYS = [
31
+ 'ANTHROPIC_API_KEY',
32
+ 'CLAUDE_CODE_OAUTH_TOKEN',
33
+ 'ANTHROPIC_AUTH_TOKEN',
34
+ 'OPENAI_API_KEY',
35
+ 'CODEX_API_KEY',
36
+ ];
37
+ /**
38
+ * Native-login invocation per harness. Only harnesses with a REAL, finite login
39
+ * COMMAND are listed — connect fails clearly for anything else rather than faking
40
+ * a flow that never signs the user in. Verified against the installed CLIs
41
+ * (PHNX-3940): `claude auth login --help` → "Sign in to your Anthropic account"
42
+ * with `--email`; `codex login` drives the OAuth flow. A harness is added here
43
+ * only once its isolated login command is verified.
44
+ */
45
+ const LOGIN_INVOCATIONS = {
46
+ claude: { args: ['auth', 'login'], emailFlag: '--email', hint: 'Complete the Claude sign-in in your browser.' },
47
+ codex: { args: ['login'] },
48
+ };
49
+ /** Whether `connect` can drive this harness (isolation + a real native login). */
50
+ export function connectSupported(agent) {
51
+ const cap = nativeAccountCapability(agent);
52
+ return cap.scope === 'version' && !!LOGIN_INVOCATIONS[agent];
53
+ }
54
+ /**
55
+ * Named reason connect refuses a harness, or null when supported. Distinguishes
56
+ * "cannot isolate/name this login" (capability) from "no native login command
57
+ * wired yet" so the message is honest about which limit was hit.
58
+ */
59
+ export function connectRefusal(agent) {
60
+ const capabilityRefusal = nativeAccountNamingRefusal(agent);
61
+ if (capabilityRefusal)
62
+ return capabilityRefusal;
63
+ const cap = nativeAccountCapability(agent);
64
+ if (cap.scope !== 'version') {
65
+ return `${agent} authentication is ${cap.scope}-scoped; connect creates per-account isolated homes, which needs a version-scoped login.`;
66
+ }
67
+ if (!LOGIN_INVOCATIONS[agent]) {
68
+ const supported = Object.keys(LOGIN_INVOCATIONS).sort().join(', ');
69
+ return `connect does not yet drive ${agent}'s native login. Supported: ${supported}.`;
70
+ }
71
+ return null;
72
+ }
73
+ export function assertConnectSupported(agent) {
74
+ const reason = connectRefusal(agent);
75
+ if (reason)
76
+ throw new Error(reason);
77
+ }
78
+ export function loginInvocation(agent) {
79
+ const invocation = LOGIN_INVOCATIONS[agent];
80
+ if (!invocation)
81
+ throw new Error(`No native login command is wired for ${agent}.`);
82
+ return invocation;
83
+ }
84
+ /**
85
+ * Mint a fresh, opaque installation slot. `acct-<hex>` — alnum + hyphen only, so
86
+ * it satisfies VERSION_RE, and is visibly an account slot, not a release. It is
87
+ * RANDOM, never derived from the account name or identity: a name-derived slot
88
+ * recomputes an already-occupied home after a rename and lets a new connect
89
+ * overwrite another account's login (PHNX-3940 security fix). Retry-idempotency
90
+ * is instead provided by the device-scoped pending-connect map, and collision
91
+ * safety by allocating around occupied slots — see `allocateConnectSlot`.
92
+ */
93
+ export function mintConnectLabel() {
94
+ return `acct-${crypto.randomBytes(6).toString('hex')}`;
95
+ }
96
+ /**
97
+ * Resolve the home to reuse for reconnecting an existing account: THIS box's
98
+ * recorded connect home first (device-scoped, survives an expired credential),
99
+ * else a currently-signed-in local home carrying its identity. Returns null when
100
+ * neither is known — a legacy account with no recorded or live home, for which
101
+ * connect adopts a fresh home.
102
+ */
103
+ export function resolveExistingHomeLabel(existing, deviceHome, signedInHomes) {
104
+ if (deviceHome)
105
+ return deviceHome;
106
+ const match = signedInHomes.find(h => h.agent === existing.agent && h.identityKey === existing.identityKey);
107
+ return match?.label ?? null;
108
+ }
109
+ /**
110
+ * Decide the release-independent connect plan (pure). `existing` is the native
111
+ * account the name resolves to for THIS harness (or null for a new connect);
112
+ * `existingHomeLabel` is its reusable home from {@link resolveExistingHomeLabel};
113
+ * `freshSlot` is a safely-allocated opaque slot (see `allocateConnectSlot`) used
114
+ * for a new connect or an adopted reconnect home — NEVER a name-derived label.
115
+ */
116
+ export function planConnect(input) {
117
+ if (input.existing) {
118
+ const reuse = input.existingHomeLabel;
119
+ return {
120
+ mode: 'reconnect',
121
+ agent: input.agent,
122
+ label: reuse ?? input.freshSlot,
123
+ name: input.existing.name,
124
+ existing: input.existing,
125
+ adoptedHome: !reuse,
126
+ };
127
+ }
128
+ return { mode: 'new', agent: input.agent, label: input.freshSlot, name: input.name };
129
+ }
130
+ /**
131
+ * Safely allocate the opaque slot for a NEW connect or an ADOPTED reconnect home
132
+ * (PHNX-3940 security fix). Never reuses an identity-bearing slot:
133
+ *
134
+ * - `occupied` is every slot already owned by an account's home OR currently
135
+ * signed in — an allocated slot is guaranteed disjoint from it, so a new
136
+ * connect can never land on another account's home (the rename-collision flaw).
137
+ * - A named connect first reuses its device-scoped PENDING slot (a prior
138
+ * failed/cancelled attempt) IF that slot is not occupied, so a retry lands in
139
+ * the same fresh home instead of orphaning a new one.
140
+ * - Otherwise it mints random slots until one is neither occupied nor installed.
141
+ */
142
+ export function allocateConnectSlot(input) {
143
+ const mint = input.mint ?? mintConnectLabel;
144
+ // Retry reuse is only for a NEW named connect (pending is keyed by name); a
145
+ // pending slot that has since become occupied is abandoned, never overwritten.
146
+ if (!input.existing && input.name && input.pending && !input.occupied.has(input.pending)) {
147
+ return input.pending;
148
+ }
149
+ let slot = mint();
150
+ while (input.occupied.has(slot) || input.installedLabels.has(slot))
151
+ slot = mint();
152
+ return slot;
153
+ }
154
+ /**
155
+ * Fail-closed identity check after the login completes (pure).
156
+ *
157
+ * - Not signed in (no live credential) → the login did not complete; a metadata
158
+ * identity key alone is NOT proof, so `signedIn` is required.
159
+ * - Reconnect whose completed identity differs from the account's → REFUSE
160
+ * registering the binding. The native credential in that home DID change (the
161
+ * user signed in), so the message says the account BINDING is left unchanged —
162
+ * it does not claim nothing happened.
163
+ * A new connect accepts whatever identity signed in (that is the account being
164
+ * created); the caller registers it.
165
+ */
166
+ export function verifyConnectedIdentity(plan, observed) {
167
+ if (!observed.signedIn || !observed.identityKey) {
168
+ throw new Error(`No ${plan.agent} login completed in ${plan.agent}@${plan.label} (no live credential). Nothing was connected.`);
169
+ }
170
+ if (plan.mode === 'reconnect' && plan.existing && observed.identityKey !== plan.existing.identityKey) {
171
+ throw new Error(`The ${plan.agent} login that just completed in ${plan.agent}@${plan.label} is a different identity `
172
+ + `(${observed.identityKey}) than account '${plan.existing.name}' (${plan.existing.identityKey}). `
173
+ + `Account '${plan.existing.name}' still points at its original identity (binding unchanged); `
174
+ + `connect a NEW account for this login instead.`);
175
+ }
176
+ }
177
+ /** Resolve the existing native account a connect `name` refers to for a harness. */
178
+ export function findConnectAccount(agent, name, meta) {
179
+ if (!name)
180
+ return null;
181
+ const needle = name.toLowerCase();
182
+ return listNativeAccounts(meta).find(a => a.agent === agent && (a.id === name || a.name.toLowerCase() === needle || a.identityLabel?.toLowerCase() === needle)) ?? null;
183
+ }
184
+ /**
185
+ * Drive one `agents accounts connect`. Pure decisions (plan, fail-closed verify)
186
+ * come from the helpers above; the install + login + identity read are injected.
187
+ *
188
+ * The default runners are loaded via dynamic import so this module — reachable
189
+ * from `account-registry` consumers — never statically pulls in the install
190
+ * engine or exec path and closes an import cycle (the intended pattern here).
191
+ *
192
+ * `stateDir` is only for tests: override the runtime-state dir used by the
193
+ * per-harness auth-operation mutex (defaults to the real runtime state dir).
194
+ */
195
+ export async function runConnect(agent, name, opts, runners) {
196
+ assertConnectSupported(agent);
197
+ // Acquire the per-harness auth-operation mutex BEFORE any meta read, slot
198
+ // allocation, or name validation — two parallel connects for the same harness
199
+ // would otherwise both see "name available" and "slot free", then race to
200
+ // register the same slot or the same name, overwriting one account's home.
201
+ const lock = acquireAuthOperationLock(agent, opts.stateDir);
202
+ try {
203
+ return await _runConnectLocked(agent, name, opts, lock, runners);
204
+ }
205
+ finally {
206
+ lock.release();
207
+ }
208
+ }
209
+ async function _runConnectLocked(agent, name, opts, lock, runners) {
210
+ const run = runners ?? await defaultConnectRunners();
211
+ const registry = await import('../account-registry.js');
212
+ lock.assertHeld();
213
+ // Re-read meta AFTER acquiring the lock so we see any writes a concurrent
214
+ // connect committed before we were serialized past it. `opts.meta` was a
215
+ // snapshot taken before the lock was available and must not be used here.
216
+ const meta = readMeta();
217
+ const existing = findConnectAccount(agent, name, meta);
218
+ // Validate the requested NAME and its collisions BEFORE any install or login,
219
+ // so a bad name never mints an orphan home and drives a login that can't be
220
+ // recorded. Only a genuinely NEW named connect needs this (a reconnect names an
221
+ // account that already passed the check).
222
+ if (!existing && name)
223
+ registry.assertNativeAccountNameAvailable(name, agent);
224
+ const signedIn = await run.signedInHomes();
225
+ lock.assertHeld();
226
+ const existingHomeLabel = existing
227
+ ? resolveExistingHomeLabel(existing, registry.nativeAccountHome(existing.id, meta), signedIn)
228
+ : null;
229
+ // SECURITY (PHNX-3940): allocate a fresh slot that is DISJOINT from every
230
+ // occupied home — a slot already owned by an account OR currently signed in.
231
+ // This is what stops a NEW connect (e.g. `connect work` after `work` was
232
+ // renamed `personal`) from landing on and overwriting another account's login.
233
+ const occupied = new Set([
234
+ ...registry.ownedConnectHomeLabels(meta),
235
+ ...signedIn.filter(h => h.agent === agent).map(h => h.label),
236
+ ]);
237
+ const freshSlot = allocateConnectSlot({
238
+ agent,
239
+ name,
240
+ existing,
241
+ occupied,
242
+ installedLabels: new Set(run.installedLabels(agent)),
243
+ pending: (!existing && name) ? registry.pendingConnectSlot(agent, name, meta) : null,
244
+ });
245
+ // Record the in-flight slot for a named connect so a failed retry reuses it.
246
+ if (!existing && name && freshSlot !== registry.pendingConnectSlot(agent, name, meta)) {
247
+ registry.setPendingConnectSlot(agent, name, freshSlot);
248
+ }
249
+ const plan = planConnect({ agent, name, existing, existingHomeLabel, freshSlot });
250
+ // Pre-launch guard (both modes): if the chosen home is already installed AND
251
+ // signed in, refuse to launch a login that would OVERWRITE its identity —
252
+ // unless it is the SAME identity we are (re)connecting. For a NEW connect any
253
+ // signed-in identity is a stranger; for a reconnect only a DIFFERENT one is.
254
+ if (run.installedLabels(agent).includes(plan.label)) {
255
+ const current = await run.observeIdentity(agent, plan.label);
256
+ lock.assertHeld();
257
+ const intended = existing?.identityKey;
258
+ if (current.signedIn && current.identityKey && current.identityKey !== intended) {
259
+ throw new Error(`${agent}@${plan.label} is currently signed in as ${current.identityKey}`
260
+ + `${existing ? ` (not account '${existing.name}')` : ''}. `
261
+ + `Refusing to launch a login that would overwrite it.`);
262
+ }
263
+ }
264
+ // Install the current release into the account's home. A reconnect that reuses
265
+ // an already-installed home skips the install (reuse, don't re-mint); a new
266
+ // account (or an adopted reconnect home) installs the current release under its
267
+ // opaque label even when the same release is already installed elsewhere. A
268
+ // retried named connect reuses the same pending slot rather than orphaning.
269
+ const alreadyInstalled = run.installedLabels(agent).includes(plan.label);
270
+ lock.assertHeld();
271
+ if (!(alreadyInstalled && (plan.mode === 'reconnect' || !!name))) {
272
+ opts.onProgress?.(`Installing ${agent} into ${plan.label}…`);
273
+ const install = await run.install(agent, plan.label, opts.onProgress);
274
+ lock.assertHeld();
275
+ if (!install.success)
276
+ throw new Error(`Could not install ${agent} for the account home: ${install.error ?? 'unknown error'}`);
277
+ }
278
+ const invocation = loginInvocation(agent);
279
+ if (invocation.hint)
280
+ opts.onProgress?.(invocation.hint);
281
+ const loginEmail = existing?.identityLabel;
282
+ lock.assertHeld();
283
+ const login = await run.launchLogin(agent, plan.label, invocation, loginEmail, lock.signal);
284
+ lock.assertHeld();
285
+ // A cancelled or failed login exits non-zero — do NOT read stale metadata and
286
+ // report success. A NAMED connect can retry the same home (its slot is pending);
287
+ // an UNNAMED connect has no pending slot, so a re-run allocates a new home.
288
+ if (login.code !== 0) {
289
+ const retry = name ? 're-run to retry the same home' : 're-run to try again (a new home is allocated)';
290
+ throw new Error(`${agent} login did not complete (exit ${login.code ?? 'null'}) in ${agent}@${plan.label}. Nothing was recorded; ${retry}.`);
291
+ }
292
+ const observed = await run.observeIdentity(agent, plan.label);
293
+ lock.assertHeld();
294
+ verifyConnectedIdentity(plan, observed);
295
+ const identityKey = observed.identityKey;
296
+ // Persist THIS box's account⇄home binding (device-scoped) so a later reconnect
297
+ // reuses this exact home even after the credential expires, and clear the
298
+ // in-flight pending slot now that it is a real account home.
299
+ let becameDefault = false;
300
+ let nameHint;
301
+ if (plan.mode === 'new') {
302
+ if (name) {
303
+ const account = registry.addNativeAccount(name, agent, identityKey, observed.email ?? undefined, 'version');
304
+ registry.setNativeAccountHome(account.id, plan.label);
305
+ registry.clearPendingConnectSlot(agent, name);
306
+ // Select this account as the harness default ONLY if none is configured —
307
+ // never override an existing choice, and never force a duplicate name.
308
+ becameDefault = registry.setDefaultAccountIfAbsent(agent, account.name);
309
+ }
310
+ else {
311
+ // No name was given: do not invent/force one. Point the user at the command
312
+ // that names this login so it becomes selectable and default-eligible.
313
+ nameHint = `agents accounts label ${agent} <name>`;
314
+ }
315
+ }
316
+ else if (existing) {
317
+ registry.setNativeAccountHome(existing.id, plan.label);
318
+ }
319
+ return {
320
+ mode: plan.mode,
321
+ agent,
322
+ label: plan.label,
323
+ name: plan.name ?? name,
324
+ identityKey,
325
+ email: observed.email,
326
+ releaseVersion: observed.releaseVersion,
327
+ becameDefault,
328
+ nameHint,
329
+ };
330
+ }
331
+ /** Real runners: the install engine, an inherited-stdio login spawn, and identity read. */
332
+ async function defaultConnectRunners() {
333
+ const [{ installVersion, getVersionHomePath, listInstalledVersions }, store, agentsMod, execMod] = await Promise.all([
334
+ import('../installations/versions.js'),
335
+ import('../installations/store.js'),
336
+ import('../agents.js'),
337
+ import('../exec.js'),
338
+ ]);
339
+ const { readInstallation } = store;
340
+ const { getAccountInfo } = agentsMod;
341
+ const { buildExecEnv } = execMod;
342
+ const { nativeAccountCapability, nativeIdentityKey } = await import('../account-capabilities.js');
343
+ const { runNativeAccountCommand } = await import('../installations/native-command.js');
344
+ return {
345
+ installedLabels: (agent) => listInstalledVersions(agent),
346
+ install: async (agent, label, onProgress) => {
347
+ const result = await installVersion(agent, 'latest', onProgress, { installationLabel: label });
348
+ return { success: result.success, error: result.error };
349
+ },
350
+ launchLogin: async (agent, label, invocation, email, signal) => {
351
+ // Pin the harness's own config-dir env (CLAUDE_CONFIG_DIR / CODEX_HOME) to
352
+ // this label's isolated home so the native login lands there — no
353
+ // credential is copied; the login writes into the home it authenticates.
354
+ const env = buildExecEnv({ agent, version: label, configVersion: label, interactive: true, mode: 'auto', effort: 'auto', cwd: process.cwd() });
355
+ // Strip any ambient provider API-key / setup-token env so the native login
356
+ // authenticates as the human's OAuth identity, not an injected credential
357
+ // that would impersonate a different account into this home.
358
+ for (const key of PROVIDER_AUTH_ENV_KEYS)
359
+ delete env[key];
360
+ const args = email && invocation.emailFlag ? [...invocation.args, invocation.emailFlag, email] : invocation.args;
361
+ return runNativeAccountCommand(agent, label, args, env, signal);
362
+ },
363
+ observeIdentity: async (agent, label) => {
364
+ const home = getVersionHomePath(agent, label);
365
+ const info = await getAccountInfo(agent, home);
366
+ const { isLaunchableSignedIn } = await import('../account-catalog.js');
367
+ return {
368
+ identityKey: nativeIdentityKey(info, nativeAccountCapability(agent)),
369
+ email: info.email,
370
+ releaseVersion: readInstallation(agent, label)?.releaseVersion ?? null,
371
+ // Strict: a live credential in THIS home, not a bare metadata identity.
372
+ signedIn: isLaunchableSignedIn(agent, home, info),
373
+ };
374
+ },
375
+ signedInHomes: async () => {
376
+ const { collectNativeHomeRows } = await import('../account-catalog.js');
377
+ const rows = await collectNativeHomeRows();
378
+ return rows
379
+ .filter(r => r.signedIn && (r.accountKey ?? r.email))
380
+ .map(r => ({ agent: r.agent, identityKey: (r.accountKey ?? r.email.toLowerCase()), label: r.label }));
381
+ },
382
+ };
383
+ }
@@ -11,6 +11,7 @@ import { AGENTS, MANAGED_AGENT_IDS } from './agents.js';
11
11
  // agent-spec/primitives is a leaf module — importing it creates no cycle, and it
12
12
  // is the only compareVersions that honors OpenClaw's `-N` rebuild suffix.
13
13
  import { compareVersions } from './agent-spec/primitives.js';
14
+ import { installedReleaseFor } from './installations/store.js';
14
15
  function getCapability(agent, cap) {
15
16
  // Guard against unknown agent ids (e.g. a caller passing "claude@2.1.168"
16
17
  // instead of "claude"). Without this, AGENTS[agent] is undefined and the
@@ -48,6 +49,7 @@ export function supports(agent, cap, version) {
48
49
  return { ok: true };
49
50
  if (!version)
50
51
  return { ok: true };
52
+ version = installedReleaseFor(agent, version);
51
53
  if (c.since && compareVersions(version, c.since) < 0) {
52
54
  return { ok: false, reason: 'too_old', need: `>= ${c.since}` };
53
55
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import * as fs from 'fs';
10
10
  import { compareVersions } from './agent-spec/primitives.js';
11
+ import { installedReleaseFor } from './installations/store.js';
11
12
  import * as path from 'path';
12
13
  import * as yaml from 'yaml';
13
14
  import { AGENTS, ensureCommandsDir, agentConfigDirName, resolveAgentName } from './agents.js';
@@ -48,6 +49,7 @@ export function commandAppliesTo(agent, version, metadata) {
48
49
  if (metadata?.agents?.length && !metadata.agents.includes(agent)) {
49
50
  return { ok: false, reason: 'agent_excluded' };
50
51
  }
52
+ version = installedReleaseFor(agent, version);
51
53
  if (metadata?.since && compareVersions(version, metadata.since) < 0) {
52
54
  return { ok: false, reason: 'too_old', need: `>= ${metadata.since}` };
53
55
  }
@@ -9,7 +9,7 @@
9
9
  import type { AgentId } from './types.js';
10
10
  import { type ModelTier } from './model-tiers.js';
11
11
  /** The top-level scope of a unified config key. */
12
- export type ConfigScope = 'run' | 'interactive' | 'auto' | 'browser' | 'project' | 'device' | 'summarizer';
12
+ export type ConfigScope = 'run' | 'interactive' | 'auto' | 'browser' | 'project' | 'device' | 'summarizer' | 'updates';
13
13
  /** A run-time default key: model, mode, effort, or tier override. */
14
14
  export interface ParsedRunConfigKey {
15
15
  scope: 'run';
@@ -51,7 +51,16 @@ export interface ParsedSummarizerConfigKey {
51
51
  scope: 'summarizer';
52
52
  property: 'enabled' | 'baseUrl' | 'model';
53
53
  }
54
- export type ParsedConfigKey = ParsedRunConfigKey | ParsedInteractiveConfigKey | ParsedAutoConfigKey | ParsedBrowserConfigKey | ParsedProjectConfigKey | ParsedDeviceConfigKey | ParsedSummarizerConfigKey;
54
+ /**
55
+ * The managed-harness auto-update switch (PHNX-3940): `updates.auto` (global)
56
+ * or `updates.<agent>.auto` (one harness — `agent` is set).
57
+ */
58
+ export interface ParsedUpdatesConfigKey {
59
+ scope: 'updates';
60
+ property: 'auto';
61
+ agent?: AgentId;
62
+ }
63
+ export type ParsedConfigKey = ParsedRunConfigKey | ParsedInteractiveConfigKey | ParsedAutoConfigKey | ParsedBrowserConfigKey | ParsedProjectConfigKey | ParsedDeviceConfigKey | ParsedSummarizerConfigKey | ParsedUpdatesConfigKey;
55
64
  export type DeviceConfigProperty = 'role' | 'max-agents' | 'scheduler' | 'daemon' | 'watchdog' | 'tmux' | 'browser.remote-control' | 'browser.task-idle-minutes' | 'notes' | 'browser.profile' | 'browser.viewer';
56
65
  /** Normalize agent@version to use `@` consistently. */
57
66
  export declare function formatAgentVersion(agent: AgentId, version: string): string;
@@ -98,6 +98,17 @@ export function parseConfigKey(key) {
98
98
  if (summarizerMatch) {
99
99
  return { scope: 'summarizer', property: summarizerMatch[1] };
100
100
  }
101
+ if (raw === 'updates.auto') {
102
+ return { scope: 'updates', property: 'auto' };
103
+ }
104
+ const updatesAgentMatch = raw.match(/^updates\.(.+)\.auto$/);
105
+ if (updatesAgentMatch) {
106
+ const agentPart = updatesAgentMatch[1].toLowerCase();
107
+ if (!(agentPart in AGENTS)) {
108
+ throw new Error(`Unknown agent '${updatesAgentMatch[1]}' in '${key}'. Known agents: ${Object.keys(AGENTS).join(', ')}.`);
109
+ }
110
+ return { scope: 'updates', agent: agentPart, property: 'auto' };
111
+ }
101
112
  const deviceMatch = raw.match(/^devices\.(.+)\.(role|max-agents|scheduler|daemon|watchdog|tmux|notes|browser\.remote-control|browser\.task-idle-minutes|browser\.profile|browser\.viewer)$/);
102
113
  if (deviceMatch) {
103
114
  return {
@@ -125,6 +136,9 @@ export function parseConfigKey(key) {
125
136
  if (raw.startsWith('summarizer.')) {
126
137
  throw new Error(`Invalid summarizer config key '${key}'. Use summarizer.enabled, summarizer.baseUrl, or summarizer.model.`);
127
138
  }
139
+ if (raw.startsWith('updates.')) {
140
+ throw new Error(`Invalid updates config key '${key}'. Use updates.auto or updates.<agent>.auto.`);
141
+ }
128
142
  if (raw.startsWith('devices.')) {
129
143
  throw new Error(`Invalid device config key '${key}'. Expected devices.<name>.<${DEVICE_CONFIG_PROPERTIES.join('|')}>.`);
130
144
  }
@@ -152,6 +166,8 @@ export function formatConfigKey(parsed) {
152
166
  return `devices.${parsed.device}.${parsed.property}`;
153
167
  case 'summarizer':
154
168
  return `summarizer.${parsed.property}`;
169
+ case 'updates':
170
+ return parsed.agent ? `updates.${parsed.agent}.auto` : 'updates.auto';
155
171
  }
156
172
  }
157
173
  /** List every canonical key the command documents, with wildcards expanded to a concrete example. */
@@ -161,7 +177,7 @@ export function listKnownConfigKeys() {
161
177
  for (const tier of MODEL_TIERS) {
162
178
  keys.push(`run.<agent@version>.tier.${tier}`);
163
179
  }
164
- keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'browser.device', 'project.root', 'summarizer.enabled', 'summarizer.baseUrl', 'summarizer.model');
180
+ keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'browser.device', 'project.root', 'summarizer.enabled', 'summarizer.baseUrl', 'summarizer.model', 'updates.auto', 'updates.<agent>.auto');
165
181
  for (const prop of DEVICE_CONFIG_PROPERTIES) {
166
182
  keys.push(`devices.<name>.${prop}`);
167
183
  }
@@ -233,5 +249,9 @@ export function configKeyStorageHint(parsed) {
233
249
  : parsed.property === 'baseUrl' ? 'summarizerBaseUrl' : 'summarizerModel';
234
250
  return `config.${yamlKey} (central agents.yaml; syncs fleet-wide)`;
235
251
  }
252
+ case 'updates':
253
+ return parsed.agent
254
+ ? `config.updatesAgentAuto.${parsed.agent} (central agents.yaml; syncs fleet-wide)`
255
+ : 'config.updatesAuto (central agents.yaml; syncs fleet-wide)';
236
256
  }
237
257
  }
@@ -42,6 +42,7 @@ import { WatchdogService } from './watchdog-service.js';
42
42
  import { DeviceProbeService } from './device-probe-service.js';
43
43
  import { SelfHealService } from './self-heal-service.js';
44
44
  import { SelfUpdateService } from './self-update-service.js';
45
+ import { HarnessUpdateService } from './harness-update-service.js';
45
46
  import { KeychainReapService } from './keychain-reap-service.js';
46
47
  import { AuthSyncService } from './auth-sync-service.js';
47
48
  import { UsageSyncService } from './usage-sync-service.js';
@@ -1090,6 +1091,10 @@ export async function runDaemon() {
1090
1091
  supervisor.register(new SelfUpdateService());
1091
1092
  else
1092
1093
  log('INFO', 'Self-update service disabled');
1094
+ if (isEnabled('harness-update'))
1095
+ supervisor.register(new HarnessUpdateService());
1096
+ else
1097
+ log('INFO', 'Harness-update service disabled');
1093
1098
  if (isEnabled('keychain-reap'))
1094
1099
  supervisor.register(new KeychainReapService());
1095
1100
  else
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Daemon harness-update service (PHNX-3940).
3
+ *
4
+ * Runs the automatic-update pass (`installations/update-runtime.ts`) on a
5
+ * schedule so a managed, transactional npm harness (Claude, Codex, …) stays
6
+ * current with no operator action, subject to the `updates.auto` /
7
+ * `updates.<agent>.auto` switches and each installation's own update policy.
8
+ *
9
+ * The pass itself does real, synchronous filesystem work per installation —
10
+ * staging an npm install into a sibling directory, `fs.renameSync`/`fs.cpSync`
11
+ * swaps, `fs.rmSync` cleanup — potentially across several installations in one
12
+ * tick. Running that inline on the daemon's own event loop would stall every
13
+ * other service (secrets broker, browser IPC, the scheduler) for the
14
+ * duration, the same class of problem `self-update-service.ts` solves for the
15
+ * CLI's OWN upgrade by installing into a fresh process it then exits into.
16
+ * Here there is no "exit and let the supervisor restart" option (the daemon
17
+ * itself isn't what's being updated), so instead this tick SPAWNS a bounded
18
+ * child (`agents __harness-update-run`) over a Node IPC channel and only waits
19
+ * on that child — every sync fs call happens in the child's own event loop /
20
+ * thread pool, never this one.
21
+ *
22
+ * Cancellation is COOPERATIVE and cross-platform (PHNX-3940). On the tick's
23
+ * deadline or daemon shutdown — both delivered as the supervisor aborting the
24
+ * tick's `AbortSignal` — the daemon does NOT force-kill the child (execFile's
25
+ * `timeout`/`signal` would, unconditionally on Windows, mid-swap). It SENDS the
26
+ * child an IPC cancel message; the child stops at its next safe boundary (see
27
+ * `installations/update-cancellation.ts` + `update.ts`'s `shouldCancel`) and
28
+ * exits on its own, and the daemon waits for that TRUE exit. A wedged child that
29
+ * ignores the request past a generous grace — never the normal path, since the
30
+ * child's own npm/probe work is bounded — is force-reaped only as an
31
+ * orphan-prevention backstop, and that abnormal case is reported as a failure,
32
+ * never as a clean pass.
33
+ */
34
+ import { BasePeriodicService, type DaemonContext } from './service.js';
35
+ import type { DaemonServiceId } from '../daemon-services.js';
36
+ /**
37
+ * Cancellation deadline per tick. A real pass can touch several installations, each an npm
38
+ * install (`INSTALL_TIMEOUT_MS` = 120s in strategies.ts) plus two launch
39
+ * probes; 10 minutes leaves headroom for a handful of harnesses in one pass
40
+ * while staying comfortably under the 15-minute cadence above.
41
+ */
42
+ export declare const HARNESS_UPDATE_DEADLINE_MS: number;
43
+ /**
44
+ * How long to wait for the child to exit AFTER a cooperative cancel before
45
+ * force-reaping it as an orphan backstop. The child's own work is bounded — one
46
+ * npm install (`INSTALL_TIMEOUT_MS` = 120s in strategies.ts) plus two launch
47
+ * probes, then a fast synchronous swap — so a healthy child stops well within
48
+ * this window. A slow staging/postinstall can also exhaust it, but a delivered
49
+ * cancel prevents that stage from committing. The backstop kills the process
50
+ * group on POSIX and the worker on Windows; the tick reports a failure.
51
+ */
52
+ export declare const HARNESS_UPDATE_CANCEL_GRACE_MS: number;
53
+ export interface HarnessUpdateOutcome {
54
+ ran: boolean;
55
+ reason?: string;
56
+ exitCode?: number | null;
57
+ stdout?: string;
58
+ /** True when the pass was asked to stop (deadline/shutdown) and did so cooperatively. */
59
+ cancelled?: boolean;
60
+ }
61
+ export interface CooperativeChildResult {
62
+ exitCode: number | null;
63
+ stdout: string;
64
+ /** True when a cancel was requested during this child's life (deadline/shutdown). */
65
+ cancelled: boolean;
66
+ }
67
+ /**
68
+ * Dependency seam so tests can assert the tick's decision/spawn logic without
69
+ * actually installing anything. Production always uses
70
+ * {@link defaultHarnessUpdateDeps}.
71
+ */
72
+ export interface HarnessUpdateDeps {
73
+ runAutoUpdatePass(signal: AbortSignal): Promise<CooperativeChildResult>;
74
+ }
75
+ export declare function defaultHarnessUpdateDeps(): HarnessUpdateDeps;
76
+ /**
77
+ * Spawn `command args` as a child over a Node IPC channel and drive it to a TRUE
78
+ * completion, requesting a cooperative stop (never a kill) when `signal` aborts —
79
+ * the supervisor aborts it on the tick's deadline OR on daemon shutdown.
80
+ *
81
+ * Resolves with the child's real exit code and captured output. Rejects only on:
82
+ * - a spawn failure (missing binary) — a genuine service failure; and
83
+ * - the child having to be force-reaped past the grace window, or dying to an
84
+ * external signal — never a clean pass, so the tick logs it as ERROR instead
85
+ * of reading a killed process as a completed update.
86
+ *
87
+ * A non-zero exit with no kill is resolved (not rejected): the pass exits
88
+ * non-zero for a per-installation vendor error, which is a normal tick outcome.
89
+ *
90
+ * Exported for the real-subprocess cancellation tests.
91
+ */
92
+ export declare function driveCooperativeChild(command: string, args: string[], signal: AbortSignal, graceMs: number, spawnOpts?: {
93
+ env?: NodeJS.ProcessEnv;
94
+ cwd?: string;
95
+ }): Promise<CooperativeChildResult>;
96
+ /**
97
+ * Core decision + spawn, shared so a future on-demand trigger (mirroring
98
+ * `triggerSelfUpdateInBackground`) can reuse it. Returns rather than throws —
99
+ * the periodic tick logs the outcome and moves on either way.
100
+ */
101
+ export declare function runHarnessUpdateTick(ctx: DaemonContext, signal: AbortSignal, deps?: HarnessUpdateDeps): Promise<HarnessUpdateOutcome>;
102
+ export declare class HarnessUpdateService extends BasePeriodicService {
103
+ readonly id: DaemonServiceId;
104
+ readonly intervalMs: number;
105
+ readonly deadlineMs: number;
106
+ readonly startupDelayMs = 60000;
107
+ protected onStart(_ctx: DaemonContext): Promise<void>;
108
+ protected onStop(): Promise<void>;
109
+ protected onTick(ctx: DaemonContext, signal: AbortSignal): Promise<void>;
110
+ }