@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
@@ -1,5 +1,21 @@
1
- import { ALL_AGENT_IDS, getAccountInfo, supportsAccountInspection } from './agents.js';
2
- import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
1
+ import { ALL_AGENT_IDS, credentialPresence, getAccountInfo, supportsAccountInspection } from './agents.js';
2
+ import { getGlobalDefault, getVersionHomePath, listInstalledVersions } from './installations/versions.js';
3
+ import { readInstallation } from './installations/store.js';
4
+ import { readMeta } from './state.js';
5
+ import { listNativeAccounts, readAccountRegistry } from './account-registry.js';
6
+ import { isLaunchableSignedIn as isCredentialLaunchable } from './accounting/rotate.js';
7
+ /**
8
+ * Strict "is this home actually connected?" — a live CREDENTIAL in that exact
9
+ * version home, not just a metadata identity claim (PHNX-3940). A `.claude.json`
10
+ * carrying an `oauthAccount` block with no `.credentials.json`/`.oauth_token`
11
+ * beside it is stale/expired, and must read as `reconnect-needed`, never
12
+ * `connected`. Where agents-cli does not know the credential location
13
+ * (`knownLocation` false), it falls back to the metadata `signedIn` since there
14
+ * is nothing stricter to check.
15
+ */
16
+ export function isLaunchableSignedIn(agent, versionHome, info) {
17
+ return isCredentialLaunchable(info.signedIn, credentialPresence(agent, versionHome));
18
+ }
3
19
  export function groupNativeAccountRows(rows) {
4
20
  const grouped = new Map();
5
21
  for (const row of rows) {
@@ -27,12 +43,124 @@ export function groupNativeAccountRows(rows) {
27
43
  }
28
44
  /** Discover signed-in harness-native identities without copying their auth files. */
29
45
  export async function discoverNativeAccounts() {
46
+ const rows = await collectNativeHomeRows();
47
+ return groupNativeAccountRows(rows.map(r => ({ agent: r.agent, version: r.label, accountKey: r.accountKey, email: r.email, signedIn: r.signedIn })));
48
+ }
49
+ /** Probe every installed home of every inspectable harness for its native identity. */
50
+ export async function collectNativeHomeRows() {
30
51
  const rows = [];
31
52
  for (const agent of ALL_AGENT_IDS.filter(supportsAccountInspection)) {
32
- for (const version of listInstalledVersions(agent)) {
33
- const info = await getAccountInfo(agent, getVersionHomePath(agent, version));
34
- rows.push({ agent, version, accountKey: info.accountKey, email: info.email, signedIn: info.signedIn });
53
+ for (const label of listInstalledVersions(agent)) {
54
+ const home = getVersionHomePath(agent, label);
55
+ const info = await getAccountInfo(agent, home);
56
+ rows.push({
57
+ agent,
58
+ label,
59
+ releaseVersion: readInstallation(agent, label)?.releaseVersion ?? null,
60
+ accountKey: info.accountKey,
61
+ email: info.email,
62
+ // Strict: a live credential in THIS home, not a bare metadata identity.
63
+ signedIn: isLaunchableSignedIn(agent, home, info),
64
+ });
65
+ }
66
+ }
67
+ return rows;
68
+ }
69
+ /**
70
+ * Fold installed homes + the registry into account-first native rows (pure).
71
+ *
72
+ * Every home carrying an identity contributes an `AccountHome`; the identity is
73
+ * the group key. A registered account (name/id/connect home) is merged onto its
74
+ * identity, and — crucially — a registered account whose identity has NO live
75
+ * home still appears, as `reconnect-needed`, so a stale/expired login is never
76
+ * silently dropped from the list nor shown as connected.
77
+ */
78
+ export function buildNativeCatalog(rows, meta, globalDefault = getGlobalDefault) {
79
+ const registered = listNativeAccounts(meta);
80
+ const defaults = meta.accounts?.defaults ?? {};
81
+ const groups = new Map();
82
+ const keyOf = (agent, identity) => `${agent}:${identity}`;
83
+ // Every installed home that carries a resolvable identity seeds a group.
84
+ for (const row of rows) {
85
+ const identity = row.accountKey ?? row.email?.toLowerCase();
86
+ if (!identity)
87
+ continue;
88
+ const key = keyOf(row.agent, identity);
89
+ const group = groups.get(key) ?? { agent: row.agent, identityKey: identity, email: null, homes: [] };
90
+ group.email ??= row.email;
91
+ group.homes.push({ label: row.label, releaseVersion: row.releaseVersion, signedIn: row.signedIn });
92
+ groups.set(key, group);
93
+ }
94
+ // A registered account whose identity has no discovered home still belongs in
95
+ // the catalog — it just has nothing live to connect through yet. Its
96
+ // `identityKey` is the same value a home row groups on (a raw accountKey, or
97
+ // an already-lowercased email), so an exact-key check finds the existing group.
98
+ for (const account of registered) {
99
+ const key = keyOf(account.agent, account.identityKey);
100
+ if (!groups.has(key)) {
101
+ groups.set(key, { agent: account.agent, identityKey: account.identityKey, email: account.identityLabel ?? null, homes: [] });
102
+ }
103
+ }
104
+ const out = [];
105
+ for (const group of groups.values()) {
106
+ const account = registered.find(a => a.agent === group.agent && a.identityKey === group.identityKey);
107
+ const email = group.email ?? account?.identityLabel ?? null;
108
+ const homes = [...group.homes].sort((a, b) => a.label.localeCompare(b.label));
109
+ const signedIn = homes.some(h => h.signedIn);
110
+ // The configured per-harness account default is AUTHORITATIVE: when present,
111
+ // only the matching native account is the default — an explicit default that
112
+ // names a provider (or a stale name) never invents a native default. Only
113
+ // when NO account default is configured does the global-default installation
114
+ // home decide (diagnostic fallback).
115
+ const defaultRef = defaults[group.agent];
116
+ let isDefault;
117
+ if (defaultRef !== undefined) {
118
+ isDefault = !!account && (account.name === defaultRef || account.id === defaultRef);
119
+ }
120
+ else {
121
+ const gd = globalDefault(group.agent);
122
+ isDefault = !!gd && homes.some(h => h.label === gd);
35
123
  }
124
+ // The home is a PER-HOST fact: this box's recorded connect home when it is
125
+ // actually installed here, else the local home carrying the identity. A home
126
+ // label recorded on another box is never assumed to exist locally.
127
+ const recordedHome = account ? (meta.deviceAccounts?.homes?.[account.id] ?? null) : null;
128
+ const home = (recordedHome && homes.some(h => h.label === recordedHome))
129
+ ? recordedHome
130
+ : (homes.find(h => h.signedIn)?.label ?? homes[0]?.label ?? null);
131
+ out.push({
132
+ kind: 'native',
133
+ agent: group.agent,
134
+ identityKey: group.identityKey,
135
+ name: account?.name ?? null,
136
+ id: account?.id ?? null,
137
+ email,
138
+ display: email ?? account?.name ?? group.identityKey,
139
+ home,
140
+ installations: homes,
141
+ isDefault,
142
+ state: signedIn ? 'connected' : 'reconnect-needed',
143
+ });
36
144
  }
37
- return groupNativeAccountRows(rows);
145
+ // Named accounts first, then default, then a stable display order.
146
+ return out.sort((a, b) => a.agent.localeCompare(b.agent)
147
+ || Number(!!b.name) - Number(!!a.name)
148
+ || Number(b.isDefault) - Number(a.isDefault)
149
+ || a.display.localeCompare(b.display));
150
+ }
151
+ function toProviderRow(account) {
152
+ return { kind: 'provider', name: account.name, id: account.id, provider: account.provider, auth: account.auth, baseUrl: account.baseUrl };
153
+ }
154
+ /**
155
+ * Wire the real collectors to the pure builder. This is the canonical
156
+ * account-first read model `view.ts` renders: native identities (account +
157
+ * connection first, release/home secondary) plus the durable provider bundles.
158
+ */
159
+ export async function loadAccountCatalog() {
160
+ const meta = readMeta();
161
+ const native = buildNativeCatalog(await collectNativeHomeRows(), meta);
162
+ const provider = Object.values(readAccountRegistry().accounts)
163
+ .map(toProviderRow)
164
+ .sort((a, b) => a.name.localeCompare(b.name));
165
+ return { native, provider };
38
166
  }
@@ -55,9 +55,52 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'device
55
55
  * able to omit it.
56
56
  */
57
57
  export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument, preferAgent?: AgentId): UnifiedAccount | null;
58
+ /**
59
+ * Validate a native account NAME (charset + per-harness uniqueness) WITHOUT a
60
+ * known identity — the pre-flight `agents accounts connect` runs before it
61
+ * installs a home and drives a login, so a bad/colliding name fails before any
62
+ * side effect instead of orphaning a freshly-minted home (PHNX-3940).
63
+ */
64
+ export declare function assertNativeAccountNameAvailable(name: string, agent: AgentId): void;
58
65
  export declare function addNativeAccount(name: string, agent: AgentId, identityKey: string, identityLabel: string | undefined, scope: 'version' | 'device'): NativeAccount;
59
66
  /** Create or replace the version-independent label for one native identity. */
60
67
  export declare function labelNativeAccount(agent: AgentId, identityKey: string, identityLabel: string | undefined, label: string | undefined, scope: 'version' | 'device'): NativeAccount;
68
+ /**
69
+ * Record THIS box's connect home for an account (PHNX-3940). Device-scoped: the
70
+ * home a box minted for an account is not assumed to exist on any other box, so
71
+ * it lives in the device doc keyed by the stable account id, never on the
72
+ * fleet-synced central identity row. Idempotent. A reconnect reads it back via
73
+ * {@link nativeAccountHome} to reuse the exact home even when the credential has
74
+ * expired and local signed-in discovery can no longer find it.
75
+ */
76
+ export declare function setNativeAccountHome(accountId: string, installationLabel: string): void;
77
+ /** This box's recorded connect home for an account, or null. */
78
+ export declare function nativeAccountHome(accountId: string, meta: Pick<Meta, 'deviceAccounts'>): string | null;
79
+ /**
80
+ * Every installation label THIS box has recorded as SOME account's connect home
81
+ * (PHNX-3940). These are identity-bearing and MUST NEVER be re-minted for a new
82
+ * account — the safe-allocation invariant that stops a new connect from
83
+ * overwriting another account's login.
84
+ */
85
+ export declare function ownedConnectHomeLabels(meta: Pick<Meta, 'deviceAccounts'>): Set<string>;
86
+ /** This box's in-flight connect slot for `(agent, name)`, or null. */
87
+ export declare function pendingConnectSlot(agent: AgentId, name: string, meta: Pick<Meta, 'deviceAccounts'>): string | null;
88
+ /** Record an in-flight connect slot for `(agent, name)` (device-scoped). */
89
+ export declare function setPendingConnectSlot(agent: AgentId, name: string, slot: string): void;
90
+ /** Clear the in-flight connect slot for `(agent, name)` once the account lands. */
91
+ export declare function clearPendingConnectSlot(agent: AgentId, name: string): void;
92
+ /**
93
+ * Set the per-harness default account by NAME only when none is configured
94
+ * (PHNX-3940). Never overrides an existing choice — a first connect selecting a
95
+ * default is a convenience, not a takeover. Returns whether it set the default.
96
+ *
97
+ * The check-then-write is performed INSIDE the `updateMeta` callback so two
98
+ * concurrent callers (concurrent Promises or back-to-back calls on an async
99
+ * boundary) cannot both observe "no default" and then both set themselves. Only
100
+ * the first write wins; the second callback sees the first's write and returns
101
+ * the current state unchanged.
102
+ */
103
+ export declare function setDefaultAccountIfAbsent(agent: AgentId, name: string): boolean;
61
104
  export declare function bindAccount(nameOrId: string, target: string, preferAgent?: AgentId): UnifiedAccount;
62
105
  export declare function unbindAccount(nameOrId: string, target: string, preferAgent?: AgentId): void;
63
106
  /** Every target bound to `accountId`: this box's device-doc bindings merged over
@@ -221,6 +221,16 @@ function assertUniqueUnifiedName(name, meta, doc, exceptIds, agent) {
221
221
  if (provider && !exceptIds?.has(provider.id))
222
222
  throw new Error(`Account '${name}' already exists.`);
223
223
  }
224
+ /**
225
+ * Validate a native account NAME (charset + per-harness uniqueness) WITHOUT a
226
+ * known identity — the pre-flight `agents accounts connect` runs before it
227
+ * installs a home and drives a login, so a bad/colliding name fails before any
228
+ * side effect instead of orphaning a freshly-minted home (PHNX-3940).
229
+ */
230
+ export function assertNativeAccountNameAvailable(name, agent) {
231
+ assertNativeLabel(name);
232
+ assertUniqueUnifiedName(name, readMeta(), undefined, undefined, agent);
233
+ }
224
234
  export function addNativeAccount(name, agent, identityKey, identityLabel, scope) {
225
235
  assertNativeLabel(name);
226
236
  const meta = readMeta();
@@ -283,6 +293,87 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
283
293
  });
284
294
  return { ...matches[0], name: resolvedLabel, identityLabel, scope: rowScope };
285
295
  }
