@phnx-labs/agents-cli 1.22.77 → 1.22.79

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 (95) hide show
  1. package/CHANGELOG.md +13 -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/auth.js +4 -1
  7. package/dist/commands/config.js +37 -0
  8. package/dist/commands/exec.js +21 -10
  9. package/dist/commands/update.js +169 -18
  10. package/dist/commands/versions.d.ts +11 -0
  11. package/dist/commands/versions.js +30 -4
  12. package/dist/commands/view.d.ts +4 -0
  13. package/dist/commands/view.js +114 -19
  14. package/dist/index.js +35 -2
  15. package/dist/lib/account-catalog.d.ts +97 -1
  16. package/dist/lib/account-catalog.js +134 -6
  17. package/dist/lib/account-registry.d.ts +43 -0
  18. package/dist/lib/account-registry.js +97 -2
  19. package/dist/lib/accounting/rotate.d.ts +2 -2
  20. package/dist/lib/accounting/rotate.js +5 -5
  21. package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
  22. package/dist/lib/accounts/auth-operation-lock.js +55 -0
  23. package/dist/lib/accounts/connect.d.ts +170 -0
  24. package/dist/lib/accounts/connect.js +383 -0
  25. package/dist/lib/actor.d.ts +17 -0
  26. package/dist/lib/actor.js +26 -2
  27. package/dist/lib/capabilities.js +2 -0
  28. package/dist/lib/commands.js +2 -0
  29. package/dist/lib/config-keys.d.ts +11 -2
  30. package/dist/lib/config-keys.js +21 -1
  31. package/dist/lib/daemon/daemon.js +5 -0
  32. package/dist/lib/daemon/harness-update-service.d.ts +110 -0
  33. package/dist/lib/daemon/harness-update-service.js +216 -0
  34. package/dist/lib/daemon-services.d.ts +1 -1
  35. package/dist/lib/daemon-services.js +5 -0
  36. package/dist/lib/device-config.d.ts +0 -1
  37. package/dist/lib/device-config.js +50 -5
  38. package/dist/lib/exec.js +26 -2
  39. package/dist/lib/fs-atomic.d.ts +2 -0
  40. package/dist/lib/fs-atomic.js +2 -0
  41. package/dist/lib/hooks/install.js +7 -2
  42. package/dist/lib/identity/index.d.ts +10 -8
  43. package/dist/lib/identity/index.js +15 -11
  44. package/dist/lib/installations/active-check.d.ts +48 -0
  45. package/dist/lib/installations/active-check.js +84 -0
  46. package/dist/lib/installations/index.d.ts +5 -1
  47. package/dist/lib/installations/index.js +4 -0
  48. package/dist/lib/installations/installation-lock.d.ts +6 -0
  49. package/dist/lib/installations/installation-lock.js +29 -0
  50. package/dist/lib/installations/launch-gate.d.ts +69 -0
  51. package/dist/lib/installations/launch-gate.js +133 -0
  52. package/dist/lib/installations/native-command.d.ts +5 -0
  53. package/dist/lib/installations/native-command.js +52 -0
  54. package/dist/lib/installations/shims.d.ts +8 -2
  55. package/dist/lib/installations/shims.js +105 -2
  56. package/dist/lib/installations/store.d.ts +5 -1
  57. package/dist/lib/installations/store.js +24 -3
  58. package/dist/lib/installations/strategies.js +55 -35
  59. package/dist/lib/installations/types.d.ts +17 -0
  60. package/dist/lib/installations/update-cancellation.d.ts +82 -0
  61. package/dist/lib/installations/update-cancellation.js +122 -0
  62. package/dist/lib/installations/update-policy.d.ts +69 -0
  63. package/dist/lib/installations/update-policy.js +114 -0
  64. package/dist/lib/installations/update-runtime.d.ts +118 -0
  65. package/dist/lib/installations/update-runtime.js +321 -0
  66. package/dist/lib/installations/update.d.ts +25 -0
  67. package/dist/lib/installations/update.js +141 -2
  68. package/dist/lib/installations/versions.d.ts +1 -0
  69. package/dist/lib/installations/versions.js +166 -131
  70. package/dist/lib/platform/process.d.ts +3 -1
  71. package/dist/lib/platform/process.js +2 -2
  72. package/dist/lib/staleness/detectors/commands.d.ts +1 -2
  73. package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
  74. package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
  75. package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
  76. package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
  77. package/dist/lib/staleness/detectors/rules.d.ts +1 -2
  78. package/dist/lib/staleness/detectors/skills.d.ts +1 -2
  79. package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
  80. package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
  81. package/dist/lib/staleness/writers/commands.d.ts +1 -2
  82. package/dist/lib/staleness/writers/hooks.d.ts +1 -2
  83. package/dist/lib/staleness/writers/mcp.d.ts +1 -2
  84. package/dist/lib/staleness/writers/permissions.d.ts +1 -12
  85. package/dist/lib/staleness/writers/plugins.d.ts +1 -6
  86. package/dist/lib/staleness/writers/rules.d.ts +1 -2
  87. package/dist/lib/staleness/writers/skills.d.ts +1 -2
  88. package/dist/lib/staleness/writers/subagents.d.ts +1 -2
  89. package/dist/lib/staleness/writers/workflows.d.ts +1 -8
  90. package/dist/lib/state.d.ts +3 -1
  91. package/dist/lib/state.js +38 -13
  92. package/dist/lib/types.d.ts +26 -1
  93. package/dist/lib/types.js +5 -0
  94. package/dist/lib/view-types.d.ts +6 -0
  95. 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
