@phnx-labs/agents-cli 1.22.52 → 1.22.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +336 -0
- package/README.md +42 -9
- package/dist/bootstrap.js +55 -154
- package/dist/cli/command-registry.d.ts +5 -0
- package/dist/cli/command-registry.js +8 -1
- package/dist/commands/accounts.js +220 -174
- package/dist/commands/apply.js +6 -3
- package/dist/commands/auth-mint.d.ts +8 -0
- package/dist/commands/auth-mint.js +96 -0
- package/dist/commands/auth.js +5 -1
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cost.js +8 -2
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/exec.js +26 -17
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.d.ts +1 -0
- package/dist/commands/focus.js +4 -2
- package/dist/commands/go.d.ts +5 -4
- package/dist/commands/go.js +8 -7
- package/dist/commands/insights.js +9 -0
- package/dist/commands/monitors.js +85 -30
- package/dist/commands/output.js +8 -2
- package/dist/commands/repo.js +18 -0
- package/dist/commands/secrets.js +33 -14
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions.d.ts +20 -12
- package/dist/commands/sessions.js +94 -40
- package/dist/commands/setup-accounts.d.ts +8 -0
- package/dist/commands/setup-accounts.js +47 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +11 -2
- package/dist/commands/share.d.ts +52 -3
- package/dist/commands/share.js +262 -18
- package/dist/commands/ssh.d.ts +7 -0
- package/dist/commands/ssh.js +53 -14
- package/dist/commands/status.js +14 -0
- package/dist/commands/sync.js +44 -0
- package/dist/commands/view.d.ts +3 -1
- package/dist/commands/view.js +5 -4
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +165 -53
- package/dist/lib/accounting/rotate.d.ts +20 -6
- package/dist/lib/accounting/rotate.js +38 -7
- package/dist/lib/accounting/usage.d.ts +37 -1
- package/dist/lib/accounting/usage.js +71 -6
- package/dist/lib/agent-spec/agents.d.ts +5 -2
- package/dist/lib/agent-spec/agents.js +25 -7
- package/dist/lib/analytics/mix-commands.js +12 -6
- package/dist/lib/answer-router.js +2 -1
- package/dist/lib/auth-mint.d.ts +150 -0
- package/dist/lib/auth-mint.js +434 -0
- package/dist/lib/browser/profiles.d.ts +18 -0
- package/dist/lib/browser/profiles.js +26 -1
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- package/dist/lib/browser/remote-control.d.ts +9 -7
- package/dist/lib/browser/remote-control.js +9 -7
- package/dist/lib/claude-account-token.d.ts +10 -0
- package/dist/lib/claude-account-token.js +14 -4
- package/dist/lib/config-drift.d.ts +37 -0
- package/dist/lib/config-drift.js +72 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
- package/dist/lib/daemon/auth-sync-service.js +34 -0
- package/dist/lib/daemon/daemon.js +30 -4
- package/dist/lib/daemon/runner.js +10 -2
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +3 -3
- package/dist/lib/device-config.js +8 -7
- package/dist/lib/devices/config-migration.js +147 -1
- package/dist/lib/devices/connect.d.ts +26 -0
- package/dist/lib/devices/connect.js +48 -1
- package/dist/lib/devices/device-docs.d.ts +35 -0
- package/dist/lib/devices/device-docs.js +163 -0
- package/dist/lib/devices/discovery-policy.d.ts +14 -2
- package/dist/lib/devices/discovery-policy.js +31 -21
- package/dist/lib/devices/doctor-findings.d.ts +5 -1
- package/dist/lib/devices/doctor-findings.js +19 -1
- package/dist/lib/devices/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +88 -30
- package/dist/lib/exec.js +138 -34
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +35 -2
- package/dist/lib/feed-broadcast.js +1 -1
- package/dist/lib/fleet/apply.d.ts +11 -0
- package/dist/lib/fleet/apply.js +23 -3
- package/dist/lib/fleet/auth-sync.js +5 -3
- package/dist/lib/help.d.ts +9 -0
- package/dist/lib/help.js +29 -1
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- package/dist/lib/hosts/passthrough.d.ts +1 -10
- package/dist/lib/hosts/passthrough.js +1 -13
- package/dist/lib/hosts/providers/local.d.ts +9 -3
- package/dist/lib/hosts/providers/local.js +23 -12
- package/dist/lib/hosts/reconnect.d.ts +7 -4
- package/dist/lib/hosts/reconnect.js +29 -25
- package/dist/lib/hosts/registry.js +4 -1
- package/dist/lib/hosts/remote-os.js +3 -1
- package/dist/lib/installations/versions.js +9 -1
- package/dist/lib/linux-userns.d.ts +58 -0
- package/dist/lib/linux-userns.js +116 -0
- package/dist/lib/memory.d.ts +26 -0
- package/dist/lib/memory.js +80 -1
- package/dist/lib/monitors/config.d.ts +11 -0
- package/dist/lib/monitors/config.js +8 -0
- package/dist/lib/monitors/engine.js +8 -1
- package/dist/lib/monitors/state.d.ts +37 -1
- package/dist/lib/monitors/state.js +79 -4
- package/dist/lib/permissions-registry.d.ts +2 -0
- package/dist/lib/permissions-registry.js +116 -14
- package/dist/lib/permissions.d.ts +5 -3
- package/dist/lib/permissions.js +25 -27
- package/dist/lib/profiles.d.ts +8 -7
- package/dist/lib/profiles.js +12 -0
- package/dist/lib/project-key.d.ts +9 -0
- package/dist/lib/project-key.js +11 -0
- package/dist/lib/secrets/bundles.d.ts +35 -0
- package/dist/lib/secrets/bundles.js +78 -1
- package/dist/lib/secrets/push.d.ts +3 -8
- package/dist/lib/secrets/push.js +18 -14
- package/dist/lib/secrets/remote.d.ts +9 -18
- package/dist/lib/secrets/remote.js +11 -26
- package/dist/lib/secrets/reserved-sync.d.ts +65 -0
- package/dist/lib/secrets/reserved-sync.js +129 -0
- package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
- package/dist/lib/self-heal/registry.js +4 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +8 -5
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +39 -4
- package/dist/lib/session/db.js +168 -31
- package/dist/lib/session/discover.d.ts +32 -4
- package/dist/lib/session/discover.js +126 -37
- package/dist/lib/session/insights.d.ts +14 -0
- package/dist/lib/session/insights.js +25 -2
- package/dist/lib/session/linear.js +1 -1
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/pid-registry.d.ts +7 -0
- package/dist/lib/session/prompt.d.ts +15 -0
- package/dist/lib/session/prompt.js +21 -0
- package/dist/lib/session/shell-programs.d.ts +17 -0
- package/dist/lib/session/shell-programs.js +21 -0
- package/dist/lib/session/state.js +2 -1
- package/dist/lib/session/stream-render.js +2 -1
- package/dist/lib/session/tool-calls.js +2 -5
- package/dist/lib/session/trajectory-html.js +2 -1
- package/dist/lib/session/trajectory.js +3 -12
- package/dist/lib/session/types.d.ts +25 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/publish.d.ts +53 -5
- package/dist/lib/share/publish.js +99 -17
- package/dist/lib/share/worker-template.js +594 -64
- package/dist/lib/startup/root-command.js +2 -1
- package/dist/lib/state.d.ts +24 -0
- package/dist/lib/state.js +318 -54
- package/dist/lib/sync-status.d.ts +4 -0
- package/dist/lib/sync-status.js +3 -0
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- package/dist/lib/traces/classify.js +24 -19
- package/dist/lib/traces/insights.d.ts +67 -0
- package/dist/lib/traces/insights.js +178 -0
- package/dist/lib/traces/phenotype.d.ts +67 -0
- package/dist/lib/traces/phenotype.js +437 -0
- package/dist/lib/traces/segments.d.ts +133 -0
- package/dist/lib/traces/segments.js +301 -0
- package/dist/lib/traces/sync.d.ts +33 -0
- package/dist/lib/traces/sync.js +11 -2
- package/dist/lib/types.d.ts +47 -1
- package/dist/lib/usage-refresh.js +2 -1
- package/dist/lib/view-types.d.ts +2 -0
- package/dist/lib/watchdog/runner.js +18 -4
- package/package.json +2 -1
package/dist/commands/sync.js
CHANGED
|
@@ -35,6 +35,8 @@ import { Option } from 'commander';
|
|
|
35
35
|
import chalk from 'chalk';
|
|
36
36
|
import { resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
|
|
37
37
|
import { agentLabel, resolveAgentName, MANAGED_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
|
|
38
|
+
import { autoEvictCentralBrowserProfiles } from '../lib/browser/registry.js';
|
|
39
|
+
import { shouldAutoClaimCentralProfile } from '../lib/browser/profiles.js';
|
|
38
40
|
import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, healDanglingVersionPointers, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildSelection, mergeRepoScopedSelections, listRepoNames, getVersionHomePath, } from '../lib/installations/versions.js';
|
|
39
41
|
import { capableAgents } from '../lib/capabilities.js';
|
|
40
42
|
import { parseHookManifest, registerHooksToSettings } from '../lib/hooks/install.js';
|
|
@@ -352,6 +354,12 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
|
|
|
352
354
|
else
|
|
353
355
|
outLog(chalk.yellow(` ! ${repo}: ${(res.error ?? 'pull failed').split('\n')[0]}`));
|
|
354
356
|
}
|
|
357
|
+
// Drain the legacy central `browser:` tombstone now that the repos are pulled.
|
|
358
|
+
// Running AFTER the pull is load-bearing: it acts on a view of central that
|
|
359
|
+
// already reflects peers' drains, so it never re-claims a profile another box
|
|
360
|
+
// already migrated (which would flip that profile's kind identity->fungible).
|
|
361
|
+
// The interactive path is always non-quiet, non-json.
|
|
362
|
+
evictCentralBrowserProfilesForSync(false, false, outLog, errLog);
|
|
355
363
|
// 2. One selection spanning the chosen repos.
|
|
356
364
|
const selection = mergeRepoScopedSelections(repos, cwd);
|
|
357
365
|
const hasResources = selection.memory === 'all' || Object.entries(selection).some(([kind, v]) => kind !== 'memory' && Array.isArray(v) && v.length > 0);
|
|
@@ -374,6 +382,36 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
|
|
|
374
382
|
}
|
|
375
383
|
}
|
|
376
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Drain the legacy central `browser:` tombstone during `agents sync` (PHNX-3315).
|
|
387
|
+
* New profiles write the per-device doc, but profiles created before the
|
|
388
|
+
* device-scoped store lingered in the shared top-level `agents.yaml` and churned
|
|
389
|
+
* every fleet pull until someone ran `agents browser profiles claim` by hand.
|
|
390
|
+
* Fold the ones THIS box can host into its device doc — host-gated, and the
|
|
391
|
+
* selection is computed under the meta lock (see autoEvictCentralBrowserProfiles)
|
|
392
|
+
* so it never races itself. Callers MUST invoke this AFTER the repo pull: acting
|
|
393
|
+
* on a pre-pull view of central risks re-claiming a profile a peer already
|
|
394
|
+
* drained, which would flip its kind identity->fungible. Non-fatal so a hiccup
|
|
395
|
+
* can never wedge the sync.
|
|
396
|
+
*/
|
|
397
|
+
function evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog) {
|
|
398
|
+
try {
|
|
399
|
+
// Auto-claim ONLY remote (ssh://) tombstones — they are fungible by design,
|
|
400
|
+
// so a concurrent cross-machine double-claim is harmless. Local/cdp profiles
|
|
401
|
+
// have no per-machine ownership signal and are left central for an explicit
|
|
402
|
+
// `agents browser profiles claim` (PHNX-3315 review). See
|
|
403
|
+
// shouldAutoClaimCentralProfile.
|
|
404
|
+
const result = autoEvictCentralBrowserProfiles(shouldAutoClaimCentralProfile);
|
|
405
|
+
if (!quiet && !json && result.claimed.length > 0) {
|
|
406
|
+
outLog(chalk.gray(` Claimed ${result.claimed.length} central browser profile(s) into this device: ${result.claimed.join(', ')}`));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
if (!quiet && !json) {
|
|
411
|
+
errLog(chalk.yellow(` ! browser profile eviction skipped: ${err.message}`));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
377
415
|
/**
|
|
378
416
|
* The umbrella verb: bare `agents sync` (no agent) makes this machine current.
|
|
379
417
|
* Resolves the flags + a secrets passphrase (env-only for now; tokenized auth
|
|
@@ -415,6 +453,12 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
|
|
|
415
453
|
log: (msg) => { if (!quiet && !json)
|
|
416
454
|
outLog(chalk.gray(` ${msg}`)); },
|
|
417
455
|
});
|
|
456
|
+
// Drain the legacy central `browser:` tombstone AFTER the umbrella pull, so
|
|
457
|
+
// we act on central as converged by this sync rather than a stale pre-pull
|
|
458
|
+
// copy that could re-claim a profile a peer already migrated. Skipped under
|
|
459
|
+
// --cloud (fetch-only, no local reconcile).
|
|
460
|
+
if (!opts.cloud)
|
|
461
|
+
evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog);
|
|
418
462
|
if (json) {
|
|
419
463
|
emitJson({
|
|
420
464
|
// A refused resource is not a clean sync (RUSH-2700).
|
package/dist/commands/view.d.ts
CHANGED
|
@@ -81,7 +81,9 @@ export declare function parseResourceSections(options: {
|
|
|
81
81
|
* agents-cli extension's "resume current session in best available version"
|
|
82
82
|
* command).
|
|
83
83
|
*/
|
|
84
|
-
export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSections?: Set<ResourceSection
|
|
84
|
+
export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSections?: Set<ResourceSection>, opts?: {
|
|
85
|
+
forceRefresh?: boolean;
|
|
86
|
+
}): Promise<ViewJsonAgent[]>;
|
|
85
87
|
/**
|
|
86
88
|
* Identity key for duplicate-install detection. Prefers accountKey — which
|
|
87
89
|
* encodes account AND org — over the bare email: two installs can share an
|
package/dist/commands/view.js
CHANGED
|
@@ -1209,7 +1209,7 @@ export function parseResourceSections(options, jsonMode) {
|
|
|
1209
1209
|
* agents-cli extension's "resume current session in best available version"
|
|
1210
1210
|
* command).
|
|
1211
1211
|
*/
|
|
1212
|
-
export async function collectAgentsJson(filterAgentId, resourceSections) {
|
|
1212
|
+
export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
|
|
1213
1213
|
const agentsToShow = filterAgentId ? [filterAgentId] : ALL_AGENT_IDS;
|
|
1214
1214
|
const authCache = readAuthHealthCache();
|
|
1215
1215
|
const host = machineId();
|
|
@@ -1230,7 +1230,7 @@ export async function collectAgentsJson(filterAgentId, resourceSections) {
|
|
|
1230
1230
|
home,
|
|
1231
1231
|
cliVersion: version,
|
|
1232
1232
|
info,
|
|
1233
|
-
})));
|
|
1233
|
+
})), { forceRefresh: opts?.forceRefresh === true });
|
|
1234
1234
|
const mergeCanonical = (info) => {
|
|
1235
1235
|
const key = getUsageLookupKey(info);
|
|
1236
1236
|
if (!key)
|
|
@@ -1273,6 +1273,7 @@ export async function collectAgentsJson(filterAgentId, resourceSections) {
|
|
|
1273
1273
|
plan: info.plan,
|
|
1274
1274
|
usageStatus: info.usageStatus,
|
|
1275
1275
|
overageCredits: info.overageCredits,
|
|
1276
|
+
usageError: usageInfo?.error ?? null,
|
|
1276
1277
|
windows: snapshot
|
|
1277
1278
|
? snapshot.windows.map((w) => ({
|
|
1278
1279
|
key: w.key,
|
|
@@ -1562,7 +1563,7 @@ export async function viewAction(agentArg, options) {
|
|
|
1562
1563
|
return;
|
|
1563
1564
|
}
|
|
1564
1565
|
if (json) {
|
|
1565
|
-
const data = await collectAgentsJson(undefined, resourceSections);
|
|
1566
|
+
const data = await collectAgentsJson(undefined, resourceSections, { forceRefresh });
|
|
1566
1567
|
console.log(JSON.stringify(data, null, 2));
|
|
1567
1568
|
return;
|
|
1568
1569
|
}
|
|
@@ -1624,7 +1625,7 @@ export async function viewAction(agentArg, options) {
|
|
|
1624
1625
|
if (json) {
|
|
1625
1626
|
// --json ignores the @version suffix, but --resources/--detailed (or a
|
|
1626
1627
|
// section flag) now attach each version's resource inventory + sync-state.
|
|
1627
|
-
const data = await collectAgentsJson(agentId, resourceSections);
|
|
1628
|
+
const data = await collectAgentsJson(agentId, resourceSections, { forceRefresh });
|
|
1628
1629
|
console.log(JSON.stringify(data[0] ?? { agent: agentId, versions: [], harnesses: [] }, null, 2));
|
|
1629
1630
|
return;
|
|
1630
1631
|
}
|
|
@@ -34,7 +34,15 @@ export type UnifiedAccount = (CredentialAccount & {
|
|
|
34
34
|
export declare function accountRegistryPath(base?: string): string;
|
|
35
35
|
export declare function readAccountRegistry(base?: string): AccountRegistryDocument;
|
|
36
36
|
export declare function findAccount(name: string, doc?: AccountRegistryDocument): CredentialAccount | null;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Effective native accounts: the fleet-shared central store (version-scoped
|
|
39
|
+
* identities) merged with THIS box's own device doc (device-scoped identities).
|
|
40
|
+
* A native login is machine-local — its home follows its scope (PHNX-3315), so
|
|
41
|
+
* a `scope:'device'` identity is read from this box's device doc and never the
|
|
42
|
+
* shared central agents.yaml (which is where its email/identityKey PII used to
|
|
43
|
+
* accumulate). On an id collision the device slice wins.
|
|
44
|
+
*/
|
|
45
|
+
export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): NativeAccount[];
|
|
38
46
|
/**
|
|
39
47
|
* Resolve one account by name or id across both stores, native first.
|
|
40
48
|
*
|
|
@@ -46,15 +54,17 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts'>): Native
|
|
|
46
54
|
* running before the body, so callers that only need a native lookup must be
|
|
47
55
|
* able to omit it.
|
|
48
56
|
*/
|
|
49
|
-
export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
|
|
57
|
+
export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
|
|
50
58
|
export declare function addNativeAccount(name: string, agent: AgentId, identityKey: string, identityLabel: string | undefined, scope: 'version' | 'device'): NativeAccount;
|
|
51
59
|
/** Create or replace the version-independent label for one native identity. */
|
|
52
60
|
export declare function labelNativeAccount(agent: AgentId, identityKey: string, identityLabel: string | undefined, label: string | undefined, scope: 'version' | 'device'): NativeAccount;
|
|
53
61
|
export declare function bindAccount(nameOrId: string, target: string): UnifiedAccount;
|
|
54
62
|
export declare function unbindAccount(nameOrId: string, target: string): void;
|
|
55
|
-
|
|
63
|
+
/** Every target bound to `accountId`: this box's device-doc bindings merged over
|
|
64
|
+
* the fleet-shared central bindings (PHNX-3315). */
|
|
65
|
+
export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
|
|
56
66
|
/** Explicit selection wins over a configured per-harness default. */
|
|
57
|
-
export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts'>, opts?: {
|
|
67
|
+
export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
|
|
58
68
|
useDefault?: boolean;
|
|
59
69
|
target?: string;
|
|
60
70
|
}): string | undefined;
|
|
@@ -100,7 +110,7 @@ export type SpawnAccount = {
|
|
|
100
110
|
* A native account bound to a different harness than the one being launched
|
|
101
111
|
* fails loudly (EXEC-ACCOUNT-4). Returns null when nothing is selected.
|
|
102
112
|
*/
|
|
103
|
-
export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts'>, opts?: {
|
|
113
|
+
export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
|
|
104
114
|
useDefault?: boolean;
|
|
105
115
|
provider?: string;
|
|
106
116
|
base?: string;
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* `{ version: 2, accounts }` view so existing consumers (harness, profiles,
|
|
13
13
|
* exec) keep working — it is now a projection over the account bundles, not a
|
|
14
14
|
* file read. Native OAuth logins are NOT accounts here; they stay native and
|
|
15
|
-
* surface through unified discovery in [[account-catalog]].
|
|
15
|
+
* surface through unified discovery in [[account-catalog]]. Labels bind to
|
|
16
|
+
* `(agent, identityKey)` on the central `accounts.native` rows in agents.yaml,
|
|
17
|
+
* which `agents repo push/pull` already syncs fleet-wide.
|
|
16
18
|
*/
|
|
17
19
|
import * as fs from 'node:fs';
|
|
18
20
|
import * as path from 'node:path';
|
|
@@ -21,9 +23,9 @@ import * as yaml from 'yaml';
|
|
|
21
23
|
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
22
24
|
import { getUserAgentsDir, readMeta, updateMeta } from './state.js';
|
|
23
25
|
import { deleteKeychainToken, getKeychainToken, hasKeychainToken } from './secrets/index.js';
|
|
24
|
-
import { bundleExists, deleteBundle, listBundles, readBundle, renameBundle, writeBundleWithItems } from './secrets/bundles.js';
|
|
26
|
+
import { bundleExists, deleteBundle, listBundles, readAndResolveBundleEnv, readBundle, renameBundle, writeBundleWithItems } from './secrets/bundles.js';
|
|
25
27
|
import { getAccountProvider } from './account-provider-registry.js';
|
|
26
|
-
import { accountSecretItem, buildAccountBundle, parseAccountBundle } from './account-schema.js';
|
|
28
|
+
import { accountSecretItem, buildAccountBundle, parseAccountBundle, secretVarFor } from './account-schema.js';
|
|
27
29
|
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
28
30
|
const NATIVE_LABEL = /^[a-zA-Z0-9][a-zA-Z0-9@._+-]*$/;
|
|
29
31
|
const AUTH_KINDS = ['api-key', 'setup-token', 'bearer-token'];
|
|
@@ -133,8 +135,17 @@ export function readAccountRegistry(base = getUserAgentsDir()) {
|
|
|
133
135
|
export function findAccount(name, doc = readAccountRegistry()) {
|
|
134
136
|
return doc.accounts[name] ?? Object.values(doc.accounts).find(account => account.name === name) ?? null;
|
|
135
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Effective native accounts: the fleet-shared central store (version-scoped
|
|
140
|
+
* identities) merged with THIS box's own device doc (device-scoped identities).
|
|
141
|
+
* A native login is machine-local — its home follows its scope (PHNX-3315), so
|
|
142
|
+
* a `scope:'device'` identity is read from this box's device doc and never the
|
|
143
|
+
* shared central agents.yaml (which is where its email/identityKey PII used to
|
|
144
|
+
* accumulate). On an id collision the device slice wins.
|
|
145
|
+
*/
|
|
136
146
|
export function listNativeAccounts(meta) {
|
|
137
|
-
|
|
147
|
+
const merged = { ...meta.accounts?.native, ...meta.deviceAccounts?.native };
|
|
148
|
+
return Object.values(merged).map(account => ({ ...account, kind: 'native' }));
|
|
138
149
|
}
|
|
139
150
|
/**
|
|
140
151
|
* Resolve one account by name or id across both stores, native first.
|
|
@@ -155,8 +166,28 @@ export function findUnifiedAccount(nameOrId, meta, doc) {
|
|
|
155
166
|
const provider = findAccount(nameOrId, doc ?? readAccountRegistry());
|
|
156
167
|
return provider ? { ...provider, kind: 'provider' } : null;
|
|
157
168
|
}
|
|
158
|
-
function
|
|
159
|
-
|
|
169
|
+
function nativeIdentityRows(meta, agent, identityKey) {
|
|
170
|
+
return listNativeAccounts(meta).filter(account => account.agent === agent && account.identityKey === identityKey);
|
|
171
|
+
}
|
|
172
|
+
/** Every row (central + this box's device store) for the identity that `name`
|
|
173
|
+
* (id or account name) resolves to. */
|
|
174
|
+
function nativeRowsForNameOrId(meta, name) {
|
|
175
|
+
const found = listNativeAccounts(meta).find(account => account.id === name || account.name === name);
|
|
176
|
+
if (!found)
|
|
177
|
+
return [];
|
|
178
|
+
return nativeIdentityRows(meta, found.agent, found.identityKey);
|
|
179
|
+
}
|
|
180
|
+
function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
|
|
181
|
+
const needle = name.toLowerCase();
|
|
182
|
+
const nativeHits = listNativeAccounts(meta).filter(account => account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
|
|
183
|
+
if (nativeHits.some(account => !exceptIds?.has(account.id)))
|
|
184
|
+
throw new Error(`Account '${name}' already exists.`);
|
|
185
|
+
// Same laziness as findUnifiedAccount: a native row that already owns this
|
|
186
|
+
// name (even one we are mutating) means we never open the provider store.
|
|
187
|
+
if (nativeHits.length > 0)
|
|
188
|
+
return;
|
|
189
|
+
const provider = findAccount(name, doc ?? readAccountRegistry());
|
|
190
|
+
if (provider && !exceptIds?.has(provider.id))
|
|
160
191
|
throw new Error(`Account '${name}' already exists.`);
|
|
161
192
|
}
|
|
162
193
|
export function addNativeAccount(name, agent, identityKey, identityLabel, scope) {
|
|
@@ -167,13 +198,28 @@ export function addNativeAccount(name, agent, identityKey, identityLabel, scope)
|
|
|
167
198
|
if (duplicate)
|
|
168
199
|
throw new Error(`This ${agent} login is already named '${duplicate.name}'.`);
|
|
169
200
|
const account = { id: crypto.randomUUID(), name, kind: 'native', agent, identityKey, identityLabel, scope };
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
201
|
+
const entry = { id: account.id, name, agent, identityKey, identityLabel, scope };
|
|
202
|
+
// A native login's home follows its scope (PHNX-3315): a device-scoped
|
|
203
|
+
// identity lands in THIS box's device doc (its PII never touches the shared
|
|
204
|
+
// central agents.yaml); a version-scoped one stays in the fleet-shared store.
|
|
205
|
+
if (scope === 'device') {
|
|
206
|
+
updateMeta(current => ({
|
|
207
|
+
...current,
|
|
208
|
+
deviceAccounts: {
|
|
209
|
+
...current.deviceAccounts,
|
|
210
|
+
native: { ...current.deviceAccounts?.native, [account.id]: entry },
|
|
211
|
+
},
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
updateMeta(current => ({
|
|
216
|
+
...current,
|
|
217
|
+
accounts: {
|
|
218
|
+
...current.accounts,
|
|
219
|
+
native: { ...current.accounts?.native, [account.id]: entry },
|
|
220
|
+
},
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
177
223
|
return account;
|
|
178
224
|
}
|
|
179
225
|
/** Create or replace the version-independent label for one native identity. */
|
|
@@ -183,30 +229,49 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
|
|
|
183
229
|
throw new Error(`${agent} does not expose an email; pass a manual label.`);
|
|
184
230
|
assertNativeLabel(resolvedLabel);
|
|
185
231
|
const meta = readMeta();
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
throw new Error(`Account '${resolvedLabel}' already exists.`);
|
|
190
|
-
if (!existing)
|
|
232
|
+
const matches = nativeIdentityRows(meta, agent, identityKey);
|
|
233
|
+
assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)));
|
|
234
|
+
if (matches.length === 0)
|
|
191
235
|
return addNativeAccount(resolvedLabel, agent, identityKey, identityLabel, scope);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
236
|
+
// Sweep every row for this identity (PHNX-3206), routing the whole sweep to the
|
|
237
|
+
// store that owns them: all rows for one identityKey share a scope (same agent),
|
|
238
|
+
// so a device-scoped login lands in this box's device doc, a version-scoped one
|
|
239
|
+
// in central (PHNX-3315).
|
|
240
|
+
const rowScope = matches[0].scope;
|
|
241
|
+
updateMeta(current => {
|
|
242
|
+
if (rowScope === 'device') {
|
|
243
|
+
const native = { ...current.deviceAccounts?.native };
|
|
244
|
+
for (const row of matches)
|
|
245
|
+
native[row.id] = { id: row.id, name: resolvedLabel, agent, identityKey, identityLabel, scope: rowScope };
|
|
246
|
+
return { ...current, deviceAccounts: { ...current.deviceAccounts, native } };
|
|
247
|
+
}
|
|
248
|
+
const native = { ...current.accounts?.native };
|
|
249
|
+
for (const row of matches)
|
|
250
|
+
native[row.id] = { id: row.id, name: resolvedLabel, agent, identityKey, identityLabel, scope: rowScope };
|
|
251
|
+
return { ...current, accounts: { ...current.accounts, native } };
|
|
252
|
+
});
|
|
253
|
+
return { ...matches[0], name: resolvedLabel, identityLabel, scope: rowScope };
|
|
200
254
|
}
|
|
201
255
|
export function bindAccount(nameOrId, target) {
|
|
202
256
|
const meta = readMeta();
|
|
203
257
|
const account = findUnifiedAccount(nameOrId, meta);
|
|
204
258
|
if (!account)
|
|
205
259
|
throw new Error(`Unknown account '${nameOrId}'.`);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
260
|
+
// A binding follows its account: one that targets a device-scoped native login
|
|
261
|
+
// is itself machine-local and lands in this box's device doc (PHNX-3315);
|
|
262
|
+
// every other binding stays fleet-shared in central.
|
|
263
|
+
if (account.kind === 'native' && account.scope === 'device') {
|
|
264
|
+
updateMeta(current => ({
|
|
265
|
+
...current,
|
|
266
|
+
deviceAccounts: { ...current.deviceAccounts, bindings: { ...current.deviceAccounts?.bindings, [target]: account.id } },
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
updateMeta(current => ({
|
|
271
|
+
...current,
|
|
272
|
+
accounts: { ...current.accounts, bindings: { ...current.accounts?.bindings, [target]: account.id } },
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
210
275
|
return account;
|
|
211
276
|
}
|
|
212
277
|
export function unbindAccount(nameOrId, target) {
|
|
@@ -214,25 +279,43 @@ export function unbindAccount(nameOrId, target) {
|
|
|
214
279
|
const account = findUnifiedAccount(nameOrId, meta);
|
|
215
280
|
if (!account)
|
|
216
281
|
throw new Error(`Unknown account '${nameOrId}'.`);
|
|
217
|
-
|
|
282
|
+
const inCentral = meta.accounts?.bindings?.[target] === account.id;
|
|
283
|
+
const inDevice = meta.deviceAccounts?.bindings?.[target] === account.id;
|
|
284
|
+
if (!inCentral && !inDevice)
|
|
218
285
|
throw new Error(`Account '${account.name}' is not attached to '${target}'.`);
|
|
219
286
|
updateMeta(current => {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
287
|
+
let next = current;
|
|
288
|
+
if (current.accounts?.bindings?.[target] === account.id) {
|
|
289
|
+
const bindings = { ...current.accounts?.bindings };
|
|
290
|
+
delete bindings[target];
|
|
291
|
+
next = { ...next, accounts: { ...next.accounts, bindings } };
|
|
292
|
+
}
|
|
293
|
+
if (current.deviceAccounts?.bindings?.[target] === account.id) {
|
|
294
|
+
const bindings = { ...current.deviceAccounts?.bindings };
|
|
295
|
+
delete bindings[target];
|
|
296
|
+
next = { ...next, deviceAccounts: { ...next.deviceAccounts, bindings } };
|
|
297
|
+
}
|
|
298
|
+
return next;
|
|
223
299
|
});
|
|
224
300
|
}
|
|
301
|
+
/** Every target bound to `accountId`: this box's device-doc bindings merged over
|
|
302
|
+
* the fleet-shared central bindings (PHNX-3315). */
|
|
225
303
|
export function accountBindings(accountId, meta) {
|
|
226
|
-
|
|
304
|
+
const merged = { ...meta.accounts?.bindings, ...meta.deviceAccounts?.bindings };
|
|
305
|
+
return Object.entries(merged).filter(([, id]) => id === accountId).map(([target]) => target).sort();
|
|
227
306
|
}
|
|
228
307
|
/** Explicit selection wins over a configured per-harness default. */
|
|
229
308
|
export function resolveAccountSelection(explicit, agent, meta, opts = {}) {
|
|
230
309
|
if (explicit)
|
|
231
310
|
return explicit;
|
|
232
|
-
|
|
311
|
+
// This box's device-doc bindings win over the fleet-shared central bindings
|
|
312
|
+
// (PHNX-3315), so a per-box account attachment resolves without touching the
|
|
313
|
+
// shared file. Defaults are genuinely fleet-shared and stay central.
|
|
314
|
+
const bindings = { ...meta.accounts?.bindings, ...meta.deviceAccounts?.bindings };
|
|
315
|
+
const bound = opts.target ? bindings[opts.target] : undefined;
|
|
233
316
|
if (bound)
|
|
234
317
|
return bound;
|
|
235
|
-
const deviceScoped =
|
|
318
|
+
const deviceScoped = bindings[agent];
|
|
236
319
|
if (deviceScoped)
|
|
237
320
|
return deviceScoped;
|
|
238
321
|
return opts.useDefault === false ? undefined : meta.accounts?.defaults?.[agent];
|
|
@@ -285,16 +368,23 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
|
|
|
285
368
|
assertName(newName);
|
|
286
369
|
const doc = readAccountRegistry(base);
|
|
287
370
|
const meta = readMeta();
|
|
288
|
-
const
|
|
289
|
-
if (
|
|
290
|
-
assertUniqueUnifiedName(newName, meta, doc);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
371
|
+
const rows = nativeRowsForNameOrId(meta, oldName);
|
|
372
|
+
if (rows.length) {
|
|
373
|
+
assertUniqueUnifiedName(newName, meta, doc, new Set(rows.map(account => account.id)));
|
|
374
|
+
// Sweep every row for the identity (PHNX-3206) in its owning store (PHNX-3315).
|
|
375
|
+
const rowScope = rows[0].scope;
|
|
376
|
+
updateMeta(current => {
|
|
377
|
+
if (rowScope === 'device') {
|
|
378
|
+
const native = { ...current.deviceAccounts?.native };
|
|
379
|
+
for (const row of rows)
|
|
380
|
+
native[row.id] = { ...native[row.id], name: newName };
|
|
381
|
+
return { ...current, deviceAccounts: { ...current.deviceAccounts, native } };
|
|
382
|
+
}
|
|
383
|
+
const native = { ...current.accounts?.native };
|
|
384
|
+
for (const row of rows)
|
|
385
|
+
native[row.id] = { ...native[row.id], name: newName };
|
|
386
|
+
return { ...current, accounts: { ...current.accounts, native } };
|
|
387
|
+
});
|
|
298
388
|
return;
|
|
299
389
|
}
|
|
300
390
|
const account = findAccount(oldName, doc);
|
|
@@ -306,14 +396,24 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
|
|
|
306
396
|
}
|
|
307
397
|
export function removeAccount(name, base = getUserAgentsDir()) {
|
|
308
398
|
const meta = readMeta();
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
311
|
-
const bindings = accountBindings(
|
|
399
|
+
const rows = nativeRowsForNameOrId(meta, name);
|
|
400
|
+
if (rows.length) {
|
|
401
|
+
const bindings = [...new Set(rows.flatMap(row => accountBindings(row.id, meta)))].sort();
|
|
312
402
|
if (bindings.length)
|
|
313
|
-
throw new Error(`Account '${
|
|
403
|
+
throw new Error(`Account '${rows[0].name}' is attached to: ${bindings.join(', ')}. Detach it before removing it.`);
|
|
404
|
+
const ids = new Set(rows.map(row => row.id));
|
|
405
|
+
// Sweep every row for the identity (PHNX-3206) from its owning store (PHNX-3315).
|
|
406
|
+
const rowScope = rows[0].scope;
|
|
314
407
|
updateMeta(current => {
|
|
408
|
+
if (rowScope === 'device') {
|
|
409
|
+
const accounts = { ...current.deviceAccounts?.native };
|
|
410
|
+
for (const id of ids)
|
|
411
|
+
delete accounts[id];
|
|
412
|
+
return { ...current, deviceAccounts: { ...current.deviceAccounts, native: accounts } };
|
|
413
|
+
}
|
|
315
414
|
const accounts = { ...current.accounts?.native };
|
|
316
|
-
|
|
415
|
+
for (const id of ids)
|
|
416
|
+
delete accounts[id];
|
|
317
417
|
return { ...current, accounts: { ...current.accounts, native: accounts } };
|
|
318
418
|
});
|
|
319
419
|
return;
|
|
@@ -355,6 +455,18 @@ export function resolveCredentialAccount(name, host, expectedProvider, base = ge
|
|
|
355
455
|
const envVar = account.auth === 'setup-token' ? 'CLAUDE_CODE_OAUTH_TOKEN' : adapter.envFor(host, account.auth);
|
|
356
456
|
if (!hasKeychainToken(account.secretRef))
|
|
357
457
|
throw new Error(`Credential for account '${account.name}' is missing on this device. Add it with 'agents accounts set-key ${account.name}'.`);
|
|
458
|
+
const secretVar = secretVarFor(account.auth);
|
|
459
|
+
// Account bundles are policy `never`, so their value items carry no biometry
|
|
460
|
+
// ACL. Resolve through the bundle path that verifies that policy and attests
|
|
461
|
+
// `silentNoAcl` to the headless keychain guard. Calling getKeychainToken()
|
|
462
|
+
// directly makes a headless --account launch reject the prompt-free item as
|
|
463
|
+
// if it required Touch ID before the helper ever reads it (PHNX-2939).
|
|
464
|
+
const secret = readAndResolveBundleEnv(account.name, {
|
|
465
|
+
keys: [secretVar],
|
|
466
|
+
keyMode: 'storage',
|
|
467
|
+
agentOnly: true,
|
|
468
|
+
caller: 'accounts resolve',
|
|
469
|
+
}).env[secretVar];
|
|
358
470
|
const connectionEnv = { ...adapter.connectionEnvFor(host) };
|
|
359
471
|
if (account.baseUrl) {
|
|
360
472
|
const baseUrlEnv = adapter.baseUrlEnvFor(host);
|
|
@@ -367,7 +479,7 @@ export function resolveCredentialAccount(name, host, expectedProvider, base = ge
|
|
|
367
479
|
name: account.name,
|
|
368
480
|
provider: account.provider,
|
|
369
481
|
auth: account.auth,
|
|
370
|
-
env: { ...connectionEnv, [envVar]:
|
|
482
|
+
env: { ...connectionEnv, [envVar]: secret },
|
|
371
483
|
};
|
|
372
484
|
}
|
|
373
485
|
/**
|
|
@@ -336,16 +336,30 @@ export declare function resolveAccountVersion(agent: AgentId, account: string):
|
|
|
336
336
|
export declare function selectBalancedVersion(agent: AgentId): Promise<RotateResult | null>;
|
|
337
337
|
/** Select the configured version if available, otherwise another available version. */
|
|
338
338
|
export declare function selectAvailableVersion(agent: AgentId, preferredVersion?: string | null): Promise<RotateResult | null>;
|
|
339
|
+
/**
|
|
340
|
+
* Resolve the version `agents run` should use when the caller did not pin
|
|
341
|
+
* one with `@version`. The caller supplies the effective strategy.
|
|
342
|
+
*
|
|
343
|
+
* `pinned` still prefers the workspace/global default, but it MUST NOT launch
|
|
344
|
+
* a logged-out (or revoked) default when the same device holds a signed-in
|
|
345
|
+
* version that can run — that was a silent 1-second death on fleet workers
|
|
346
|
+
* whose default home had no credential (PHNX-2685). A rate-limited pin is
|
|
347
|
+
* still honoured: `--strategy pinned` remains the escape hatch to force the
|
|
348
|
+
* default through a throttle. When the default is auth-blocked and nothing
|
|
349
|
+
* else is healthy, `exhausted` is set so the caller fails loud instead of
|
|
350
|
+
* spawning into a credential-less home.
|
|
351
|
+
*/
|
|
339
352
|
export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string, collect?: (agent: AgentId) => Promise<RotateCandidate[]>): Promise<{
|
|
340
353
|
version: string | null;
|
|
341
354
|
rotation: RotateResult | null;
|
|
342
355
|
/**
|
|
343
|
-
* Set when a
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
356
|
+
* Set when a strategy found ZERO healthy candidates among the installed
|
|
357
|
+
* versions: the full excluded set, so callers fail loud with per-account
|
|
358
|
+
* reasons instead of launching the exhausted pinned default (RUSH-2132).
|
|
359
|
+
* Also set for `pinned` when the default is logged out / revoked and no
|
|
360
|
+
* signed-in alternative exists (PHNX-2685). Undefined for successful picks
|
|
361
|
+
* and when no version is installed at all (the pre-existing not-installed
|
|
362
|
+
* path — there is no account to be "unhealthy").
|
|
349
363
|
*/
|
|
350
364
|
exhausted?: RotateCandidate[];
|
|
351
365
|
}>;
|
|
@@ -690,12 +690,6 @@ export async function selectBalancedVersion(agent) {
|
|
|
690
690
|
export async function selectAvailableVersion(agent, preferredVersion) {
|
|
691
691
|
return pickAvailableCandidate(await collectRunCandidates(agent), preferredVersion);
|
|
692
692
|
}
|
|
693
|
-
/**
|
|
694
|
-
* Resolve the version `agents run` should use when the caller did not pin
|
|
695
|
-
* one with `@version`. The caller supplies the effective strategy; if that
|
|
696
|
-
* strategy cannot find a usable candidate, fall back to the pinned
|
|
697
|
-
* workspace/global version.
|
|
698
|
-
*/
|
|
699
693
|
/**
|
|
700
694
|
* Record a rotation pick so parallel callers see it as recently-used.
|
|
701
695
|
* Writes a stamp file per agent — lightweight, no locking needed since
|
|
@@ -722,12 +716,49 @@ function readRotationStamp(agent) {
|
|
|
722
716
|
catch { /* missing or corrupt — treat as no stamp */ }
|
|
723
717
|
return null;
|
|
724
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* Resolve the version `agents run` should use when the caller did not pin
|
|
721
|
+
* one with `@version`. The caller supplies the effective strategy.
|
|
722
|
+
*
|
|
723
|
+
* `pinned` still prefers the workspace/global default, but it MUST NOT launch
|
|
724
|
+
* a logged-out (or revoked) default when the same device holds a signed-in
|
|
725
|
+
* version that can run — that was a silent 1-second death on fleet workers
|
|
726
|
+
* whose default home had no credential (PHNX-2685). A rate-limited pin is
|
|
727
|
+
* still honoured: `--strategy pinned` remains the escape hatch to force the
|
|
728
|
+
* default through a throttle. When the default is auth-blocked and nothing
|
|
729
|
+
* else is healthy, `exhausted` is set so the caller fails loud instead of
|
|
730
|
+
* spawning into a credential-less home.
|
|
731
|
+
*/
|
|
725
732
|
export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
|
|
726
733
|
const fallback = resolveVersion(agent, cwd);
|
|
734
|
+
const candidates = await collect(agent);
|
|
727
735
|
if (strategy === 'pinned') {
|
|
736
|
+
const pinnedCandidate = fallback
|
|
737
|
+
? candidates.find((c) => c.version === fallback)
|
|
738
|
+
: undefined;
|
|
739
|
+
// Auth-blocked pin: the home cannot authenticate, so launching it is a
|
|
740
|
+
// guaranteed miss. Prefer a signed-in sibling on this device.
|
|
741
|
+
if (pinnedCandidate && isSignInRecoverable(readinessFromCandidate(pinnedCandidate))) {
|
|
742
|
+
const rotation = pickAvailableCandidate(candidates, fallback);
|
|
743
|
+
if (rotation) {
|
|
744
|
+
emit('rotation.resolved', {
|
|
745
|
+
module: 'rotate',
|
|
746
|
+
agent,
|
|
747
|
+
version: rotation.picked.version,
|
|
748
|
+
strategy,
|
|
749
|
+
healthy: rotation.healthy.length,
|
|
750
|
+
excluded: rotation.excluded.length,
|
|
751
|
+
});
|
|
752
|
+
return { version: rotation.picked.version, rotation };
|
|
753
|
+
}
|
|
754
|
+
return {
|
|
755
|
+
version: fallback,
|
|
756
|
+
rotation: null,
|
|
757
|
+
exhausted: candidates.length > 0 ? candidates : undefined,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
728
760
|
return { version: fallback, rotation: null };
|
|
729
761
|
}
|
|
730
|
-
const candidates = await collect(agent);
|
|
731
762
|
const rotation = strategy === 'available'
|
|
732
763
|
? pickAvailableCandidate(candidates, fallback)
|
|
733
764
|
: pickBalancedCandidate(candidates);
|