296
+ /**
297
+ * Record THIS box's connect home for an account (PHNX-3940). Device-scoped: the
298
+ * home a box minted for an account is not assumed to exist on any other box, so
299
+ * it lives in the device doc keyed by the stable account id, never on the
300
+ * fleet-synced central identity row. Idempotent. A reconnect reads it back via
301
+ * {@link nativeAccountHome} to reuse the exact home even when the credential has
302
+ * expired and local signed-in discovery can no longer find it.
303
+ */
304
+ export function setNativeAccountHome(accountId, installationLabel) {
305
+ updateMeta(current => ({
306
+ ...current,
307
+ deviceAccounts: {
308
+ ...current.deviceAccounts,
309
+ homes: { ...current.deviceAccounts?.homes, [accountId]: installationLabel },
310
+ },
311
+ }));
312
+ }
313
+ /** This box's recorded connect home for an account, or null. */
314
+ export function nativeAccountHome(accountId, meta) {
315
+ return meta.deviceAccounts?.homes?.[accountId] ?? null;
316
+ }
317
+ /**
318
+ * Every installation label THIS box has recorded as SOME account's connect home
319
+ * (PHNX-3940). These are identity-bearing and MUST NEVER be re-minted for a new
320
+ * account — the safe-allocation invariant that stops a new connect from
321
+ * overwriting another account's login.
322
+ */
323
+ export function ownedConnectHomeLabels(meta) {
324
+ return new Set(Object.values(meta.deviceAccounts?.homes ?? {}));
325
+ }
326
+ function pendingConnectKey(agent, name) {
327
+ return `${agent}:${name.toLowerCase()}`;
328
+ }
329
+ /** This box's in-flight connect slot for `(agent, name)`, or null. */
330
+ export function pendingConnectSlot(agent, name, meta) {
331
+ return meta.deviceAccounts?.pendingConnects?.[pendingConnectKey(agent, name)] ?? null;
332
+ }
333
+ /** Record an in-flight connect slot for `(agent, name)` (device-scoped). */
334
+ export function setPendingConnectSlot(agent, name, slot) {
335
+ updateMeta(current => ({
336
+ ...current,
337
+ deviceAccounts: {
338
+ ...current.deviceAccounts,
339
+ pendingConnects: { ...current.deviceAccounts?.pendingConnects, [pendingConnectKey(agent, name)]: slot },
340
+ },
341
+ }));
342
+ }
343
+ /** Clear the in-flight connect slot for `(agent, name)` once the account lands. */
344
+ export function clearPendingConnectSlot(agent, name) {
345
+ updateMeta(current => {
346
+ const pendingConnects = { ...current.deviceAccounts?.pendingConnects };
347
+ delete pendingConnects[pendingConnectKey(agent, name)];
348
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, pendingConnects } };
349
+ });
350
+ }
351
+ /**
352
+ * Set the per-harness default account by NAME only when none is configured
353
+ * (PHNX-3940). Never overrides an existing choice — a first connect selecting a
354
+ * default is a convenience, not a takeover. Returns whether it set the default.
355
+ *
356
+ * The check-then-write is performed INSIDE the `updateMeta` callback so two
357
+ * concurrent callers (concurrent Promises or back-to-back calls on an async
358
+ * boundary) cannot both observe "no default" and then both set themselves. Only
359
+ * the first write wins; the second callback sees the first's write and returns
360
+ * the current state unchanged.
361
+ */
362
+ export function setDefaultAccountIfAbsent(agent, name) {
363
+ let set = false;
364
+ updateMeta(current => {
365
+ if (current.accounts?.defaults?.[agent])
366
+ return current; // already set — no-op
367
+ if (current.agents?.[agent] || current.isolatedAgents?.[agent])
368
+ return current; // preserve a legacy home default
369
+ set = true;
370
+ return {
371
+ ...current,
372
+ accounts: { ...current.accounts, defaults: { ...current.accounts?.defaults, [agent]: name } },
373
+ };
374
+ });
375
+ return set;
376
+ }
286
377
  export function bindAccount(nameOrId, target, preferAgent) {
287
378
  const meta = readMeta();
288
379
  // Scope to the harness being bound to: a bare identity selector matches every
@@ -463,16 +554,20 @@ export function removeAccount(name, base = getUserAgentsDir()) {
463
554
  // Sweep every row for the identity (PHNX-3206) from its owning store (PHNX-3315).
464
555
  const rowScope = rows[0].scope;
465
556
  updateMeta(current => {
557
+ // Drop this box's connect-home record for the removed account (PHNX-3940).
558
+ const homes = { ...current.deviceAccounts?.homes };
559
+ for (const id of ids)
560
+ delete homes[id];
466
561
  if (rowScope === 'device') {
467
562
  const accounts = { ...current.deviceAccounts?.native };
468
563
  for (const id of ids)
469
564
  delete accounts[id];
470
- return { ...current, deviceAccounts: { ...current.deviceAccounts, native: accounts } };
565
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, native: accounts, homes } };
471
566
  }
472
567
  const accounts = { ...current.accounts?.native };
473
568
  for (const id of ids)
474
569
  delete accounts[id];
475
- return { ...current, accounts: { ...current.accounts, native: accounts } };
570
+ return { ...current, accounts: { ...current.accounts, native: accounts }, deviceAccounts: { ...current.deviceAccounts, homes } };
476
571
  });
