@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.
- package/CHANGELOG.md +15 -0
- package/README.md +28 -1
- package/dist/bootstrap.js +40 -12
- package/dist/commands/accounts.d.ts +22 -0
- package/dist/commands/accounts.js +158 -35
- package/dist/commands/config.js +37 -0
- package/dist/commands/exec.js +21 -10
- package/dist/commands/update.js +169 -18
- package/dist/commands/versions.d.ts +11 -0
- package/dist/commands/versions.js +30 -4
- package/dist/commands/view.d.ts +69 -3
- package/dist/commands/view.js +235 -75
- package/dist/index.js +35 -2
- package/dist/lib/account-catalog.d.ts +97 -1
- package/dist/lib/account-catalog.js +134 -6
- package/dist/lib/account-registry.d.ts +43 -0
- package/dist/lib/account-registry.js +97 -2
- package/dist/lib/accounting/rotate.d.ts +2 -2
- package/dist/lib/accounting/rotate.js +5 -5
- package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
- package/dist/lib/accounts/auth-operation-lock.js +55 -0
- package/dist/lib/accounts/connect.d.ts +170 -0
- package/dist/lib/accounts/connect.js +383 -0
- package/dist/lib/capabilities.js +2 -0
- package/dist/lib/commands.js +2 -0
- package/dist/lib/config-keys.d.ts +11 -2
- package/dist/lib/config-keys.js +21 -1
- package/dist/lib/daemon/daemon.js +5 -0
- package/dist/lib/daemon/harness-update-service.d.ts +110 -0
- package/dist/lib/daemon/harness-update-service.js +216 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +0 -1
- package/dist/lib/device-config.js +50 -5
- package/dist/lib/exec.js +26 -2
- package/dist/lib/fs-atomic.d.ts +2 -0
- package/dist/lib/fs-atomic.js +2 -0
- package/dist/lib/hooks/install.js +7 -2
- package/dist/lib/installations/active-check.d.ts +48 -0
- package/dist/lib/installations/active-check.js +84 -0
- package/dist/lib/installations/index.d.ts +5 -1
- package/dist/lib/installations/index.js +4 -0
- package/dist/lib/installations/installation-lock.d.ts +6 -0
- package/dist/lib/installations/installation-lock.js +29 -0
- package/dist/lib/installations/launch-gate.d.ts +69 -0
- package/dist/lib/installations/launch-gate.js +133 -0
- package/dist/lib/installations/native-command.d.ts +5 -0
- package/dist/lib/installations/native-command.js +52 -0
- package/dist/lib/installations/shims.d.ts +8 -2
- package/dist/lib/installations/shims.js +105 -2
- package/dist/lib/installations/store.d.ts +5 -1
- package/dist/lib/installations/store.js +24 -3
- package/dist/lib/installations/strategies.js +55 -35
- package/dist/lib/installations/types.d.ts +17 -0
- package/dist/lib/installations/update-cancellation.d.ts +82 -0
- package/dist/lib/installations/update-cancellation.js +122 -0
- package/dist/lib/installations/update-policy.d.ts +69 -0
- package/dist/lib/installations/update-policy.js +114 -0
- package/dist/lib/installations/update-runtime.d.ts +118 -0
- package/dist/lib/installations/update-runtime.js +321 -0
- package/dist/lib/installations/update.d.ts +25 -0
- package/dist/lib/installations/update.js +141 -2
- package/dist/lib/installations/versions.d.ts +1 -0
- package/dist/lib/installations/versions.js +166 -131
- package/dist/lib/platform/process.d.ts +3 -1
- package/dist/lib/platform/process.js +2 -2
- package/dist/lib/staleness/detectors/commands.d.ts +1 -2
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
- package/dist/lib/staleness/detectors/rules.d.ts +1 -2
- package/dist/lib/staleness/detectors/skills.d.ts +1 -2
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
- package/dist/lib/staleness/writers/commands.d.ts +1 -2
- package/dist/lib/staleness/writers/hooks.d.ts +1 -2
- package/dist/lib/staleness/writers/mcp.d.ts +1 -2
- package/dist/lib/staleness/writers/permissions.d.ts +1 -12
- package/dist/lib/staleness/writers/plugins.d.ts +1 -6
- package/dist/lib/staleness/writers/rules.d.ts +1 -2
- package/dist/lib/staleness/writers/skills.d.ts +1 -2
- package/dist/lib/staleness/writers/subagents.d.ts +1 -2
- package/dist/lib/staleness/writers/workflows.d.ts +1 -8
- package/dist/lib/state.d.ts +3 -1
- package/dist/lib/state.js +38 -13
- package/dist/lib/types.d.ts +26 -1
- package/dist/lib/types.js +5 -0
- package/dist/lib/view-types.d.ts +6 -0
- 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
|
|
33
|
-
const
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
782
|
+
const matching = candidates.filter((c) => c.signedIn &&
|
|
783
783
|
(c.email?.toLowerCase() === needle || c.accountKey?.toLowerCase() === needle));
|
|
784
|
-
return
|
|
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>;
|