+ }
@@ -1,3 +1,4 @@
1
+ import { type PhoenixSession } from './identity/client.js';
1
2
  import type { ActorConfig } from './types.js';
2
3
  export type ActorKind = 'human' | 'agent';
3
4
  export interface ResolvedActor {
@@ -20,6 +21,13 @@ export interface ResolvedActor {
20
21
  * happens to be signed into tailscale with.
21
22
  */
22
23
  phoenixId?: string;
24
+ /**
25
+ * The human's hosted profile picture (https URL) from the Phoenix ID session
26
+ * signed in on this device, attached only when that session belongs to the
27
+ * same email as the resolved actor. Rides the env as `AGENTS_ACTOR_AVATAR` so
28
+ * tools the agent runs (artifacts, shares) can show the person, not initials.
29
+ */
30
+ avatarUrl?: string;
23
31
  }
24
32
  /** Result of `tailscale whois --json <ip>` we care about. */
25
33
  export interface WhoisIdentity {
@@ -34,6 +42,13 @@ export interface WhoisIdentity {
34
42
  * from the tailnet DisplayName + login email.
35
43
  */
36
44
  export declare function actorFromIdentity(who: WhoisIdentity | undefined, host: string, actors: Record<string, ActorConfig>): ResolvedActor;
45
+ /**
46
+ * The Phoenix ID profile picture for a resolved human, when the session signed
47
+ * in on this device is that same person (matched on email, case-insensitive).
48
+ * A shared box signed in as someone else must not lend its picture to whoever
49
+ * SSH-ed in, so a mismatch yields nothing. Pure: the session is injected.
50
+ */
51
+ export declare function actorAvatar(actor: ResolvedActor, session: PhoenixSession | null): string | undefined;
37
52
  /**
38
53
  * Injectable tailscale resolvers, so tests can drive the SSH-whois and
39
54
  * local-self branches deterministically without a real tailscale on the box
@@ -43,6 +58,8 @@ export declare function actorFromIdentity(who: WhoisIdentity | undefined, host:
43
58
  export interface ActorResolvers {
44
59
  whois: (ip: string) => WhoisIdentity | undefined;
45
60
  self: () => WhoisIdentity | undefined;
61
+ /** The Phoenix ID session on this device (a local file read, no network). */
62
+ session: () => PhoenixSession | null;
46
63
  }
47
64
  /**
48
65
  * Compute the actor for a given environment. The only impurity is the tailscale
package/dist/lib/actor.js CHANGED
@@ -25,6 +25,7 @@
25
25
  import { spawnSync } from 'child_process';
26
26
  import { machineId } from './machine-id.js';
27
27
  import { parseSshConnection } from './session/provenance.js';
28
+ import { readSession } from './identity/client.js';
28
29
  import { readMeta } from './state.js';
29
30
  /** Hard cap on the whois shell-out — it runs on the spawn hot path. */
30
31
  const WHOIS_TIMEOUT_MS = 2000;
@@ -145,9 +146,28 @@ function inheritedActor(env) {
145
146
  email: env.AGENTS_ACTOR_EMAIL || undefined,
146
147
  github: env.AGENTS_ACTOR_GITHUB || undefined,
147
148
  phoenixId: env.AGENTS_ACTOR_PHOENIX_ID || undefined,
149
+ avatarUrl: httpsUrl(env.AGENTS_ACTOR_AVATAR),
148
150
  };
149
151
  }
150
- const defaultResolvers = { whois: tailscaleWhois, self: tailscaleSelf };
152
+ /** An avatar is only ever an https URL; anything else is not an avatar. */
153
+ function httpsUrl(value) {
154
+ const trimmed = value?.trim();
155
+ return trimmed && /^https:\/\/\S+$/i.test(trimmed) ? trimmed : undefined;
156
+ }
157
+ /**
158
+ * The Phoenix ID profile picture for a resolved human, when the session signed
159
+ * in on this device is that same person (matched on email, case-insensitive).
160
+ * A shared box signed in as someone else must not lend its picture to whoever
161
+ * SSH-ed in, so a mismatch yields nothing. Pure: the session is injected.
162
+ */
163
+ export function actorAvatar(actor, session) {
164
+ if (actor.kind !== 'human' || !actor.email || !session?.email)
165
+ return undefined;
166
+ if (actor.email.trim().toLowerCase() !== session.email.trim().toLowerCase())
167
+ return undefined;
168
+ return httpsUrl(session.avatarUrl);
169
+ }
170
+ const defaultResolvers = { whois: tailscaleWhois, self: tailscaleSelf, session: readSession };
151
171
  /**
152
172
  * Compute the actor for a given environment. The only impurity is the tailscale
153
173
  * shell-out (injectable via `resolvers`), so tests drive every branch explicitly.
@@ -171,7 +191,9 @@ export function computeActor(env = process.env, resolvers = defaultResolvers) {
171
191
  // UNRESOLVED rather than being misattributed to the box's owner.
172
192
  if (!who && !sshRaw)
173
193
  who = resolvers.self();
174
- return actorFromIdentity(who, machineId(), readActors());
194
+ const actor = actorFromIdentity(who, machineId(), readActors());
195
+ const avatarUrl = actorAvatar(actor, resolvers.session());
196
+ return avatarUrl ? { ...actor, avatarUrl } : actor;
175
197
  }
176
198
  let cached;
177
199
  let resolverOverride;
@@ -220,6 +242,8 @@ export function actorEnv(actor) {
220
242
  env.AGENTS_ACTOR_GITHUB = actor.github;
221
243
  if (actor.phoenixId)
222
244
  env.AGENTS_ACTOR_PHOENIX_ID = actor.phoenixId;
245
+ if (actor.avatarUrl)
246
+ env.AGENTS_ACTOR_AVATAR = actor.avatarUrl;
223
247
  if (actor.kind === 'human' && actor.name && actor.email) {
224
248
  env.GIT_AUTHOR_NAME = actor.name;
225
249
  env.GIT_AUTHOR_EMAIL = actor.email;
@@ -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