477
572
  return;
478
573
  }
@@ -402,7 +402,7 @@ export declare function collectHarnessCandidates(agentIds?: AgentId[]): Promise<
402
402
  * (the usual form) or its `accountKey`, and only ever returns a signed-in slot.
403
403
  * Returns null when nothing matches, so the caller can fall back and warn.
404
404
  */
405
- export declare function matchAccountVersion(candidates: RotateCandidate[], account: string): string | null;
405
+ export declare function matchAccountVersion(candidates: RotateCandidate[], account: string, preferredLabel?: string | null): string | null;
406
406
  /**
407
407
  * Resolve a routine's `account:` pin (login email or account key) to the
408
408
  * installed version currently holding that account. Thin I/O wrapper over
@@ -412,7 +412,7 @@ export declare function matchAccountVersion(candidates: RotateCandidate[], accou
412
412
  * (RUSH-1957): the pinned run never rotates and never lands on another
413
413
  * routine's credential.
414
414
  */
415
- export declare function resolveAccountVersion(agent: AgentId, account: string): Promise<string | null>;
415
+ export declare function resolveAccountVersion(agent: AgentId, account: string, preferredLabel?: string | null): Promise<string | null>;
416
416
  /**
417
417
  * Pick a healthy version for `agent` using weighted random by remaining
418
418
  * capacity. See `pickBalancedCandidate` for algorithm details.
@@ -775,13 +775,13 @@ export async function collectHarnessCandidates(agentIds = ALL_AGENT_IDS) {
775
775
  * (the usual form) or its `accountKey`, and only ever returns a signed-in slot.
776
776
  * Returns null when nothing matches, so the caller can fall back and warn.
777
777
  */
778
- export function matchAccountVersion(candidates, account) {
778
+ export function matchAccountVersion(candidates, account, preferredLabel) {
779
779
  const needle = account.trim().toLowerCase();
780
780
  if (!needle)
781
781
  return null;
782
- const match = candidates.find((c) => c.signedIn &&
782
+ const matching = candidates.filter((c) => c.signedIn &&
783
783
  (c.email?.toLowerCase() === needle || c.accountKey?.toLowerCase() === needle));
784
- return match?.version ?? null;
784
+ return matching.find(candidate => candidate.version === preferredLabel)?.version ?? matching[0]?.version ?? null;
785
785
  }
786
786
  /**
787
787
  * Resolve a routine's `account:` pin (login email or account key) to the
@@ -792,9 +792,9 @@ export function matchAccountVersion(candidates, account) {
792
792
  * (RUSH-1957): the pinned run never rotates and never lands on another
793
793
  * routine's credential.
794
794
  */
795
- export async function resolveAccountVersion(agent, account) {
795
+ export async function resolveAccountVersion(agent, account, preferredLabel) {
796
796
  const candidates = await collectRunCandidates(agent);
797
- return matchAccountVersion(candidates, account);
797
+ return matchAccountVersion(candidates, account, preferredLabel);
798
798
  }
799
799
  /**
800
800
  * Pick a healthy version for `agent` using weighted random by remaining
@@ -0,0 +1,9 @@
1
+ import { type AgentId } from '../types.js';
2
+ export declare function authLockFilePath(agent: AgentId, stateDir?: string): string;
3
+ export interface AuthOperationLock {
4
+ readonly signal: AbortSignal;
5
+ assertHeld(): void;
6
+ release(): void;
7
+ }
8
+ /** Uses the same lock primitive as configuration writes; no fail-open fallback. */
9
+ export declare function acquireAuthOperationLock(agent: AgentId, stateDir?: string): AuthOperationLock;
@@ -0,0 +1,55 @@
1
+ /** Cross-process exclusion for native connect/logout, including browser waits. */
2
+ import * as path from 'node:path';
3
+ import lockfile from 'proper-lockfile';
4
+ import { ensureLockTarget } from '../fs-atomic.js';
5
+ import { getRuntimeStateDir } from '../state.js';
6
+ import { isAgentId } from '../types.js';
7
+ export function authLockFilePath(agent, stateDir) {
8
+ if (!isAgentId(agent))
9
+ throw new Error('Unknown authentication harness.');
10
+ return path.join(stateDir ?? getRuntimeStateDir(), `auth-op-lock-${agent}.json`);
11
+ }
12
+ /** Uses the same lock primitive as configuration writes; no fail-open fallback. */
13
+ export function acquireAuthOperationLock(agent, stateDir) {
14
+ const target = authLockFilePath(agent, stateDir);
15
+ ensureLockTarget(target, '{}', 0o700);
16
+ let compromised = null;
17
+ const controller = new AbortController();
18
+ let unlock;
19
+ try {
20
+ unlock = lockfile.lockSync(target, {
21
+ stale: 10 * 60_000,
22
+ update: 1_000,
23
+ // The primitive refreshes the lock while a browser flow awaits input.
24
+ // Acquisition is immediate: never silently queue native logins.
25
+ onCompromised: (error) => {
26
+ compromised = new Error(`Authentication lock was lost: ${error.message}`);
27
+ controller.abort(compromised);
28
+ },
29
+ });
30
+ }
31
+ catch (error) {
32
+ throw new Error(`Cannot safely start ${agent} authentication: another sign-in or sign-out may be in progress. ${error.message}`);
33
+ }
34
+ let released = false;
35
+ return {
36
+ signal: controller.signal,
37
+ assertHeld() {
38
+ controller.signal.throwIfAborted();
39
+ if (released)
40
+ throw new Error('Authentication lock was already released.');
41
+ },
42
+ release() {
43
+ if (released)
44
+ return;
45
+ released = true;
46
+ try {
47
+ unlock();
48
+ }
49
+ finally {
50
+ if (compromised)
51
+ throw new Error(`Authentication lock was lost: ${compromised.message}`);
52
+ }
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,170 @@
1
+ import type { AgentId, Meta } from '../types.js';
2
+ import { type NativeAccount } from '../account-registry.js';
3
+ /** Which harnesses `connect` supports, and how their native login is launched. */
4
+ export interface LoginInvocation {
5
+ /** argv passed to the installed binary to start the native login. */
6
+ args: string[];
7
+ /** Flag that pre-fills the login email (appended as `[emailFlag, email]`), when supported. */
8
+ emailFlag?: string;
9
+ /** One-line hint shown before the login flow takes over. */
10
+ hint?: string;
11
+ }
12
+ /** Whether `connect` can drive this harness (isolation + a real native login). */
13
+ export declare function connectSupported(agent: AgentId): boolean;
14
+ /**
15
+ * Named reason connect refuses a harness, or null when supported. Distinguishes
16
+ * "cannot isolate/name this login" (capability) from "no native login command
17
+ * wired yet" so the message is honest about which limit was hit.
18
+ */
19
+ export declare function connectRefusal(agent: AgentId): string | null;
20
+ export declare function assertConnectSupported(agent: AgentId): void;
21
+ export declare function loginInvocation(agent: AgentId): LoginInvocation;
22
+ /**
23
+ * Mint a fresh, opaque installation slot. `acct-<hex>` — alnum + hyphen only, so
24
+ * it satisfies VERSION_RE, and is visibly an account slot, not a release. It is
25
+ * RANDOM, never derived from the account name or identity: a name-derived slot
26
+ * recomputes an already-occupied home after a rename and lets a new connect
27
+ * overwrite another account's login (PHNX-3940 security fix). Retry-idempotency
28
+ * is instead provided by the device-scoped pending-connect map, and collision
29
+ * safety by allocating around occupied slots — see `allocateConnectSlot`.
30
+ */
31
+ export declare function mintConnectLabel(): string;
32
+ export type ConnectMode = 'new' | 'reconnect';
33
+ export interface ConnectPlan {
34
+ mode: ConnectMode;
35
+ agent: AgentId;
36
+ /** The installation label whose isolated home hosts this account's login. */
37
+ label: string;
38
+ /** The human account name to register (new) or already registered (reconnect). */
39
+ name?: string;
40
+ /** For reconnect: the existing account being re-authenticated. */
41
+ existing?: NativeAccount;
42
+ /**
43
+ * For reconnect: the label was ADOPTED (freshly minted) because the existing
44
+ * account carried no recorded home and none was discoverable — so its identity
45
+ * must still be re-verified against the login, but there is no prior home to
46
+ * reuse. `false` when an existing home is being reused.
47
+ */
48
+ adoptedHome?: boolean;
49
+ }
50
+ /**
51
+ * Resolve the home to reuse for reconnecting an existing account: THIS box's
52
+ * recorded connect home first (device-scoped, survives an expired credential),
53
+ * else a currently-signed-in local home carrying its identity. Returns null when
54
+ * neither is known — a legacy account with no recorded or live home, for which
55
+ * connect adopts a fresh home.
56
+ */
57
+ export declare function resolveExistingHomeLabel(existing: NativeAccount, deviceHome: string | null, signedInHomes: Array<{
58
+ agent: AgentId;
59
+ identityKey: string;
60
+ label: string;
61
+ }>): string | null;
62
+ /**
63
+ * Decide the release-independent connect plan (pure). `existing` is the native
64
+ * account the name resolves to for THIS harness (or null for a new connect);
65
+ * `existingHomeLabel` is its reusable home from {@link resolveExistingHomeLabel};
66
+ * `freshSlot` is a safely-allocated opaque slot (see `allocateConnectSlot`) used
67
+ * for a new connect or an adopted reconnect home — NEVER a name-derived label.
68
+ */
69
+ export declare function planConnect(input: {
70
+ agent: AgentId;
71
+ name?: string;
72
+ existing: NativeAccount | null;
73
+ existingHomeLabel: string | null;
74
+ freshSlot: string;
75
+ }): ConnectPlan;
76
+ /**
77
+ * Safely allocate the opaque slot for a NEW connect or an ADOPTED reconnect home
78
+ * (PHNX-3940 security fix). Never reuses an identity-bearing slot:
79
+ *
80
+ * - `occupied` is every slot already owned by an account's home OR currently
81
+ * signed in — an allocated slot is guaranteed disjoint from it, so a new
82
+ * connect can never land on another account's home (the rename-collision flaw).
83
+ * - A named connect first reuses its device-scoped PENDING slot (a prior
84
+ * failed/cancelled attempt) IF that slot is not occupied, so a retry lands in
85
+ * the same fresh home instead of orphaning a new one.
86
+ * - Otherwise it mints random slots until one is neither occupied nor installed.
87
+ */
88
+ export declare function allocateConnectSlot(input: {
89
+ agent: AgentId;
90
+ name?: string;
91
+ existing: NativeAccount | null;
92
+ occupied: ReadonlySet<string>;
93
+ installedLabels: ReadonlySet<string>;
94
+ pending: string | null;
95
+ mint?: () => string;
96
+ }): string;
97
+ /**
98
+ * Fail-closed identity check after the login completes (pure).
99
+ *
100
+ * - Not signed in (no live credential) → the login did not complete; a metadata
101
+ * identity key alone is NOT proof, so `signedIn` is required.
102
+ * - Reconnect whose completed identity differs from the account's → REFUSE
103
+ * registering the binding. The native credential in that home DID change (the
104
+ * user signed in), so the message says the account BINDING is left unchanged —
105
+ * it does not claim nothing happened.
106
+ * A new connect accepts whatever identity signed in (that is the account being
107
+ * created); the caller registers it.
108
+ */
109
+ export declare function verifyConnectedIdentity(plan: ConnectPlan, observed: Pick<ObservedIdentity, 'identityKey' | 'signedIn'>): void;
110
+ /** Resolve the existing native account a connect `name` refers to for a harness. */
111
+ export declare function findConnectAccount(agent: AgentId, name: string | undefined, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): NativeAccount | null;
112
+ /** The observed identity of a completed login. */
113
+ export interface ObservedIdentity {
114
+ identityKey: string | null;
115
+ email: string | null;
116
+ releaseVersion: string | null;
117
+ /** Live credential presence — proof of sign-in, not just a metadata identity claim. */
118
+ signedIn: boolean;
119
+ }
120
+ /**
121
+ * Side-effecting operations connect needs, injected so the planning/verification
122
+ * path is unit-testable and the parent can own the real E2E login+verify.
123
+ */
124
+ export interface ConnectRunners {
125
+ installedLabels(agent: AgentId): string[];
126
+ /** Install the CURRENT release into `label`'s isolated home (opaque label). */
127
+ install(agent: AgentId, label: string, onProgress?: (m: string) => void): Promise<{
128
+ success: boolean;
129
+ error?: string;
130
+ }>;
131
+ /** Launch the harness's native login under `label`'s home; resolves on exit. */
132
+ launchLogin(agent: AgentId, label: string, invocation: LoginInvocation, email?: string, signal?: AbortSignal): Promise<{
133
+ code: number | null;
134
+ }>;
135
+ observeIdentity(agent: AgentId, label: string): Promise<ObservedIdentity>;
136
+ signedInHomes(): Promise<Array<{
137
+ agent: AgentId;
138
+ identityKey: string;
139
+ label: string;
140
+ }>>;
141
+ }
142
+ export interface ConnectResult {
143
+ mode: ConnectMode;
144
+ agent: AgentId;
145
+ label: string;
146
+ name?: string;
147
+ identityKey: string;
148
+ email: string | null;
149
+ releaseVersion: string | null;
150
+ /** True when this connect became the harness's default (only set if none was configured). */
151
+ becameDefault: boolean;
152
+ /** For an UNNAMED connect: the command to name the login (no name was forced). */
153
+ nameHint?: string;
154
+ }
155
+ /**
156
+ * Drive one `agents accounts connect`. Pure decisions (plan, fail-closed verify)
157
+ * come from the helpers above; the install + login + identity read are injected.
158
+ *
159
+ * The default runners are loaded via dynamic import so this module — reachable
160
+ * from `account-registry` consumers — never statically pulls in the install
161
+ * engine or exec path and closes an import cycle (the intended pattern here).
162
+ *
163
+ * `stateDir` is only for tests: override the runtime-state dir used by the
164
+ * per-harness auth-operation mutex (defaults to the real runtime state dir).
165
+ */
166
+ export declare function runConnect(agent: AgentId, name: string | undefined, opts: {
167
+ meta: Pick<Meta, 'accounts' | 'deviceAccounts'>;
168
+ onProgress?: (m: string) => void;
169
+ stateDir?: string;
170
+ }, runners?: ConnectRunners): Promise<ConnectResult>;